Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

程序竞争

复习

  • 多核调度:任务分到多个 CPU 后还要处理负载和缓存亲和
  • 线程与并发:一个进程内可以有多条共享内存的执行线
  • 进程间通信:进程之间靠管道、消息、信号或共享内存交换数据

TL;DR

  • 多条执行线同时读写同一份数据,可能互相干扰
  • “读取—修改—写回”不是一步完成的,中途可能被打断
  • 结果取决于执行顺序的现象,叫竞态条件(程序竞争)
  • 它难以复现,是并发程序里最经典的难题

正文

  先看一个让人想不通的实验。

  我们开两个线程,让它们各自把同一个变量 count 加 1000 次。按理说,最后 count 应该是 2000。可实际跑起来,结果常常是 1000 多、1900 多,甚至每次都不一样。

  加个加法而已,怎么会加错?

加一,其实是三步

  问题出在:count = count + 1 这行代码,对计算机来说并不是“一步”完成的,而是三步:

  1. :把 count 的值从内存取到寄存器
  2. :在寄存器里加一
  3. :把新值写回内存

  单线程时,这三步顺顺当当连在一起。可两个线程同时跑,就可能交错。

交错是怎样丢数据的

  假设 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 次还会出错吗?为什么?

小结

知识点

  • 加一其实是读、改、写三步,可能被打断
  • 交错执行会导致丢失更新
  • 竞态条件:结果依赖执行顺序
  • 竞态条件难以复现,是并发程序的经典难题

参考资料

  1. Wikipedia(zh):竞态条件:race condition
  2. Wikipedia(zh):丢失更新:lost update

思考题答案(仅供参考)

  不会出错。如果“读取、加一、写回”被硬件保证为不可分割的一步,那么两个线程的加法就会一个接一个地完成,不会出现“都读到旧值”的交错,最终一定是 2000。这也正是下一章要讲的“原子操作”的思路:把容易出错的几步,合成一个不可打断的整体。

协议

  本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

封面图

设计师 | 南国微雪