线程与同步
多个线程缺少协调地读写共享状态,结果可能依赖调度顺序。本课把 counter = counter + 1 拆成读取、私有副本加一、写回三个原子教学步骤。另一个线程可在步骤之间执行,导致更新丢失。
两个线程各递增三次,期望为 6,默认无锁轨迹却为 4。六次写回都执行了,没有崩溃,两次增量被旧值覆盖。随后用互斥锁让同一调度字符串得到 6,再用相反顺序获取两把锁展示死锁。
竞态如何发生
普通递增并不因源码只有一行就保证不可分割。读取把共享值放进私有临时变量,加法只修改副本,写回才改变共享状态。需要原子递增时,应使用语言提供的原子读改写或合适的同步,而不是依赖行数。
单核上两个线程通过交错执行共享 CPU,系统可在步骤之间切换。线程的寄存器状态随上下文保存,因此旧副本也会跨切换存活。多核则还能同时执行。
例如 T1 先读 0,切到 T2 后,T2 读 0、加一、写 1;再回 T1,它仍用旧副本 0 算出 1,覆盖已经为 1 的 counter。每条教学指令都合法,组合却丢掉 T2 的增量。实际语言对数据竞争另有规则,例如 C/C++ 非原子共享数据竞争属于未定义行为,不能把本课的顺序交错当成完整语言语义。
逐步演示
默认调度串是 1 222 11 222 111 2 111 22,每位指定一个教学机器步骤给 T1 或 T2。共十八步、七次切换。每个线程都重复三次 tmp = counter、tmp = tmp + 1、counter = tmp。
- T1 读共享 0,得到
tmp₁=0,共享值未变。 - 切到 T2,T1 保存旧 0。
- T2 读到 0,得到
tmp₂=0。 - T2 私有加法使
tmp₂=1,counter 仍为 0。 - T2 写 counter=1,完成一次增量。
- 切回 T1。
- T1 用旧副本计算
tmp₁=0+1=1,没有使用当前 counter=1。 - T1 写回 1。两次 store 已完成,共享值却只增加一次。
- T2 完整递增,counter=2。
- T1 完整递增,counter=3。
- T2 读 3 后被切走。
- T1 完成最后一次递增,counter=4,然后结束。
- T2 对旧 3 加一,写 4,第二次覆盖其他线程的增量。
这些编号包含讲解与切换动作,不等于十八个机器槽编号。最终六次 store,结果 4。底部槽带按线程着色,用 X 标出覆盖更新的 store,发生在机器步骤 6、18,相距十二个位置。中途有重叠读改写就可能丢更新,但并非每次切换都会出错。
互斥锁保证什么
切到 Mutex,调度字符串逐字不变,但执行轨迹不同:加锁与解锁增加步骤,被阻塞的尝试也消耗一个槽,字符串重复使用,共三十五个机器步骤、十四次切换。它不是完全相同的切换点,只是同一调度输入。
锁只有一个拥有者。其他线程执行 lock.acquire() 时若锁已被占用,就进入等待队列,不能开始受保护的读取。本模型不自旋。拥有者仍可被调度暂停,无关代码也可运行;互斥保证的是遵守同一锁的临界区不能同时进入。
所以默认第二步仍能切到 T2,但 T2 只能执行导致等待的加锁尝试,不能读到将来过时的副本。只要所有更新都用同一锁保护完整读改写,且任务最终能前进,每次完成的增量都会保留,最终为 6。
本轨迹有五个槽用于发现锁暂不可用。同步把错误覆盖变成等待,仍有切换与竞争成本。工程上应让临界区覆盖完整不变量,同时避免把无关长计算或 I/O 放进去。
单独计数器适合原子读改写,不需用户管理互斥锁;但原子类型是否无锁、是否一条硬件指令,要看类型和平台。若不变量跨多个字段,例如余额与记录、队列与长度,分别原子更新每个字段还不够,必须协调整个更新,例如用共同临界区。
两把锁如何死锁
Two locks 中,T1 按 A→B 获取 lock_a、lock_b,T2 按 B→A 获取。
T1 取得 A 后切换;T2 取得 B,再请求 A 被阻塞;回到 T1 请求 B,也被阻塞。只需四次获取尝试,两者都等对方释放,释放又写在获取之后,counter 连 1 都到不了。
等待图形成环:T1 → lock_b → T2 → lock_a → T1。对这里的独占锁,环说明无法前进。统一全局获取顺序可消除这类环:让 T2 也先取 A,竞争失败者不持有 B,成功者可继续取 B、完成并释放。
边界情况
- 幸运顺序
111222111222111222只在完整递增边界切换,无锁也得到 6。一次成功不能证明所有顺序正确。 - 一次旧写可覆盖多个增量。
122222222211111111让 T1 读 0 后,T2 连做三次到 3;T1 的旧 store 把 3 写回 1,最终为 3。 - 阻塞不是完全免费。互斥轨迹的失败 acquire 消耗一个槽,但睡眠后不在本模型里持续占 CPU 自旋。
- 短调度串会重复。
12让线程交替;1让 T1 先完成。指定已完成或已阻塞线程的槽会转给另一个可运行者。 - 两个线程、一个变量已足够重现。更多线程不改变失效机制,具体发生概率依负载与调度而定。
常见错误
- 因
counter += 1只有一行就当原子。保证来自语言、运行时与同步 API,不能依赖某个 CPython/GIL 版本中观察到的偶然行为。 - 只锁写回
counter = tmp,不锁读取。过时副本早已形成,临界区必须包含完整读改写。 - 测试通过就认定没有竞态。它只覆盖执行过的顺序,还需分析共享状态与使用专门检测工具。
- 只给死锁加超时。超时可报告失败,但若不安全释放、回滚并消除循环依赖,根本问题仍在。这里直接统一锁顺序。
- 把整个函数都锁住。正确的锁范围是共享不变量需要的部分,无关 I/O 或长计算会增加串行等待。
模型范围
单核交错、每个伪指令原子,没有缓存与指令重排。真实内存模型与同步规则更复杂,C/C++ 的 volatile 本身不提供线程原子性或同步。互斥锁用拥有者加 FIFO 等待队列建模,不含自旋、futex,也不额外保证队列外公平性。
信号量和条件变量未演示。互斥锁有所有权语义,二值信号量通常不要求同一线程释放;条件变量配合锁等待共享谓词成立,不能将它们当成完全可互换的接口。