1. 互斥与同步⚓
1.1 竞争与协作⚓
1.1.1 互斥的概念⚓
由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section)
,它是访问共享资源的代码片段,一定不能给多线程同时执行。
互斥(mutualexclusion)
,保证一个线程在临界区执行时,其他线程应该被阻止进入临界区。
1.1.2 同步的概念⚓
同步
,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
1.2 互斥与同步的实现和使用⚓
为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:
- 锁:加锁、解锁操作;
- 信号量:P、V 操作;
这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。
下文的实现都是简单的演示。
1.2.1 锁⚓
使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。
任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。
根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。
1.2.1.1 测试和置位指令⚓
在说明「忙等待锁」的实现之前,先介绍现代 CPU 体系结构提供的特殊原子操作指令 —— 测试和置位(Test-and-Set)指令
。
用 C 代码表示 Test-and-Set 指令的形式如下:
- 把 old_ptr 更新为 new 的新值
- 返回 old_ptr 的旧值;
关键是这些代码是原子执行。因为既可以测试旧值,又可以设置新值,所以我们把这条指令叫作「测试并设置」。
原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态。
1.2.1.2 忙等待锁(自旋锁)⚓
用 Test-and-Set 指令实现的忙等待锁:
当某一个线程已经持有锁(即 flag 为 1)。本线程调用 lock()
,然后调用 TestAndSet(flag, 1)
,只要另一个线程一直持有锁,TestAndSet() 会重复返回 1,本线程会一直忙等。当 flag 终于被改为 0,本线程会调用 TestAndSet(),返回 0 并且原子地设置为 1,从而获得锁,进入临界区。
当获取不到锁时,线程就会一直 while 循环,不做任何事情,所以就被称为「忙等待锁」,也被称为自旋锁(spin lock)
。
在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
1.2.1.3 无等待锁⚓
不自旋,当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。
推荐:《操作系统导论》第 28 章锁。
1.2.2 信号量⚓
原理就是 P、V 操作,可以参考前文 《进程间的通信方式》。
信号量数据结构与 PV 操作的算法描述如下图:
PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行 PV 函数时是具有原子性的。
1.2.3 生产者 - 消费者问题⚓
生产者 - 消费者问题描述:
- 生产者在生成数据后,放在一个缓冲区中;
- 消费者从缓冲区取出数据处理;
- 任何时刻,只能有一个生产者或消费者可以访问缓冲区;
需要三个信号量,分别是:
- 互斥信号量 mutex:用于互斥访问缓冲区,初始化值为 1;
- 资源信号量 fullBuffers:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空);
- 资源信号量 emptyBuffers:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n(缓冲区大小);
1.3 经典同步问题⚓
1.3.1 哲学家就餐⚓
问题描述:
- 5 个老大哥哲学家,闲着没事做,围绕着一张圆桌吃面;
- 巧就巧在,这个桌子只有 5 支叉子,每两个哲学家之间放一支叉子;
- 哲学家围在一起先思考,思考中途饿了就会想进餐;
- 奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐;
- 吃完后,会把两支叉子放回原处,继续思考;
如何保证哲 学家们的动作有序进行,而不会出现有人永远拿不到叉子呢?
1.3.1.1 方案一⚓
这种解法存在一个极端的问题:假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了, 这样就没有人能够拿到他们右边的叉子,也就说每一位哲学家都会在 P(fork[(i + 1) % N ])
这条语句阻塞了,很明显这发生了死锁的现象。
1.3.1.2 方案二⚓
既然「方案一」会发生同时竞争左边叉子导致死锁的现象,那么我们就在拿叉子前,加个互斥信号量,代码如下:
方案二虽然能让哲学家们按顺序吃饭,但是每次进餐只能有一位哲学家,而桌面上是有 5 把叉子,按道理是能可以有两个哲学家同时进餐的,所以从效率角度上,这不是最好的解决方案。
1.3.1.3 方案三⚓
避免哲学家可以同时拿左边的刀叉,采用分支结构,根据哲学家的编号的不同,而采取不同的动作。
即让偶数编号的哲学家「先拿左边的叉子后拿右边的叉子」,奇数编号的哲学家「先拿右边的叉子后拿左边的叉子」。
V 操作是不需要分支的,因为 V 操作是不会阻塞的。
方案三既不会出现死锁,也可以两人同时进餐。
1.3.1.4 方案四⚓
另外一种可行的解决方案,我们用一个数组 state 来记录每一位哲学家的三个状态,分别是在进餐状态、思考状态、饥饿状态(正在试图拿叉子)。
那么,一个哲学家只有在两个邻居都没有进餐时,才可以进入进餐状态。
方案四同样不会出现死锁,也可以两人同时进餐。
1.3.2 读者 - 写者问题⚓
问题描述:
- 「读 - 读」允许:同一时刻,允许多个读者同时读
- 「读 - 写」互斥:没有写者时读者才能读,没有读者时写者才能写
- 「写 - 写」互斥:没有其他写者时,写者才能写
1.3.2.1 方案一⚓
读者优先策略:
- 只要有读者正在读的状态,后来的读者都可以直接进入,
- 如果读者持续不断进入,则写者会处于饥饿状态。
1.3.2.2 方案二⚓
写者优先策略:
- 只要有写者准备要写入,写者应尽快执行写操作,后来的读者就必须阻塞;
- 如果有写者持续不断写入,则读者就处于饥饿状态;
注意,这里 rMutex
的作用,开始有多个读者读数据,它们全部进入读者队列,此时来了一个写者,执行了 P(rMutex)
之后,后续的读者由于阻塞在 rMutex 上,都不能再进入读者队列,而写者到来,则可以全部进入写者队列,因此保证了写者优先。
同时,第一个写者执行了 P(rMutex)
之后,也不能马上开始写,必须等到所有进入读者队列的读者都执行完读操作,通过 V(wDataMutex)
唤醒写者的写操作。
1.3.2.3 方案三⚓
公平策略:
- 优先级相同;
- 写者、读者互斥访问;
- 只能一个写者访问临界区;
- 可以有多个读者同时访问临界资源;
开始来了一些读者读数据,它们全部进入读者队列,此时来了一个写者,执行 P(flag)
操作,使得后续到来的读者都阻塞在 flag 上,不能进入读者队列,这会使得读者队列逐渐为空,即 rCount
减为 0。
这个写者也不能立马开始写(因为此时读者队列不为空),会阻塞在信号量 wDataMutex
上,读者队列中的读者全部读取结束后,最后一个读者进程执行 V(wDataMutex)
,唤醒刚才的写者,写者则继续开始进行写操作。