Unix/C/C++--线程

1 线程的简介

  • 多进程并发编程中,我们为每个流使用了单独的进程。内核会自动调度每个进程,而每个进程有自己的私有地址空间,这使得流共享数据很困难。I/O复用并发编程中,我们创建自己的逻辑流,并利用I/O多路复用显式地调度流。因为只有一个进程,
    所有的流共享整个地址空间。线程是这两种方法的混合。
  • 线程(thread)就是运行在进程上下文中的逻辑流。线程由内核自动调度。每个线程都有它自己的线程上下文(thread contxt),包括唯一的整数线程ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。
  • 基于线程的逻辑流结合了基于进程和基于I/O多路复用的流的特性。同进程一样,线程由内核自动调度,并且内核通过一个整数ID来识别线程。同基于I/O多路复用的流一样,多个线程运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的所有内容,包括它的代码、数据、堆、共享库和打开的文件。

2 进程和线程关系及区别

2.1 线程

线程只有 3 个基本状态:就绪,执行,阻塞。
线程存在 5 种基本操作来切换线程的状态:派生,阻塞,激活,调度,结束。

2.2 进程

进程通信有 4 种形式:主从式,会话式,消息或邮箱机制,共享存储区方式。

3 线程执行模型

在这里插入图片描述
每个进程开始生命周期时都是单一线程,这个线程称为主线程(main thread)。
在某一时刻,主线程创建了一个对等线程(peer thread),从这个时间点开始,两个线程就并发地运行。
**因为主线程执行一个慢速系统调用,例如read或者sleep,或者因为被系统的时间间隔计时器中断,控制就会通过上下文切换传递到对等线程。**对等线程会执行一段时间,然后控制传递回主线程,一次类推。在一些重要的方面,线程执行是不同于进程的。因为一个线程的上下文要比一个进程的上下文小很多,线程的上下文切换要比进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子进程来组织的。
和一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。
主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。
**对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。**另外,每个对等线程都能读写相同的共享数据。

4 Posix 线程

4.1 介绍

Posix 线程(Pthreads)是在C程序中处理线程的一个标准接口。它最早出现在1995年,而且在所有的Linux系统上都可用。Pthread定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。(POSIX表示可移植操作系统接口(Portable Operating System Interface of UNIX,缩写为 POSIX ),POSIX标准定义了操作系统应该为应用程序提供的接口标准)

4.2 示例

//
#include “csapp.h”
 
woid *thread(void *vargp);
 
int main(int argc, char **argv)
{
	pthread_t tid;
	Pthread_create(&tid, NULL, thread, NULL);
	Pthread_join(tid, NULL);
	exit(0);
}
 
void *thread(void *vargp)	/* Thread routine */
{
	printf("Hello, world!\n");
	return NULL;
}

(1)上面展示了一个简单的Pthreads程序。主程序创建一个对等线程,然后等待它的终止。对等线程输出“Hello, world!\n”
并且终止。当主线程检测到对等线程终止后,它就通过调用exit终止该进程。线程的代码和本地数据被封装在一个线程例程(threa routine)中。
(2)正如函数void *thread(void *vargp);所示,每个线程例程都以一个通用指针作为输入,并返回一个通用指针。如果想传递多个参数给线程例程,应该将参数放到一个结构体中,并传递一个指向该结构的指针。相似地,如果想要线程例程返回多个参数,可以返回一个结构体的指着。
(3)pthread_t tid;主线程声明了一个本地变量tid,可以用来存放对等线程的ID。
(4)Pthread_create(&tid, NULL, thread, NULL); 主线程调用此函数创建一个新的对等线程。函数调用返回后,主线程和新的对等线程同时运行。
(5)Pthread_join(tid, NULL); 调用此函数,主线程等待对等线程终止。
(6)exit(0);调用此函数,终止当时运行在这个进程中的所有线程。

4.3 主要API的介绍

4.3.1 创建线程pthread_create

