互斥与同步
在操作系统中,线程的调度一直是如何更高效地利用CPU所要关心的问题。
对于某些程序代码,只允许在同一时刻只有一个线程访问,我们将这样的代码称为【临界区(criticalsection)】。它是执行共享资源的代码片段,多线程执行共享变量的这段代码可能会导致竞争状态,一定不能给多线程同时执行。我们希望这段代码是互斥(mutualexclusion)的,也就说执行临界区(criticalsection)代码段的只能有一个线程,其他线程阻塞等待,达到排队效果。
互斥和同步就可以看作是操作系统为了多线程协商如何使用临界区资源所想出的法子。互斥解决了「多进程/线程」对临界区使用的问题,但是它没有解决「多进程/线程」协同工作的问题。
一、基本概念
我们都知道在多线程里,每个线程一定是顺序执行的,它们各自独立,以不可预知的速度向前推进,但有时候我们希望多个线程能密切合作,以实现一个共同的任务。
所谓同步,就是「多进程/线程间」在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为「进程/线程」同步。
举个例,有两个角色分别是研发、质量管控,质量管控测试功能,需要等研「发完成开发」,研发要修bug也要等质量管控「测试完成提交BUG」,正常流程是研发完成开发,通知质量管控进行测试,质量管控测试完成,通知研发人员修复bug。
二、互斥和同步之间的关系
同步是一种更为复杂的互斥,而互斥是一种特殊的同步。可以理解为同步涵盖了互斥的概念,是更高一层的调度策略。也就是说互斥是两个线程之间不可以同时运行,他们会相互排斥,必须等待一个线程运行完毕,另一个才能运行,而同步也是不能同时运行,但他是必须要按照某种次序来运行相应的线程(也是一种互斥)!
三、如何实现互斥?
主要可以通过锁和信号量这两种方式实现进程/线程之间的互斥。
(1)使用加锁实现互斥
任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。
根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。
「忙等待锁」指的是当线程获取不到锁时,线程就会一直 wile 循环,不做任何事情,所以就被称为「忙等待锁」,也被称为自旋锁(spin lock)。这是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
「无忙等待锁」顾明思议就是获取不到锁的时候,不用自旋。既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。
(2)使用互斥量实现互斥
信号量是操作系统提供的一种协调共享资源访问的方法。通常信号量表示资源的数量,对应的变量是一个整型(sem
)变量。
另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:
- P 操作:将
sem
减1
,相减后,如果sem < 0
,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞; - V 操作:将
sem
加1
,相加后,如果sem <= 0
,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;
P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。即任何想进入临界区的线程,必先在互斥信号量上执行 P 操作,在完成对临界资源的访问后再执行 V 操作。
PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行 PV 函数时是具有原子性的。
我们先来说说如何使用信号量实现临界区的互斥访问。为每类共享资源设置一个信号量 s
,其初值为 1
,表示该临界资源未被占用。
只要把进入临界区的操作置于 P(s)
和 V(s)
之间,即可实现进程/线程互斥:
此时,任何想进入临界区的线程,必先在互斥信号量上执行 P 操作,在完成对临界资源的访问后再执行 V 操作。由于互斥信号量的初始值为 1,故在第一个线程执行 P 操作后 s 值变为 0,表示临界资源为空闲,可分配给该线程,使之进入临界区。
若此时又有第二个线程想进入临界区,也应先执行 P 操作,结果使 s 变为负值,这就意味着临界资源已被占用,因此,第二个线程被阻塞。
并且,直到第一个线程执行 V 操作,释放临界资源而恢复 s 值为 0 后,才唤醒第二个线程,使之进入临界区,待它完成临界资源的访问后,又执行 V 操作,使 s 恢复到初始值 1。
对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示:
- 如果互斥信号量为 1,表示没有线程进入临界区;
- 如果互斥信号量为 0,表示有一个线程进入临界区;
- 如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。
通过互斥信号量的方式,就能保证临界区任何时刻只有一个线程在执行,就达到了互斥的效果。
四、如何实现同步?
(1)使用信号量实现同步
生产者-消费者问题
生产者-消费者问题描述:
- 生产者在生成数据后,放在一个缓冲区中;
- 消费者从缓冲区取出数据处理;
- 任何时刻,只能有一个生产者或消费者可以访问缓冲区;
我们对问题分析可以得出:
- 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥;
- 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。
那么我们需要三个信号量,分别是:
- 互斥信号量
mutex
:用于互斥访问缓冲区,初始化值为 1; - 资源信号量
fullBuffers
:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空); - 资源信号量
emptyBuffers
:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n (缓冲区大小);
具体实现代码:
五、总结
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理