Linux 线程同步与互斥

发布于:2025-08-06 ⋅ 阅读:(16) ⋅ 点赞:(0)

1. 线程同步与互斥初步

什么是同步,什么是互斥?简单来说,同步就是不同线程按照一定顺序访问共同资源;互斥就是任何时刻,都只有一个线程访问共同资源。

同一进程中的不同线程访问相同资源,需要同步与互斥;不同进程中的不同线程访问相同资源,也需要同步与互斥。

在本篇博客中,我们重点讲解,同一进程中,不同线程的同步与互斥实现。

2. 线程互斥

2.1 相关概念

临界资源:多线程执行流访问的公共资源成为临界资源。

临界区:访问临界资源的代码称为临界区。

互斥:对于多线程访问的公共资源,同一时刻,只有一个线程的一个执行流进行访问,用以保护公共资源。

原子性:无法被任何调度机制破坏的行为,只有彻底完成和完全未开始,这两种状态。

2.2 互斥量实现互斥

2.2.1 互斥量初始化和销毁

互斥量的初始化有两种方式。

在这里插入图片描述
使用宏进行初始化,不能设置互斥锁的属性,是默认初始化方式,一般用于生命周期是全局的锁较多,如全局变量或静态变量。由于一般用于生命周期为全局的锁,因此往往不用pthread_mutex_destroy销毁(实际上销毁也可以)。

使用pthread_mutex_init初始化,可以通过第二个参数自主设置锁的属性(如果传空指针代表使用默认属性,此时与使用宏初始化相同),一般用于局部变量,也正因为通常用于局部变量,因此要与pthread_mutex_destroy搭配使用。

pthread_destroy本质是一个逆初始化的过程,即将锁的状态置为未初始化,无法使用状态。此时,如果想再次使用该锁,必须再次初始化。

2.2.2 互斥量加锁和解锁

在这里插入图片描述

实现互斥的方式,就是通过互斥量的加锁和解锁,互斥量加锁和解锁之间的区域就是临界区,在临界区中进行临界资源的访问,就可以保护临界资源。

互斥量加锁一般使用pthread_mutex_lock,这个加锁是阻塞加锁——因为一把锁只能被一个线程执行流拿走,直到该执行流将锁解锁,即释放后,才会重新进行锁的竞争。而在锁被别的线程拿走时,此时再加锁,即申请锁,相关线程会阻塞挂起,直到相应锁被释放后,才会被唤醒,进而重新竞争锁。

另外一个加锁pthread_mutex_trylock,这个加锁用得不是很多,它是非阻塞申请锁,如果申请锁没有成功,不会挂起等待,而是直接返回,不怎么使用。

解锁,即释放锁使用pthread_mutex_unlock,这个不用作太多解释。

下面看一段通过互斥锁进行临界资源保护的多线程代码。

2.3 互斥量原理

那么互斥锁是如何实现互斥的呢?互斥锁保证了访问临界区的互斥,互斥锁自身的申请,又如何保证是互斥的呢?

互斥锁的原理可以通过下面这张图来理解:

在这里插入图片描述
加锁的过程:我们可以使用1来代表互斥锁中的内容,%al来代表al寄存器中的内容。首先,我们要确定的是,最简单的,诸如赋值,交换值这种汇编指令,在逻辑上是无法再分割的,它们本身就具有原子性,不会被任何调度机制打断。

所以,最终与加锁最直接关联的就是 xchgb %al,mutex,这是将al寄存器中的内容与mutex中的内容进行交换。无论是单核CPU,还是多核CPU,还是多CPU,最终一定只有一个线程能够先完成该指令(多核CPU,多CPU间会进行协调),即先拿到mutex中的1,而拿到1的线程可以正常返回,执行临界区的代码;而之后的线程就只能拿到0,进而导致挂起等待,等待唤醒后,再重新竞争锁。

对于解锁而言:就是将mutex重新置为1,然后唤醒线程,让他们重新去竞争锁。

所以,用户层面的互斥锁实现是非常简单而有高效的,互斥锁的加锁过程本身并不是逻辑不可分的,即整个操作本身并不是原子的,但通过特定的实现方式,实现了逻辑上的原子性,一次只可能有一个线程申请锁成功,其它申请锁失败的线程均会挂起等待。

需要特别说明的是,POSIX标准所实现的mutex锁,在竞争锁时,哪个线程可以先竞争到锁,由具体的线程调度等多方面因素决定;
而解锁,唤醒等待进程时,可能会出现“插队”,也就是stealing lock的情况(即释放锁后,恰好有一个未进入等待的线程竞争锁,这时就会出现插队)。即便没有插队的情况,所有线程都在等待状态中,POSIX标准也并不保证mutex锁的等待队列是公平的。虽然理论上等待队列通常都是FIFO组织的,也就是先进入等待的,可以先被唤醒,但是这点并不被严格保证,即理论上FIFO,实际会受环境影响。