#include <pthread.h>
typedef  void *(func)(void *);
int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
返回:成功返回0,出错返回非0

pthread_create函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程。能用attr参数来改变
新创建线程的默认属性。在之后示例中,总是用一个为NULL的attr参数来调用pthread_self函数来获得它自己的线程ID。

#include <pthread.h>
pthread_t pthread_self(void);
返回调用者的线程ID。

4.3.2 终止线程pthread_exit

一个线程终止的方式如下:
A、当顶层的线程例程返回时,线程会隐式终止。
B、通过**调用pthread_exit函数,线程会显式地终止。**如果主线程调用pthread_exit,它会等待所有其他对等线程终止,返回值为thread_return.

#include <pthread.h>
void pthread_exit(void *thread_return);

A、某个对等线程调用Linux的exit函数,该函数终止进程以及所有与进程相关的线程。
B、另一个对等线程通过以当前线程ID作为参数调用pthread_cancel函数来终止当前线程。

#include  <pthread.h>
int pthread_cancel(pthread_t tid);
返回:成功返回0,出错返回非0

4.3.3 回收已终止线程的资源pthread_join

线程通过调用pthread_join函数等待其他线程终止。

#include <pthread.h>
int pthread_join(pthread_t tid, void **thread_return);
返回:成功返回0,出错返回非0

pthread_join函数会阻塞,直到线程tid终止,将线程例程返回的通用(void*)指针赋值为thread_return指向的位置,然后回收已终止线程占用的所有的内存资源。
注意,和Linux的wait函数不同,pthread_join函数只能等待一个指定的线程终止。没有办法让pthread_wait等待任意一个线程终止。这使得代码变得复杂,这是一个不合理的机制。

4.3.4 分离线程pthread_detach

