深入解析同步与互斥
文章目录
一、同步与互斥的基本概念
同步:是指多个进程或线程为了完成共同的任务,在执行顺序上需要相互协调、相互等待的一种关系。例如,在一个文件下载与解压的应用场景中,下载线程完成文件下载后,解压线程才能开始工作,解压线程需要等待下载线程的工作结束,这就是一种同步关系,确保各部分工作按正确顺序依次开展,以实现整体功能。
互斥:是指多个进程或线程在对临界资源(同一时刻只能被一个进程或线程访问的资源,如打印机、共享内存中的特定区域等)进行访问时,需要保证在任意时刻只有一个进程或线程能够访问该资源,避免数据不一致等问题。比如多个线程同时对同一个全局变量进行写操作,如果不进行互斥控制,就可能导致该变量最终的值出现错误,所以需要互斥机制来保证同一时间只有一个线程能操作这个全局变量。
二、基本的实现方法
(一)软件方法
- 算法思路:通过编写特定的程序逻辑,利用编程语言提供的一些特性(如循环、判断等)来实现同步与互斥。例如,Dekker 算法就是一种经典的软件实现互斥的方法。它通过设置一些标志变量和轮询机制来确保在任何时候只有一个进程能进入临界区(访问临界资源的代码段)。不过软件方法往往较为复杂,并且容易受到执行顺序、进程切换等因素影响,正确性较难保证,效率也相对不高。
(二)硬件方法
- 中断屏蔽:通过禁止中断来实现互斥访问临界资源。在一个进程进入临界区前,使用特定的指令屏蔽中断,这样就不会有其他进程通过中断切换进来访问同一临界资源,当进程离开临界区后再开启中断。例如,在一些简单的嵌入式系统中,当对关键的硬件寄存器(临界资源)进行读写操作时,可暂时屏蔽中断,防止其他代码干扰,但这种方法会影响系统对外部中断的响应及时性,不能长时间使用,而且在多处理器系统中可能效果不佳。
- 硬件指令:像 Test-and-Set 指令、Swap 指令等。这些指令在执行时能原子性地完成一些操作(不可被中断地执行完整个操作),用于实现互斥访问。以 Test-and-Set 指令为例,它可以对一个布尔变量(锁变量)进行测试并同时设置其值为真,如果原来的值为假,说明可以进入临界区,并且将其置为真来锁住临界资源;若原来为真,则说明已经有进程在访问,需要等待。这种基于硬件指令的方法相对简单高效,但依赖特定硬件支持,且对复杂的同步场景适应性有限。
以下是软件方法和硬件方法的对比图表:
对比项目 | 软件方法 | 硬件方法 |
---|---|---|
实现复杂度 | 较高,依赖复杂程序逻辑 | 相对简单,依赖特定硬件指令或机制 |
可靠性 | 易受多种因素影响,较难保证 | 相对稳定,但有适用范围限制 |
效率 | 较低,存在较多轮询等开销 | 较高,基于硬件原子操作 |
适用场景 | 简单系统或理论研究场景 | 对效率要求高且硬件支持的场景 |
三、锁
锁(Lock):是一种常用的实现互斥的机制,类似于现实生活中的锁,只有拿到“钥匙”(获取锁)的进程或线程才能访问对应的临界资源。一般分为互斥锁和读写锁等类型。
- 互斥锁:提供了最基本的互斥功能,当一个进程或线程获取了互斥锁后,其他试图获取该锁的进程或线程就会被阻塞,直到锁被释放。例如,在多个线程对同一个文件进行写操作时,每个线程在写之前都要先获取互斥锁,写完后释放锁,这样就能保证同一时刻只有一个线程在写文件,避免文件内容错乱。
- 读写锁:适用于存在大量读操作和少量写操作的场景。多个线程可以同时获取读锁进行读操作(因为读操作通常不会改变数据,相互之间不冲突),但写操作时则需要独占锁,当有线程获取写锁时,其他线程(无论是读还是写)都不能获取锁。比如在一个共享的数据缓存中,多个线程可以同时读取缓存中的数据,但当有线程要更新缓存数据(写操作)时,需要获取写锁,阻止其他线程的读写,保证数据一致性。
四、信号量
信号量(Semaphore):是一种更强大、更灵活的用于同步与互斥的机制,它本质上是一个整型变量,并且有两个原子操作(PV 操作)与之关联,P 操作(通常表示等待操作)和 V 操作(通常表示释放操作)。
- 定义与初始化:信号量在使用前需要进行初始化,设定其初始值,表示可用资源的数量或者允许同时进入临界区的进程或线程数量等。例如,若把信号量初始化为 1,则可以当作互斥锁来使用;若初始化为大于 1 的值,比如 5,则可以表示有 5 个相同类型的资源可供分配。
- P 操作(Wait 操作):
// 伪代码示例
void P(semaphore s) {
s.value--; // 信号量的值减 1
if (s.value < 0) {
// 将当前进程或线程阻塞,放入等待队列
block(s.queue);
}
}
当执行 P 操作时,先将信号量的值减 1,如果减 1 后的值小于 0,表示当前没有可用资源了(对于互斥来说,就是已经有其他进程或线程进入临界区了),那么执行 P 操作的进程或线程就会被阻塞,放入与该信号量关联的等待队列中等待资源可用。
- V 操作(Signal 操作):
// 伪代码示例
void V(semaphore s) {
s.value++; // 信号量的值加 1
if (s.value <= 0) {
// 从等待队列中唤醒一个阻塞的进程或线程
wakeup(s.queue);
}
}
执行 V 操作时,将信号量的值加 1,如果加 1 后的值小于等于 0,说明有进程或线程正在等待该资源,此时就从等待队列中唤醒一个被阻塞的进程或线程,使其可以继续去获取资源并执行。
例如,在生产者 - 消费者问题中,用信号量来控制缓冲区的使用,设置一个信号量表示缓冲区的空闲空间数量(初始化为缓冲区大小),生产者生产一个产品后执行 V 操作来增加空闲空间数量,消费者消费一个产品前执行 P 操作来获取空闲空间,如果没有空闲空间则等待;同时设置另一个信号量表示缓冲区中已有产品数量(初始化为 0),消费者消费前执行 P 操作获取产品,生产者生产后执行 V 操作添加产品,通过这样的信号量机制实现了生产者和消费者之间的同步与互斥。
五、条件变量
条件变量(Condition Variable):通常需要和互斥锁配合使用,用于让线程在某个条件满足时进行等待,或者在条件改变时唤醒等待该条件的线程。
- 等待操作(wait):当线程执行到条件变量的等待操作时,会先释放与之关联的互斥锁(避免死锁,因为如果不释放锁,其他线程永远无法改变条件来满足当前线程的等待需求),然后将自己阻塞,等待条件满足被唤醒。例如,在一个线程池实现中,工作线程如果发现任务队列中没有任务可执行(这就是等待的条件),就会在对应的条件变量上执行等待操作,释放互斥锁,进入阻塞状态,等待有新任务添加到队列中。
- 唤醒操作(signal/broadcast):当某个线程改变了条件,使得其他线程等待的条件可能满足时,可以通过 signal 操作唤醒一个等待该条件的线程,或者通过 broadcast 操作唤醒所有等待该条件的线程。比如,在任务队列中添加了新任务后,负责管理任务队列的线程可以通过 signal 或 broadcast 操作唤醒那些等待任务的工作线程,让它们重新获取互斥锁去检查任务队列并执行任务。
以下是信号量和条件变量的对比图表:
对比项目 | 信号量 | 条件变量 |
---|---|---|
功能侧重 | 侧重于资源数量的控制以及同步互斥的通用实现 | 侧重于基于条件的线程阻塞与唤醒机制 |
使用方式 | 通过 PV 操作对整型信号量值进行改变来控制 | 需和互斥锁配合,基于条件判断进行等待和唤醒操作 |
适用场景 | 广泛适用于各种资源分配、同步互斥场景 | 常用于多线程间基于特定条件的协调场景 |
六、经典同步问题
(一)生产者 - 消费者问题
- 问题描述:有一组生产者线程和一组消费者线程,它们通过一个共享缓冲区进行交互。生产者生产产品放入缓冲区,消费者从缓冲区取出产品进行消费,需要保证缓冲区不能同时被生产者和消费者同时访问(互斥),并且生产者在缓冲区满时要等待有空位才能生产,消费者在缓冲区空时要等待有产品才能消费(同步)。
- 代码示例(伪代码,基于信号量实现):
// 定义信号量
semaphore mutex = 1; // 互斥信号量,用于控制缓冲区的互斥访问
semaphore empty = N; // 表示缓冲区空闲空间数量,初始化为缓冲区大小 N
semaphore full = 0; // 表示缓冲区已有产品数量,初始化为 0
// 生产者线程函数
void producer() {
while (true) {
// 生产产品
item produced_item = produce();
P(empty); // 获取空闲空间,若没有则等待
P(mutex); // 获取互斥锁,进入临界区
// 将产品放入缓冲区
put_item(produced_item);
V(mutex); // 释放互斥锁
V(full); // 增加缓冲区已有产品数量
}
}
// 消费者线程函数
void consumer() {
while (true) {
P(full); // 获取产品,若没有则等待
P(mutex); // 获取互斥锁,进入临界区
// 从缓冲区取出产品
item consumed_item = get_item();
V(mutex); // 释放互斥锁
V(empty); // 增加空闲空间数量
// 消费产品
consume(consumed_item);
}
}
(二)读者 - 写者问题
- 问题描述:存在一组读者线程和一组写者线程共享一个数据资源(如文件、共享变量等)。多个读者可以同时读取资源(因为读操作不会改变数据,相互之间不冲突),但写者在写入数据时需要独占资源,不能有其他读者或写者同时访问,并且要保证读者和写者之间的合理同步,避免出现数据不一致等问题。
- 代码示例(伪代码,基于读写锁实现):
// 定义读写锁
rwlock rw_lock;
// 读者线程函数
void reader() {
while (true) {
rw_lock.read_lock(); // 获取读锁,可多个读者同时获取
// 进行读操作,读取共享数据资源
read_data();
rw_lock.read_unlock(); // 释放读锁
}
}
// 写者线程函数
void writer() {
while (true) {
rw_lock.write_lock(); // 获取写锁,独占资源
// 进行写操作,修改共享数据资源
write_data();
rw_lock.write_unlock(); // 释放写锁
}
}
(三)哲学家进餐问题
- 问题描述:有五个哲学家围坐在一张圆桌旁,每个哲学家面前有一碗饭和一根筷子,哲学家们要么思考,要么进餐,进餐时需要同时拿起左右两根筷子(筷子是临界资源),但筷子数量有限,可能会导致死锁情况(比如每个哲学家都拿起了左边的筷子,然后都在等待右边的筷子,就陷入僵局),需要设计一种机制来保证哲学家们能正常进餐,避免死锁。
- 代码示例(伪代码,一种避免死锁的解法,基于信号量实现):
// 定义信号量数组表示筷子,每个信号量初始化为 1
semaphore chopsticks[5] = {1, 1, 1, 1, 1};
// 哲学家线程函数
void philosopher(int i) {
while (true) {
think(); // 哲学家思考
if (i % 2 == 0) { // 偶数号哲学家先拿左边筷子
P(chopsticks[i]);
P(chopsticks[(i + 1) % 5]);
} else { // 奇数号哲学家先拿右边筷子
P(chopsticks[(i + 1) % 5]);
P(chopsticks[i]);
}
eat(); // 进餐
V(chopsticks[i]);
V(chopsticks[(i + 1) % 5]);
}
}
这些经典同步问题在考研中经常出现,考查对同步互斥概念的理解以及各种机制(如信号量、锁等)的应用能力,需要熟练掌握其原理、实现方式以及代码的逻辑思路,以便在面对相关考题时能够准确分析和解答。