卷 IV · 时间CH 13深度 13/24

不变的东西不需要锁

前面十二章一直在说「值不会变」。这一章把这句话换算成钱。四个线程各加 25 次,应该是 100——可变计数器给出 36,丢了 64 次。而在完全相同的交错下,atom 给出 100。

丢更新compare-and-set为什么锁不是唯一解

▷ 先猜一下
int n = 0;
void bump() { n = n + 1; }     // 四个线程,每个调用 25 次

问:最后 n 是多少?

A 100,加法很快,撞上的概率极低 B 100,JVM 保证 int 的读写是原子的 C 小于等于 100,具体多少不确定 D 大于 100 也有可能

如果你选了 C,那么第二问:丢更新的根本原因是什么? 是「加法不是原子的」吗?

先看数

答案是 C。下面这台模拟器把四个线程的交错真的跑出来—— 每个线程在「读」和「写回」之间会被切走,调度由一个带种子的随机数决定, 所以同一个种子永远给出同一个结果:

默认那一档(种子 7):可变计数器最终是 36,丢了 64 次更新。 而 atom 那一列是 100,重试了 131 次。

请特别注意一件事:两边跑的是同一个调度、同一个交错。 不是「atom 运气好」——你可以点「换一个交错」试很多次, atom 那一列永远是 100。

丢的那 64 次去哪了

n = n + 1 其实是三件事:

1. 读   把 n 现在的值读到寄存器      ← 假设读到 5
2. 加   算 5 + 1 = 6
3. 写   把 6 写回 n

如果在第 1 步和第 3 步之间,别的线程也读了 5、算了 6、写了 6, 那么两个线程各干了一次活,n 却只涨了 1。一次更新丢了。

现在回答第二问,这是这一章的关键:

◆ 可以带走的判断

丢更新的原因不是「加法不是原子的」—— 加法在哪儿都不是原子的,包括 Clojure 里。

真正的原因是:你把「读到的那个值」当成了「写回时的值」。 这两者之间隔着一段时间,而你的代码假设这段时间里什么都没发生。

看出来了吗?这就是第 3 章那件事的另一个说法。 你读到的是某一时刻的状态,你写回时用的却是当前的身份。 在这两个动作之间,那个身份可能已经指向别的值了。

三种回答

第一种:加锁。

synchronized void bump() { n = n + 1; }

做法是不许别人在这段时间里插进来。它有效,但代价是真实的: 别的线程要等;忘了加锁不会有人提醒你; 锁多了会死锁(第 15 章会真的撞一次); 而且「哪些数据被哪把锁保护」这件事不写在代码里,写在你脑子里。

第二种:CAS(比较并交换)。

(def n (atom 0))
(swap! n inc)

swap! 不锁任何东西。它的做法是写回之前先确认没人动过

读到旧值 v
算出新值 (f v)
如果 atom 里还是 v  → 换成新值,成功
否则                → 丢掉重算   ← 这就是那 131 次重试

关键在于第三步是一条原子指令(x86 的 CMPXCHG): 「比较并交换」这两个动作在硬件层面不可分割。 于是「我读到的还在不在」这个问题有了可靠答案。

第三种:一开始就没有共享的可变状态。

这是这本书前十二章一直在铺的路。如果每个线程各算各的, 最后把结果合并——就没有任何东西需要协调:

(reduce + (pmap process chunks))    ;; 各算各的,最后合并

这条路能走通的前提,正是数据不可变: 四个线程同时读同一个向量,不需要任何同步,因为它谁也改不了。

为什么值不需要锁

把话说到底。锁保护的到底是什么?

锁保护的是「不变量在中间状态被人看见」。 比如一个链表插入到一半,next 指针已经改了但 size 还没改—— 这一瞬间的数据结构是的,谁看见谁出事。锁的作用是不让人看见这一瞬。

而不可变数据结构没有这一瞬。第 2 章讲过: assoc 是先把新版本完整建好(新建 4 个节点), 最后返回新的根。在这个过程中,旧版本自始至终是完整的、正确的; 新版本在完全建好之前,没有任何人能看见它。