在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detache)。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源(如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放。默认情况下,线程被创建成可结合的。为了避免内存泄漏,每个可结合线程都应该要么被其他线程显式地收回,要么通过调用pthread_detach函数被分离。

#include <pthread.h>
int pthread_detach(pthread_t tid);
返回:成功返回0,出错返回非0

pthread_detach函数分离可结合线程tid.线程能够通过以pthread_self()为参数的pthead_detach调用来分离它们自己。在实际应用程序中,有很好的理由要使用分离的线程。例如,一个高性能Web服务器可能在每次收到web浏览器的连接请求时都创建一个新的对等线程。因为每个连接都是由一个单独的线程独立处理的,所以对于服务器而言,就没有必要显式地等待每个对等线程终止。此情况下,每个对等线程都应该在它开始处理请求之前分离它自身,这样就能在它终止后回收它的内存资源了。

4.3.5 初始化线程pthread_once

pthread_once函数允许你初始化与线程例程相关的状态。

#include <pthread.h>
pthread_once_t once_control = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
返回:总是0

once_control变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT。当你第一次用参数once_conteol调用pthread_once时,它调用init_routine,这是一个没有输入参数、也不返回什么的函数。接下来的以once_control为参数的pthread_once调用不做任何事情。无论何时,当你需要动态初始化多个线程贡献的全局变量时,pthread_once函数是很有用的。

5 多线程程序中的共享变量

5.1 线程内存模型

  • 一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享相同的打开文件的集合。
  • 从实际操作来说,让一个线程去读写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟内存的任意位置。
  • 各自独立的线程栈的内存模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问。这里说的是通常而不是总是,是因为不同的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任何部分。示例中printf("[%d]: %s (cnt=%d)\n", myid, ptr[myid], ++cnt);对等线程直接通过全局变量ptr间接引用主线程的栈的内容。
#include "csapp.h"
#define N 2
 
void *thread(void *vargp);
 
char **ptr;	/* global variable */
 
int main()
{
	int i;
	pthread_t tid;
	char *msgs[N] = {
		"Hello from foo",
		"Hello from bar"
	};
	
	ptr = msgs;
	for (i = 0; i < N; i++)
		pthread_create(&tid, NULL, thread, (void *)i);
	pthread_exit(NULL);
}
void *thread(void *vargp)
{
	int myid = (int)vargp;
	static int cnt = 0;
	printf("[%d]: %s(cnt = %d)\n", myid, ptr[myid], ++cnt);
	return NULL;
}

5.2 将变量映射到内存

多线程的C程序中变量根据它们的存储类型被映射到虚拟内存:

  • 全局变量。全局变量定义在函数之外,在运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,在任何线程都可以引用。如上述代码中的ptr.当一个变量只有一个实例时,我们只用变量名来表示这个实例。
  • 本地自动变量。本地自动变量就是定义在函数内部但是没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使多个线程在执行同一个线程例程时也是如此。
  • 本地静态变量。本地静态变量是定义在函数内部并有static属性的变量。和全局变量一样,虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。例如上面代码thread函数中的static int cnt = 0;在运行时,虚拟内存的续/写区域中也只有一个cnt的实例。每个对等线程都读/写这个实例。

5.3 共享变量

一个变量共享,当且仅当它的一个实例被一个以上的线程引用。如上面代码中变量cnt是共享的,因为它只有一个运行实例,并且这个实例被两个对等线程引用。变量myid不是共享的,因为它的两个实例中每一个只被一个线程引用。

6 并发

并发下,进程、线程的优缺点比较

6.1 进程

深入理解计算机系统-- 并发编程(1)
构造并发程序最简单的方法就是用进程,使用那些大家都熟悉的函数,像fork、exec和waitpid。

6.1.1 优缺点

对于在父子进程间共享状态信息,进程有一个非常清晰的模型**:共享文件表**,但是不共享用户地址空间。进程有独立的地址空间即使优点优点也是缺点。

  • 优点:有独立的地址空间,一个进程不可能不小心覆盖另一个进程的虚拟内存,这就消除了许多令人迷惑的错误。
  • 缺点:独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。
  • 缺点:基于进程的设计另一个缺点是,它们往往比较慢,因为进程控制和IPC的开销很高

6.2 线程

7 线程同步

  • 多线程共享内存时,共享的变量,如果是只读的,不存在一致性问题,可读写的需要进行同步,确保变量访问不会访问到无效值。
  • 当存储器读和存储器写这两个周期交叉时,会出现访问不一致。读写周期的交叉取决于处理器的体系结构和多线程访问变量一致性。
  • 如果修改操作是原子操作,不存在一致性问题,但是目前大多处理器,存储访问都是多个总线周期,多处理器的总线周期通常在多个处理器上是交叉的,易造成数据顺序不一致性。

7.1 互斥量

7.1.1 简介

互斥量mutex本质上是一把锁,访问加锁,结束解锁。

7.1.2 死锁

线程试图对同一个互斥量加锁两次,那么自身就会陷入死锁两次。
锁的粒度太粗,造成多线程阻塞,
锁的粒度太细,造成锁开销大。

7.1.3 超时

pthread_mutex_timelock
设置加锁时间,超时不再加锁,返回错误码ETIMEDOUT

7.2 读写锁(共享互斥锁)

7.2.1 简介

  • 读写锁类似互斥量,允许更高并发性。
  • 3种状态:
    (1)读模式下加锁
    (2)写模式下加锁
    (3)不加锁
  • 读写锁在写加锁状态时,所有其他线程会在加锁的地方阻塞;
    读写锁在读加锁状态时,所有其他线程会在写模式加锁的地方阻塞,所有其他线程会在读模式加锁的地方获取访问权限。
    读写锁适合对数据结构读次数远大于写的地方。

7.2.2 超时

pthread_rwlock_timerdlock
pthread_rwlock_timewrlock

7.2.3 函数介绍

在这里插入图片描述

7.3 条件变量

当需要死循环判断某个条件成立与否时【true or false】,我们往往需要开一个线程死循环来判断,这样非常消耗CPU。使用条件变量,可以让当前线程wait,释放CPU,如果条件改变时,我们再notify退出线程,再次进行判断。

7.4 自旋锁

7.4.1 介绍

  • 自旋锁是一种用于保护多线程共享资源的锁,与一般互斥锁(mutex)不同之处在于当自旋锁尝试获取锁时以忙等待(busy waiting)的形式不断地循环检查锁是否可用。
  • 在多CPU的环境中,对持有锁较短的程序来说,使用自旋锁代替一般的互斥锁往往能够提高程序的性能。

7.4.2 自旋锁使用时注意事项

由于自旋时不释放CPU,因而持有自旋锁的线程应该尽快释放自旋锁,否则等待该自旋锁的线程会一直在哪里自旋,这就会浪费CPU时间。
持有自旋锁的线程在sleep之前应该释放自旋锁以便其他咸亨可以获得该自旋锁。内核编程中,如果持有自旋锁的代码sleep了就可能导致整个系统挂起。(下面会解释)

7.4.3 自旋锁和互斥锁的区别

  • 使用任何锁都需要消耗系统资源(内存资源和CPU时间),这种资源消耗可以分为两类:
    (1)建立锁所需要的资源
    (2)当线程被阻塞时所需要的资源
  • 从实现原理上来讲,Mutex属于sleep-waiting类型的 锁。例如在一个双核的机器上有两个线程(线程A和线程B),它们分别运行在Core0和Core1上。假设线程A想要通过 pthread_mutex_lock操作去得到一个临界区的锁,而此时这个锁正被线程B所持有,那么线程A就会被阻塞(blocking),Core0 会在此时进行上下文切换(Context Switch)将线程A置于等待队列中,此时Core0就可以运行其他的任务(例如另一个线程C)而不必进行忙等待。而Spin lock则不然,它属于busy-waiting类型的锁,如果线程A是使用pthread_spin_lock操作去请求锁,那么线程A就会一直在 Core0上进行忙等待并不停的进行锁请求,直到得到这个锁为止。
  • 对于自旋锁来说,它只需要消耗很少的资源来建立锁;随后当线程被阻塞时,它就会一直重复检查看锁是否可用了,也就是说当自旋锁处于等待状态时它会一直消耗CPU时间。
    对于互斥锁来说,与自旋锁相比它需要消耗大量的系统资源来建立锁;随后当线程被阻塞时,线程的调度状态被修改,并且线程被加入等待线程队列;最后当锁可用 时,在获取锁之前,线程会被从等待队列取出并更改其调度状态;但是在线程被阻塞期间,它不消耗CPU资源。
    因此自旋锁和互斥锁适用于不同的场景。
    自旋锁适用于那些仅需要阻塞很短时间的场景,
    而互斥锁适用于那些可能会阻塞很长时间的场景。

7.5 屏障

9、内核memory barrier学习

7.6 锁的性能比较

不同的锁有不同的应用场景,性能比较见C++互斥量、原子锁、自旋锁等比较
注:博主目前没有测试对比,后期有时间再测试对比
(1)单线程无锁速度最快,但应用场合受限;
(2)多线程无锁速度第二快,但结果不对,未保护临界代码段;
(3)多线程原子锁第三快,且结果正确;
(4)多线程互斥量较慢,慢与原子锁近10倍,结果正确;
(5)多线程自旋锁最慢,慢与原子锁30倍,结果正确。
结论:原子锁速度最快,互斥量和自旋锁都用保护多线程共享资源。

参考

1、深入理解计算机系统之十: 并发编程(2)
2、进程和线程关系及区别 (实用)
3、深入理解计算机系统-- 并发编程(1)
4、条件变量、pthread_cond_init
5、《UNIX环境高级编程》
6、C++条件变量
7、C++互斥量、原子锁、自旋锁等比较
8、自旋锁与互斥锁的对比、手工实现自旋锁
9、内核memory barrier学习

展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 技术黑板 设计师: CSDN官方博客
应支付0元
点击重新获取
扫码支付

支付成功即可阅读