临界区与原子操作
复习
- 线程与并发:一个进程内可以有多条共享内存的执行线
- 进程间通信:进程之间靠管道、消息、信号或共享内存交换数据
- 程序竞争:多条执行线同时读写共享数据,可能互相干扰
TL;DR
- 临界区:一段访问共享数据的代码,同一时刻只允许一个执行线进入
- 原子操作:硬件保证“不可被中途打断”的操作
- 保证“同一时刻只有一个进入临界区”,叫互斥(mutual exclusion)
- 用原子操作,可以搭出实现互斥的锁
正文
上一章我们看到,加一被拆成读、改、写三步,两条执行线一交错,就丢数据。要根治,得划出一条界线:某段代码,同一时刻只许一个人进。
临界区
这段“必须独占”的代码,叫临界区(critical section)。它通常正是那些访问共享数据的代码,比如“读—改—写某个全局变量”。
我们要的规矩只有一条:同一时刻,最多只有一个执行线在临界区里。 这条规矩叫互斥(mutual exclusion)。其余的线程要么在门口等,要么先去做别的事。
线程 A:│ 进入临界区 │ 执行 │ 离开 │
线程 B: │ 进入临界区 │ 执行 │ 离开 │
只要这条规矩守住,临界区里的“读—改—写”就再也不会被插入,丢失更新也就不发生了。
原子操作
可“划出临界区”本身,也需要一个不含糊的工具。这正是原子操作(atomic operation)的用武之地。
原子操作由硬件提供,一旦开始就保证一口气做完,中间绝不会被别的执行线插进来——要么没做,要么全做完,没有“做了一半”的状态。
常见的原子操作有:
- 交换:把寄存器和内存里的值对调
- 测试并设置:读出旧值,同时把它设成 1
- 比较并交换:只有当内存里的值等于预期时,才把它换成新值
它们看似简单,却是搭所有同步工具的“地基”。
用原子操作搭一把锁
有了原子操作,实现互斥就有了思路。以“测试并设置”为例,我们可以用一个标志位表示锁:
- 想进临界区,就“测试并设置”这个标志位
- 如果测出来的旧值是 0(没人占),就说明抢到了锁,可以进去
- 如果旧值是 1(已被占),说明别人正拿着,只能等
因为整个“测试 + 设置”是一步原子操作,两个线程不可能同时抢到——总有一个先拿到,另一个只能看到 1。
抢到锁的一方进入临界区,办完事再把标志位设回 0,放别人进来。如此,临界区就被稳稳地保护起来了。
忙等的代价
上面这种“拿不到就反复重试”的等待方式,叫忙等(busy waiting):线程一直占着 CPU 空转。
能拿到就还好,可如果锁被占很久,忙等就纯属浪费。怎样等得更聪明?下一章,我们把这套机制封装成一个好用的接口——互斥锁,并讨论它该怎样等待。
思考题
“测试并设置”为什么必须是原子操作?如果把它拆成“先测试、再设置”两步,两个线程同时想抢锁,会发生什么?
小结
知识点
- 临界区:访问共享数据、必须独占执行的代码
- 互斥:同一时刻只有一个执行线在临界区
- 原子操作:硬件保证不可打断,常见有交换、测试并设置、比较并交换
- 用原子操作可以搭出锁
参考资料
- Wikipedia(zh):临界区:critical section
- Wikipedia(zh):互斥锁:mutex
- Wikipedia(zh):原子操作:atomic operation
思考题答案(仅供参考)
如果拆成两步,两个线程可能都先“测试”,都看到标志位是 0,都以为没人占,于是都去“设置”并进入临界区——互斥就破了。把“测试并设置”合成一步原子操作,硬件保证同一时刻只有一个线程完成它,两个线程必然分成先后:先完成的抢到锁,后完成的看到 1,只能等。所以原子性是这条防线成立的关键。
协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
封面图
设计师 | 南国微雪