另外,mutex锁的等待队列中的唤醒,是一个一个唤醒的,而不是同时唤醒,否则会导致惊群效应,即thundering herd,带来无意义的消耗(因为锁最终只能被一个线程申请,其它线程又只能再次进入等待状态,那么这些线程的唤醒就是无意义的,而且这样太过无序)。

2.4 互斥锁面向对象式的封装

互斥锁面向对象式的封装时,最常见的设计风格就是RAII式,即Resource Acquisition is initialization,即对象在创建获取时,即完成初始化。更完整来说,还有一条,对象在析构时,自动完成资源释放。

RAII的编程设计风格,正是依托C++ 对象的自动调用构造与析构实现的。

3. 线程同步

什么是线程同步呢?线程同步就是不同线程执行流按照一定的顺序访问临界资源。

从某种意义上来说,线程同步是线程互斥引入的新问题。

试想这么一个场景,当一个线程访问某个临界资源时,在其它线程改变这个临界资源状态之前,这个线程会发现什么也做不了。比如说一个队列,一个线程用以入队列,另外一个线程用以出队列,如果出队列的线程总是能够竞争到这个锁,但是队列又总是为空,那么此时竞争到锁的这个行为就是无意义的。也就是说,这两个线程间,一定要保持一定的执行顺序——当队列为空时,一定要先入队列;当队列为满时,一定要先出队列。

那么,如何实现执行流按顺序访问呢?答案是通过线程同步。

3.1 条件变量

线程同步是通过条件变量实现的。

在这里插入图片描述
条件变量类型为pthread_cond_t,与互斥量类似,条件变量可以定义在全局,也可以定义在局部,初始化的方式也有两种,也有对应的销毁方式,具体的作用和区别之处与互斥锁类似,不再赘述。

3.2 条件变量相关接口

条件变量的创建和销毁:

在这里插入图片描述
上图中的restrict 关键字是C99引入的标准,主要是用来告诉编译器,该指针所指向的内存块只有这一个指针指向,以便编译器做出一些优化。

条件变量的等待:

在这里插入图片描述
这个函数是线程同步实现的核心。线程同步,也就是线程要按照一定顺序执行,也就是不同的线程只有当条件满足时,才能去执行临界区的相关代码,而这个函数就是用于当特定条件不满足时,来等待条件满足的。

由于这个函数是当判断出临界资源相关条件不满足时,才会调用,所以在该函数调用前,就已经访问了临界资源,即在临界区内,所以在此之前就要加锁保护。

既然这个函数在临界区内,相应线程要去等待条件满足,所以肯定函数内部肯定要完成释放锁的逻辑,然后进入等待状态。等到条件满足被唤醒时,pthread_cond_wait()这个函数不能立即返回,因为其在临界区内,必须要重新竞争锁,成功竞争到锁后,再返回,继续执行下面的代码。

条件变量的唤醒:

在这里插入图片描述
条件变量的唤醒是用唤醒在指定条件变量下等待的线程的。pthread_cond_signal是用来唤醒在特定条件变量下等待的一个线程,而pthread_cond_broadcast则是将在特定条件变量下等待的线程全部唤醒。

3.3 条件变量的使用模板

等待条件相关:

pthread_mutex_lock(&mutex);
while(条件为假)
	pthread_cond_wait(&cond,&mutex);
修改条件
pthread_mutex_unlock(&mutex);

发送唤醒相关

pthread_mutex_lock(&mutex)
修改条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

上述有几个注意点:

  • 在等待条件为真的代码中,控制pthread_cond_wait的逻辑一定是要while,不能使用if,这样能够有效避免虚假唤醒的情况。
  • 在发送唤醒的代码中,最后pthread_cond_signalpthread_mutex_unlock,这两者的先后顺序,谁先谁后都可以,不影响线程正常的并发运行,但是推荐上述的写法。

3.4 条件变量的封装

在这里插入图片描述

4. 生产者消费者模型

4.1 理解概念

生产者,消费者模型,就是通过一个商店,即一个容器,实现消费者与生产者之间的解耦合。

生产者如果与消费者直接交互,那么是强耦合关系,当消费者有需要时,生产者才会生产,这样会带来很大弊端:其一,对于消费者而言,有需要时,不能够立即获得,需要等待生产者生产出来才能获得;其二,消费者的需要,有旺季和淡季之分,如果二者直接交互,会导致,淡季时,生产者闲得慌,旺季时,生产者累到死。

因此,通过一个容器,实现消费者与生产者解耦合——此时,消费者有需要,就到商店中买即可;生产者生产好,直接将商品输送到商店中即可——这样,就实现了生产与消费间的高效协作,同时也平衡了忙闲不均。

4.2 基于blocking queue的生产者消费者模型

4.2.1 blocking queue 介绍

在多线程编程中,阻塞队列,即blocking queue是一种常用的用于实现生产者消费者模型的数据结构。

blocking queue本质就是FIFO结构,一个有容量限制的队列结构但是由于并发编程,需要通过条件变量与互斥锁额外维护。