# 可变结构:改的过程中有一段「坏」的时间
  [1 2 3] ──改──▶ [1 2 ?] ──▶ [1 2 9]
                    ▲ 这一瞬被别人读到 = bug,所以要锁

# 不可变结构:没有中间状态
  ◇ [1 2 3]  ← 从头到尾都是完整的
  ◆ [1 2 9]  ← 建好之后才存在,建的过程中没人能看见

所以并不是「不可变的东西也需要锁但我们用了别的技巧」。 是那个需要被保护的东西根本不存在

☕ 写 Java 的你

AtomicInteger.incrementAndGet() 就是 CAS, 它内部是一个 do { v = get(); } while (!compareAndSet(v, v + 1)); 循环—— 和 swap! 一模一样,包括「失败就重试」。

差别在于覆盖面:Java 的 CAS 只对少数几个包装类有效 (AtomicIntegerAtomicReference…), 因为对一个普通对象做 CAS 没有意义——你 CAS 成功了, 但对象内部还是可能被人改。

Clojure 的 swap!任何值都有效: 一个一百万元素的向量、一个深层嵌套的 map,都能这么原子地更新。 原因就是第 2 章那件事:换掉整个值只要换一根指针,而新值造起来很便宜

▸ 在现实里
  • Git 的 push 被拒! [rejected] non-fast-forward 就是一次失败的 CAS。你基于某个 commit 做了工作, 推的时候远端发现「你以为的父提交已经不是我现在的头了」,于是拒绝。 git pull --rebase 就是重试:拿到新值,重做,再试一次。
  • 数据库的乐观锁UPDATE ... WHERE version = 3, 影响行数为 0 就说明有人先改了。逐字就是 CAS。
  • HTTP 的 If-Match / ETag:同一个模式, 「我基于这个版本改的,如果它变了就别接受」。

三样东西都在做同一件事:不阻止别人动手,只是在提交时检查一下。 这叫乐观并发控制,而它成立的前提是「重来一次不会有副作用」。

✗ 这个直觉是错的

「并发就是要加锁。不加锁的方案要么是高手才敢用的技巧, 要么就是不安全。」

锁是三种做法之一,而且是要求最高的那一种—— 它要求所有人都记得加、都按同样的顺序加、都不在持锁时调用未知代码。 这三条里任何一条被违反,问题都不会当场暴露。

更值得记住的是排序:先想能不能不共享(第三种), 再想能不能用 CAS(第二种),最后才是锁。 而「不共享」这条路在可变的世界里几乎走不通—— 因为你没法安全地把一份数据同时交给四个线程。 不可变把这条最省事的路重新打开了。

◇ 另存一版

答案是 C:小于等于 100,具体多少不确定(这次是 36)。
第二问:不是「加法不是原子的」,而是 你把读到的旧值当成了写回时的值

A 「撞上的概率极低」——恰恰相反, 循环里的读—改—写撞车概率高得惊人,因为每个线程都在持续做这件事。 这次 100 次里丢了 64 次。

B 「JVM 保证 int 读写原子」——这句话本身是对的 (32 位读写确实原子),但它保证的是单次读单次写不会撕裂, 管不了「读和写之间」。这是一个非常经典的误用: 把「每一步都原子」当成了「整段都原子」。

D 「大于 100 也有可能」——不会。丢失的更新只会让计数变小, 每次写回的值都不超过「读到的值 + 1」。

你进来时:并发安全 = 加锁,不加锁就是有风险。

你出去时:需要保护的是「中间状态被看见」, 而不可变数据结构没有中间状态; 剩下要协调的只有「身份此刻指向谁」,那用一条 CAS 指令就够了。

第 3 章的身份/状态/值一个字没变—— 这一章只是把它换算成了一个数字:64 次丢失

这一章的一句话

锁保护的是「中间状态被人看见」,而值没有中间状态。 剩下要协调的只有一根指针,一条 CAS 指令就够。

下一章把 swap! 拆开看,问一个很实际的问题: 重试是有代价的,那这个代价有多大?线程越多是不是越糟? 顺带回答一个每个人都会踩的坑—— 为什么传给 swap! 的函数必须是纯的, 往里面塞一句发邮件会发生什么。