程序竞争
复习
- 多核调度:任务分到多个 CPU 后还要处理负载和缓存亲和
- 线程与并发:一个进程内可以有多条共享内存的执行线
- 进程间通信:进程之间靠管道、消息、信号或共享内存交换数据
TL;DR
- 多条执行线同时读写同一份数据,可能互相干扰
- “读取—修改—写回”不是一步完成的,中途可能被打断
- 结果取决于执行顺序的现象,叫竞态条件(程序竞争)
- 它难以复现,是并发程序里最经典的难题
正文
先看一个让人想不通的实验。
我们开两个线程,让它们各自把同一个变量 count 加 1000 次。按理说,最后 count 应该是 2000。可实际跑起来,结果常常是 1000 多、1900 多,甚至每次都不一样。
加个加法而已,怎么会加错?
加一,其实是三步
问题出在:count = count + 1 这行代码,对计算机来说并不是“一步”完成的,而是三步:
- 读:把
count的值从内存取到寄存器 - 改:在寄存器里加一
- 写:把新值写回内存
单线程时,这三步顺顺当当连在一起。可两个线程同时跑,就可能交错。
交错是怎样丢数据的
假设 count 现在是 5。两个线程都要加一:
线程 A:读 count → 5
线程 B:读 count → 5 ← B 也读到了 5
线程 A:改 → 6
线程 B:改 → 6 ← B 基于旧的 5 得到 6
线程 A:写回 → count = 6
线程 B:写回 → count = 6 ← 各写各的,等于只加了一次
两次“加一”,本该变成 7,结果却是 6。因为 A 和 B 都基于同一个旧值 5 计算,后来者把前者的结果覆盖了。这就是丢失更新。
竞态条件
这种“结果取决于谁先谁后”的现象,叫竞态条件(race condition);由此引发的、难以察觉的冲突,常被形象地称为程序竞争。
它最讨厌的地方是难以复现:
- 大多数时候顺序刚好错开,运行正常
- 偶尔交错到坏时序,才冒一次错
- 在单核上几乎测不出来,一上多核就出问题
于是它成了并发程序里的“幽灵”:测试时好好的,上线后偶发错误,还极难定位。
出路:别让关键几步被打断
根源很清楚:两个线程可以随意穿插进对方的三步中间。
那么,如果我们能划定一段代码,保证同一时刻只有一个线程能执行它,问题不就解决了吗?这段“必须独占执行”的代码,就是下一章的临界区。
思考题
如果
count = count + 1换成一条“读取、加一、写回”合为一步的原子指令,两个线程各加 1000 次还会出错吗?为什么?
小结
知识点
- 加一其实是读、改、写三步,可能被打断
- 交错执行会导致丢失更新
- 竞态条件:结果依赖执行顺序
- 竞态条件难以复现,是并发程序的经典难题
参考资料
- Wikipedia(zh):竞态条件:race condition
- Wikipedia(zh):丢失更新:lost update
思考题答案(仅供参考)
不会出错。如果“读取、加一、写回”被硬件保证为不可分割的一步,那么两个线程的加法就会一个接一个地完成,不会出现“都读到旧值”的交错,最终一定是 2000。这也正是下一章要讲的“原子操作”的思路:把容易出错的几步,合成一个不可打断的整体。
协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
封面图
设计师 | 南国微雪