基于blocking queue的生产者消费者模型,需要维护三种关系:生产者之间,消费者之间,生产者与消费者之间。

  • 生产者之间是互斥关系,一个生产者向blocking queue中进行生产时,其它生产者不能打扰。

  • 消费者之间是互斥关系,一个消费者从blocking queue中获取时,其它消费者不能打扰。

  • 生产者与消费者之间是同步互斥关系。生产者的生产与消费者的消费不能互相打扰,另外当阻塞队列为空时,必须要先让生产者生产;当阻塞队列为满时,必须先让消费者消费。

4.2.2 blocking queue的C++实现

在这里插入图片描述

5. 信号量

5.1 概念理解

POSIX 信号量(semaphore),简单来理解就是一个计数器,一个可以实现资源预订机制的计数器,它可以用来实现线程间的同步。

5.2 认识相关接口

信号量的初始化:

在这里插入图片描述
sem_init:用来对匿名信号量进行初始化的接口。
sem:此参数传相应创建信号量的指针即可。
pshared:这个参数用来决定是否为进程间共享。如果是0,则代表是线程间信号量,要确保需要同步的各线程均能正常访问;如果不是0,则代表是进程间共享,通常需要将信号量创建在共享内存中。
value:这个是信号量计数器的初始化值,代表初始资源数。

信号量的销毁:

信号量不再使用后,就需要销毁。

在这里插入图片描述
信号量的销毁有两个注意点:第一,不能在有线程进行sem_wait或sem_post的时候,进行销毁操作,这是未定义的危险行为;第二,不能在销毁后,不重新初始化,此时使用信号量,这也是未定义行为。

信号量的PV操作:

信号量本身可以理解为计数器,是对资源进行预订,而信号量的PV操作就是用来预订释放资源:信号量的P操作为申请资源,V操作为释放资源。

P操作:

在这里插入图片描述
在这里插入图片描述
这个接口用来实现计数器减去1。为什么能实现同步机制呢?因为当资源没有时,即计数器为0时,会进入阻塞状态,直到被唤醒,并且资源又不为0,进行资源申请后,便返回。

V操作:

在这里插入图片描述
这个接口用来实现计数器加上1,即释放资源。释放的资源数并没有上限,即如果一直调用sem_post,那么相关计数器便会一直自增,直至达到设定的value最大值。除此之外,sem_post在完成资源加1后,还会唤醒阻塞在sem_wait中的线程。

因此,sem_post和sem_wait是需要配套使用的,申请一个资源后,最后必然要释放一个资源,这时由程序员在用户层面维护的。

需要特别说明的是,由于sem_post和sem_wait本身就是对临界资源的访问,所以本身是需要保护的,但设计信号量时,信号量本身就是用于临界资源访问前预订,临界资源访问后,释放,因此sem_post和sem_wait不用放在临界区内。
所以,考虑到这点,sem_post和sem_wait,本身就是原子的,不会被其它进程的sem_post和sem_wait打断,这是系统底层机制实现的操作原子性。

5.3 基于POSIX信号量和环形队列实现生产者消费者模型

5.3.1 环形队列

环形队列本身就是一个队列结构,通常使用数组实现,通过模运算,实现首位相接的逻辑。

一般环形队列通过数组实现,由于要实现FIFO结构,需要头尾两个指针控制,并且预留一个空位,以区分空队列和满队列。

空队列:头指针==尾指针。
满队列:(尾指针 + 1)% MAX_SIZE == 尾指针

需要注意的是,引入信号量之后,实际上就有了计数机制,就不需要使用上述写法区分环形队列的空与满了。

5.3.2 相关代码实现

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

关于环形队列的封装代码中,有几点需要说明:

  • 消费者与生产者分别使用一个互斥锁,这样可以实现消费者与生产者的并发访问。之所以能够这样实现,是因为信号量的PV操作本身已经在底层维护好了原子性,相关条件的满足和唤醒,是原子完成的(sem_post),因此能够使用两把锁;而对于条件变量来说,条件变量需要在临界区内使用,并且控制条件的满足与否,并不是集成在条件变量相关接口的内部的,而是在用户层面实现的,即条件的更改与pthread_cond_signal操作是分离的——因此,如果使用两把锁,就很可能会出现虚假唤醒的问题,带来额外消耗。
  • 环形队列中,使用两个信号量,分别对应消费者所需的有效数据和生产者所需的有效空间
  • 信号量的PV操作本身已经在底层维护好了操作原子性,因此不需要放入加锁的临界区内部使用,均放在加锁外部使用即可。实际上,P操作如果放在加锁内部,反倒会引入新的问题——某线程加锁后,别的线程便无法进行资源申请了,这显然是不合理的;但对于V操作,是否加锁使用,则差别不大。

5.4 理解信号量与互斥

互斥本质上就是将资源看作一份,这一份资源,一次只能由一个线程访问;而信号量,在初始化时,可以设置value值,来控制初始的资源量。

所以,互斥本质上就是二元信号量(初始值为1的信号量),此时资源被当作整体使用,只有一份,一个线程申请走了资源,其它线程便只能等待该线程将资源释放。

对于多元信号量而言,实质上就是不将资源整体使用,而分块为多个资源。


网站公告

今日签到

点亮在社区的每一天
去签到