不变的东西不需要锁
前面十二章一直在说「值不会变」。这一章把这句话换算成钱。四个线程各加 25 次,应该是 100——可变计数器给出 36,丢了 64 次。而在完全相同的交错下,atom 给出 100。
int n = 0;
void bump() { n = n + 1; } // 四个线程,每个调用 25 次
问:最后 n 是多少?
如果你选了 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] ← 建好之后才存在,建的过程中没人能看见
所以并不是「不可变的东西也需要锁但我们用了别的技巧」。 是那个需要被保护的东西根本不存在。
AtomicInteger.incrementAndGet() 就是 CAS,
它内部是一个 do { v = get(); } while (!compareAndSet(v, v + 1)); 循环——
和 swap! 一模一样,包括「失败就重试」。
差别在于覆盖面:Java 的 CAS 只对少数几个包装类有效
(AtomicInteger、AtomicReference…),
因为对一个普通对象做 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(第二种),最后才是锁。 而「不共享」这条路在可变的世界里几乎走不通—— 因为你没法安全地把一份数据同时交给四个线程。 不可变把这条最省事的路重新打开了。
《同时》(并发):那本书把六个被混用的词钉死, 并且深入到 epoll、缓存行、GMP 这些机制层面。 这一章是它的补角——同样的问题,从「数据能不能变」这个角度看, 有一大半根本不会发生。两本书合起来才是完整的图: 《同时》讲怎么把并发做对,这一卷讲怎么让需要做对的地方变少。
答案是 C:小于等于 100,具体多少不确定(这次是 36)。
第二问:不是「加法不是原子的」,而是
你把读到的旧值当成了写回时的值。
A 「撞上的概率极低」——恰恰相反, 循环里的读—改—写撞车概率高得惊人,因为每个线程都在持续做这件事。 这次 100 次里丢了 64 次。
B 「JVM 保证 int 读写原子」——这句话本身是对的 (32 位读写确实原子),但它保证的是单次读和单次写不会撕裂, 管不了「读和写之间」。这是一个非常经典的误用: 把「每一步都原子」当成了「整段都原子」。
D 「大于 100 也有可能」——不会。丢失的更新只会让计数变小, 每次写回的值都不超过「读到的值 + 1」。
你进来时:并发安全 = 加锁,不加锁就是有风险。
你出去时:需要保护的是「中间状态被看见」, 而不可变数据结构没有中间状态; 剩下要协调的只有「身份此刻指向谁」,那用一条 CAS 指令就够了。
第 3 章的身份/状态/值一个字没变—— 这一章只是把它换算成了一个数字:64 次丢失。
这一章的一句话
锁保护的是「中间状态被人看见」,而值没有中间状态。 剩下要协调的只有一根指针,一条 CAS 指令就够。
下一章把 swap! 拆开看,问一个很实际的问题:
重试是有代价的,那这个代价有多大?线程越多是不是越糟?
顺带回答一个每个人都会踩的坑——
为什么传给 swap! 的函数必须是纯的,
往里面塞一句发邮件会发生什么。