线程同步
线程同步
苏丙榅1. 线程同步概念
假设有4个线程A、B、C、D,当前一个线程A对内存中的共享资源
进行访问的时候,其他线程B, C, D都不可以对这块内存进行操作,直到线程A对这块内存访问完毕为止,B,C,D中的一个才能访问这块内存,剩余的两个需要继续阻塞等待,以此类推,直至所有的线程都对这块内存操作完毕。 线程对内存的这种访问方式就称之为线程同步,通过对概念的介绍,我们可以了解到所谓的同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行的。
1.1 为什么要同步
在研究线程同步之前,先来看一个两个线程交替数数(每个线程数50个数,交替数到100)的例子:
1 |
|
编译并执行上面的测试程序,得到如下结果:
1 | $ ./a.out |
通过对上面例子的测试,可以看出虽然每个线程内部循环了50次每次数一个数,但是最终没有数到100,通过输出的结果可以看到,有些数字被重复数了多次,其原因就是没有对线程进行同步处理,造成了数据的混乱。
两个线程在数数的时候需要分时复用CPU时间片,并且测试程序中调用了sleep()
导致线程的CPU时间片没用完就被迫挂起了,这样就能让CPU的上下文切换(保存当前状态, 下一次继续运行的时候需要加载保存的状态)更加频繁,更容易再现数据混乱的这个现象。
CPU对应寄存器、一级缓存、二级缓存、三级缓存是独占的,用于存储处理的数据和线程的状态信息,数据被CPU处理完成需要再次被写入到物理内存中,物理内存数据也可以通过文件IO操作写入到磁盘中。
在测试程序中两个线程共用全局变量number
当线程变成运行态之后开始数数,从物理内存加载数据,让后将数据放到CPU进行运算,最后将结果更新到物理内存中。如果数数的两个线程都可以顺利完成这个流程,那么得到的结果肯定是正确的。
如果线程A执行这个过程期间就失去了CPU时间片,线程A被挂起了最新的数据没能更新到物理内存。线程B变成运行态之后从物理内存读数据,很显然它没有拿到最新数据,只能基于旧的数据往后数,然后失去CPU时间片挂起。线程A得到CPU时间片变成运行态,第一件事儿就是将上次没更新到内存的数据更新到内存,但是这样会导致线程B已经更新到内存的数据被覆盖,活儿白干了,最终导致有些数据会被重复数很多次。
1.2 同步方式
对于多个线程访问共享资源出现数据混乱的问题,需要进行线程同步。常用的线程同步方式有四种:互斥锁、读写锁、条件变量、信号量。所谓的共享资源就是多个线程共同访问的变量,这些变量通常为全局数据区变量或者堆区变量,这些变量对应的共享资源也被称之为临界资源。
找到临界资源之后,再找和临界资源相关的上下文代码,这样就得到了一个代码块,这个代码块可以称之为临界区。确定好临界区(临界区越小越好)之后,就可以进行线程同步了,线程同步的大致处理思路是这样的:
- 在临界区代码的上边,添加加锁函数,对临界区加锁。
- 哪个线程调用这句代码,就会把这把锁锁上,其他线程就只能阻塞在锁上了。
- 在临界区代码的下边,添加解锁函数,对临界区解锁。
- 出临界区的线程会将锁定的那把锁打开,其他抢到锁的线程就可以进入到临界区了。
- 通过锁机制能保证临界区代码最多只能同时有一个线程访问,这样并行访问就变为串行访问了。
2. 互斥锁
2.1 互斥锁函数
互斥锁是线程同步最常用的一种方式,通过互斥锁可以锁定一个代码块, 被锁定的这个代码块, 所有的线程只能顺序执行(不能并行处理),这样多线程访问共享资源数据混乱的问题就可以被解决了,需要付出的代价就是执行效率的降低,因为默认临界区多个线程是可以并行处理的,现在只能串行处理。
在Linux中互斥锁的类型为pthread_mutex_t
,创建一个这种类型的变量就得到了一把互斥锁:
1 | pthread_mutex_t mutex; |
在创建的锁对象中保存了当前这把锁的状态信息:锁定还是打开,如果是锁定状态还记录了给这把锁加锁的线程信息(线程ID)。一个互斥锁变量只能被一个线程锁定,被锁定之后其他线程再对互斥锁变量加锁就会被阻塞,直到这把互斥锁被解锁,被阻塞的线程才能被解除阻塞。一般情况下,每一个共享资源对应一个把互斥锁,锁的个数和线程的个数无关。
Linux 提供的互斥锁操作函数如下,如果函数调用成功会返回0,调用失败会返回相应的错误号:
1 | // 初始化互斥锁 |
- 参数:
- mutex: 互斥锁变量的地址
- attr: 互斥锁的属性, 一般使用默认属性即可, 这个参数指定为NULL
1 | // 修改互斥锁的状态, 将其设定为锁定状态, 这个状态被写入到参数 mutex 中 |
这个函数被调用, 首先会判断参数 mutex 互斥锁中的状态是不是锁定状态:
- 没有被锁定, 是打开的, 这个线程可以加锁成功, 这个这个锁中会记录是哪个线程加锁成功了
- 如果被锁定了, 其他线程加锁就失败了, 这些线程都会阻塞在这把锁上
- 当这把锁被解开之后, 这些阻塞在锁上的线程就解除阻塞了,并且这些线程是通过竞争的方式对这把锁加锁,没抢到锁的线程继续阻塞
1 | // 尝试加锁 |
调用这个函数对互斥锁变量加锁还是有两种情况:
- 如果这把锁没有被锁定是打开的,线程加锁成功
- 如果锁变量被锁住了,调用这个函数加锁的线程,不会被阻塞,加锁失败直接返回错误号
1 | // 对互斥锁解锁 |
不是所有的线程都可以对互斥锁解锁,哪个线程加的锁, 哪个线程才能解锁成功。
2.1 互斥锁使用
我们可以将上面多线程交替数数的例子修改一下,使用互斥锁进行线程同步。两个线程一共操作了同一个全局变量,因此需要添加一互斥锁,来控制这两个线程。
1 |
|
3. 死锁
当多个线程访问共享资源, 需要加锁, 如果锁使用不当, 就会造成死锁这种现象。如果线程死锁造成的后果是:所有的线程都被阻塞,并且线程的阻塞是无法解开的(因为可以解锁的线程也被阻塞了)。
造成死锁的场景有如下几种:
加锁之后忘记解锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33// 场景1
void func()
{
for(int i=0; i<6; ++i)
{
// 当前线程A加锁成功, 当前循环完毕没有解锁, 在下一轮循环的时候自己被阻塞了
// 其余的线程也被阻塞
pthread_mutex_lock(&mutex);
....
.....
// 忘记解锁
}
}
// 场景2
void func()
{
for(int i=0; i<6; ++i)
{
// 当前线程A加锁成功
// 其余的线程被阻塞
pthread_mutex_lock(&mutex);
....
.....
if(xxx)
{
// 函数退出, 没有解锁(解锁函数无法被执行了)
return ;
}
pthread_mutex_lock(&mutex);
}
}重复加锁, 造成死锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42void func()
{
for(int i=0; i<6; ++i)
{
// 当前线程A加锁成功
// 其余的线程阻塞
pthread_mutex_lock(&mutex);
// 锁被锁住了, A线程阻塞
pthread_mutex_lock(&mutex);
....
.....
pthread_mutex_unlock(&mutex);
}
}
// 隐藏的比较深的情况
void funcA()
{
for(int i=0; i<6; ++i)
{
// 当前线程A加锁成功
// 其余的线程阻塞
pthread_mutex_lock(&mutex);
....
.....
pthread_mutex_unlock(&mutex);
}
}
void funcB()
{
for(int i=0; i<6; ++i)
{
// 当前线程A加锁成功
// 其余的线程阻塞
pthread_mutex_lock(&mutex);
funcA(); // 重复加锁
....
.....
pthread_mutex_unlock(&mutex);
}
}在程序中有多个共享资源, 因此有很多把锁,随意加锁,导致相互被阻塞
1
2
3
4
5
6
7场景描述:
1. 有两个共享资源:X, Y,X对应锁A, Y对应锁B
- 线程A访问资源X, 加锁A
- 线程B访问资源Y, 加锁B
2. 线程A要访问资源Y, 线程B要访问资源X,因为资源X和Y已经被对应的锁锁住了,因此这个两个线程被阻塞
- 线程A被锁B阻塞了, 无法打开A锁
- 线程B被锁A阻塞了, 无法打开B锁
在使用多线程编程的时候,如何避免死锁呢?
避免多次锁定, 多检查
对共享资源访问完毕之后, 一定要解锁,或者在加锁的使用 trylock
如果程序中有多把锁, 可以控制对锁的访问顺序(顺序访问共享资源,但在有些情况下是做不到的),另外也可以在对其他互斥锁做加锁操作之前,先释放当前线程拥有的互斥锁。
项目程序中可以引入一些专门用于死锁检测的模块
4. 读写锁
4.1 读写锁函数
读写锁是互斥锁的升级版, 在做读操作的时候可以提高程序的执行效率,如果所有的线程都是做读操作, 那么读是并行的
,但是使用互斥锁,读操作也是串行的。
读写锁是一把锁,锁的类型为pthread_rwlock_t
,有了类型之后就可以创建一把互斥锁了:
1 | pthread_rwlock_t rwlock; |
之所以称其为读写锁,是因为这把锁既可以锁定读操作,也可以锁定写操作。为了方便理解,可以大致认为在这把锁中记录了这些信息:
- 锁的状态: 锁定/打开
- 锁定的是什么操作: 读操作/写操作,
使用读写锁锁定了读操作,需要先解锁才能去锁定写操作,反之亦然。
- 哪个线程将这把锁锁上了
读写锁的使用方式也互斥锁的使用方式是完全相同的:找共享资源, 确定临界区,在临界区的开始位置加锁(读锁/写锁),临界区的结束位置解锁。
因为通过一把读写锁可以锁定读或者写操作,下面介绍一下关于读写锁的特点:
- 使用读写锁的读锁锁定了临界区,线程对临界区的访问是并行的,
读锁是共享的。
- 使用读写锁的写锁锁定了临界区,线程对临界区的访问是串行的,
写锁是独占的。
- 使用读写锁分别对两个临界区加了读锁和写锁,两个线程要同时访问者两个临界区,访问写锁临界区的线程继续运行,访问读锁临界区的线程阻塞,因为
写锁比读锁的优先级高。
如果说程序中所有的线程都对共享资源做写操作,使用读写锁没有优势,和互斥锁是一样的,如果说程序中所有的线程都对共享资源有写也有读操作,并且对共享资源读的操作越多,读写锁更有优势。
Linux提供的读写锁操作函数原型如下,如果函数调用成功返回0,失败返回对应的错误号:
1 |
|
- 参数:
- rwlock: 读写锁的地址,传出参数
- attr: 读写锁属性,一般使用默认属性,指定为NULL
1 | // 在程序中对读写锁加读锁, 锁定的是读操作 |
调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数的线程会被阻塞。
1 | // 这个函数可以有效的避免死锁 |
调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数加锁失败,对应的线程不会被阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作。
1 | // 在程序中对读写锁加写锁, 锁定的是写操作 |
调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数的线程会被阻塞。
1 | // 这个函数可以有效的避免死锁 |
调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数加锁失败,但是线程不会阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作。
1 | // 解锁, 不管锁定了读还是写都可用解锁 |
4.2 读写锁使用
题目要求:8个线程操作同一个全局变量,3个线程不定时写同一全局资源,5个线程不定时读同一全局资源。
1 |
|
5. 条件变量
5.1 条件变量函数
严格意义上来说,条件变量的主要作用不是处理线程同步, 而是进行线程的阻塞。
如果在多线程程序中只使用条件变量无法实现线程的同步, 必须要配合互斥锁来使用。虽然条件变量和互斥锁都能阻塞线程,但是二者的效果是不一样的,二者的区别如下:
- 假设有A-Z 26个线程,这26个线程共同访问同一把互斥锁,如果线程A加锁成功,那么其余B-Z线程访问互斥锁都阻塞,所有的线程只能顺序访问临界区
- 条件变量只有在满足指定条件下才会阻塞线程,如果条件不满足,多个线程可以同时进入临界区,同时读写临界资源,这种情况下还是会出现共享资源中数据的混乱。
一般情况下条件变量用于处理生产者和消费者模型,并且和互斥锁配合使用。条件变量类型对应的类型为pthread_cond_t
,这样就可以定义一个条件变量类型的变量了:
1 | pthread_cond_t cond; |
被条件变量阻塞的线程的线程信息会被记录到这个变量中,以便在解除阻塞的时候使用。
条件变量操作函数函数原型如下:
1 |
|
- 参数:
cond: 条件变量的地址
attr: 条件变量属性, 一般使用默认属性, 指定为NULL
1 | // 线程阻塞函数, 哪个线程调用这个函数, 哪个线程就会被阻塞 |
通过函数原型可以看出,该函数在阻塞线程的时候,需要一个互斥锁参数,这个互斥锁主要功能是进行线程同步,让线程顺序进入临界区,避免出现数共享资源的数据混乱。该函数会对这个互斥锁做以下几件事情:
- 在阻塞线程时候,如果线程已经对互斥锁
mutex
上锁,那么会将这把锁打开,这样做是为了避免死锁 - 当线程解除阻塞的时候,函数内部会帮助这个线程再次将这个
mutex
互斥锁锁上,继续向下访问临界区
1 | // 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示 |
这个函数的前两个参数和pthread_cond_wait
函数是一样的,第三个参数表示线程阻塞的时长,但是需要额外注意一点:struct timespec
这个结构体中记录的时间是从1971.1.1到某个时间点的时间,总长度使用秒/纳秒表示。
因此赋值方式相对要麻烦一点:
1 | time_t mytim = time(NULL); // 1970.1.1 0:0:0 到当前的总秒数 |
1 | // 唤醒阻塞在条件变量上的线程, 至少有一个被解除阻塞 |
调用上面两个函数中的任意一个,都可以换线被pthread_cond_wait
或者pthread_cond_timedwait
阻塞的线程,区别就在于pthread_cond_signal
是唤醒至少一个被阻塞的线程(总个数不定),pthread_cond_broadcast
是唤醒所有被阻塞的线程。
5.2 生产者和消费者
生产者和消费者模型的组成:
- 生产者线程 -> 若干个
- 生产商品或者任务放入到任务队列中
- 任务队列满了就阻塞, 不满的时候就工作
- 通过一个生产者的条件变量控制生产者线程阻塞和非阻塞
- 消费者线程 -> 若干个
- 读任务队列, 将任务或者数据取出
- 任务队列中有数据就消费,没有数据就阻塞
- 通过一个消费者的条件变量控制消费者线程阻塞和非阻塞
- 队列 -> 存储任务/数据,对应一块内存,为了读写访问可以通过一个数据结构维护这块内存
- 可以是数组、链表,也可以使用stl容器:queue / stack / list / vector
场景描述:使用条件变量实现生产者和消费者模型,生产者有5个,往链表头部添加节点,消费者也有5个,删除链表头部的节点。
1 |
|
代码分析
1 | void* consumer(void* arg) |
6. 信号量
6.1 信号量函数
信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作。信号量不一定是锁定某一个资源,而是流程上的概念,比如:有A,B两个线程,B线程要等A线程完成某一任务以后再进行自己下面的步骤,这个任务并不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。
信号量(信号灯)
与互斥锁和条件变量的主要不同在于”灯”的概念,灯亮则意味着资源可用,灯灭则意味着不可用。信号量主要阻塞线程, 不能完全保证线程安全,如果要保证线程安全, 需要信号量和互斥锁一起使用。
信号量和条件变量一样用于处理生产者和消费者模型,用于阻塞生产者线程或者消费者线程的运行。信号的类型为sem_t
对应的头文件为<semaphore.h>
:
1 |
|
Linux提供的信号量操作函数原型如下:
1 |
|
- 参数:
- sem:信号量变量地址
- pshared:
- 0:线程同步
- 非0:进程同步
- value:初始化当前信号量拥有的资源数(>=0),如果资源数为0,线程就会被阻塞了。
1 | // 参数 sem 就是 sem_init() 的第一个参数 |
当线程调用这个函数,并且sem
中的资源数>0
,线程不会阻塞,线程会占用sem
中的一个资源,因此资源数-1,直到sem
中的资源数减为0
时,资源被耗尽,因此线程也就被阻塞了。
1 | // 参数 sem 就是 sem_init() 的第一个参数 |
当线程调用这个函数,并且sem
中的资源数>0
,线程不会阻塞,线程会占用sem
中的一个资源,因此资源数-1,直到sem
中的资源数减为0
时,资源被耗尽,但是线程不会被阻塞,直接返回错误号,因此可以在程序中添加判断分支,用于处理获取资源失败之后的情况。
1 | // 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示 |
该函数的参数abs_timeout
和pthread_cond_timedwait
的最后一个参数是一样的,使用方法不再过多赘述。当线程调用这个函数,并且sem
中的资源数>0
,线程不会阻塞,线程会占用sem
中的一个资源,因此资源数-1,直到sem
中的资源数减为0
时,资源被耗尽,线程被阻塞,当阻塞指定的时长之后,线程解除阻塞。
1 | // 调用该函数给sem中的资源数+1 |
调用该函数会将sem
中的资源数+1
,如果有线程在调用sem_wait
、sem_trywait
、sem_timedwait
时因为sem
中的资源数为0
被阻塞了,这时这些线程会解除阻塞,获取到资源之后继续向下运行。
1 | // 查看信号量 sem 中的整形数的当前值, 这个值会被写入到sval指针对应的内存中 |
通过这个函数可以查看sem
中现在拥有的资源个数,通过第二个参数sval
将数据传出,也就是说第二个参数的作用和返回值是一样的。
6.2 生产者和消费者
由于生产者和消费者是两类线程,并且在还没有生成之前是不能进行消费的,在使用信号量处理这类问题的时候可以定义两个信号量,分别用于记录生产者和消费者线程拥有的总资源数。
1 | // 生产者线程 |
通过上面的代码可以知道,初始化信号量的时候没有消费者分配资源,消费者线程启动之后由于没有资源自然就被阻塞了,等生产者生产出产品之后,再给消费者分配资源,这样二者就可以配合着完成生产和消费流程了。
6.3 信号量使用
场景描述:使用信号量实现生产者和消费者模型,生产者有5个,往链表头部添加节点,消费者也有5个,删除链表头部的节点。
6.3.1 总资源数为1
如果生产者和消费者线程使用的信号量对应的总资源数为1,那么不管线程有多少个,可以工作的线程只有一个,其余线程由于拿不到资源,都被迫阻塞了。
1 |
|
通过测试代码可以得到如下结论:如果生产者和消费者使用的信号量总资源数为1,那么不会出现生产者线程和消费者线程同时访问共享资源的情况,不管生产者和消费者线程有多少个,它们都是顺序执行的。
6.3.2 总资源数大于1
如果生产者和消费者线程使用的信号量对应的总资源数为大于1,这种场景下出现的情况就比较多了:
- 多个生产者线程同时生产
- 多个消费者同时消费
- 生产者线程和消费者线程同时生产和消费
以上不管哪一种情况都可能会出现多个线程访问共享资源的情况,如果想防止共享资源出现数据混乱,那么就需要使用互斥锁进行线程同步,处理代码如下:
1 |
|
在编写上述代码的时候还有一个需要注意是事项,不管是消费者线程的处理函数还是生产者线程的处理函数内部有这么两行代码:
1 | // 消费者 |
这两行代码的调用顺序是不能颠倒的,如果颠倒过来就有可能会造成死锁,下面来分析一种死锁的场景:
1 | void* producer(void* arg) |
在上面的代码中,初始化状态下消费者线程没有任务信号量资源,假设某一个消费者线程先运行,调用pthread_mutex_lock(&mutex);
对互斥锁加锁成功,然后调用sem_wait(&csem);
由于没有资源,因此被阻塞了。其余的消费者线程由于没有抢到互斥锁,因此被阻塞在互斥锁上。对应生产者线程第一步操作也是调用pthread_mutex_lock(&mutex);
,但是这时候互斥锁已经被消费者线程锁上了,所有生产者都被阻塞,到此为止,多余的线程都被阻塞了,程序产生了死锁。