时间是一根轴,不是一个变量
这一章不引入任何新语法。它只问一个问题:在别人正在转账的时候查总额,你会查到什么?同一个交错,读两个位置错 11 次,读一个值错 0 次——而后者没有加锁,也没有开事务。
还是那两个账户,总额恒为 1000。有人在不停地转账, 你在旁边不停地查总额,查 30 次:
long total = accountA.balance + accountB.balance;
问:这 30 次里,有多少次算出来不等于 1000?
顺带一问:如果只允许改这一行代码,不许加锁、不许开事务, 你能让它一次都不错吗?
钱不见的那一瞬
转账在物理上是两次写:
accountA.balance -= 10; ← 写第一次
← ★ 这一瞬间,总额是 990
accountB.balance += 10; ← 写第二次
中间那一瞬,钱确实不见了。这不是 bug,是「把一件事拆成两步做」的必然结果—— 任何需要动两个地方的操作都有这么一瞬。
而你的查询也是两次读,中间同样可以被切走。 两次读撞进那一瞬,你就会看到 990。
默认种子下:读两个位置,30 次里错了 11 次。 第二行的「读一个值」,0 次。
那 0 次是怎么来的
看清楚两种读法的差别。它比看上去小,也比看上去重要:
;; 读法一:读两个位置 (+ @account-a @account-b) ;; ↑ 第一次读 ↑ 第二次读,中间隔着一段时间 ;; 读法二:读一个值 (let [snap @accounts] ;; 一次读,拿到一个值 (+ (:a snap) (:b snap))) ;; 之后都在这个值上算,它不会变
读法二里,两个账户的余额装在同一个 map 里, 而转账是把整个 map 换成新的:
(swap! accounts (fn [m]
(-> m
(update :a - 10)
(update :b + 10)))) ;; 算出完整的新值,一次换上
于是「中间状态」这个东西压根不存在:
- 新的 map 是在旁边完整算好的,算的过程中没有任何人能看见它。
- 换上去是一次指针赋值,原子的。
- 你读到的要么是转账前那个完整的 map,要么是转账后那个完整的 map。 没有第三种可能。
读法二不是「加了锁」,也不是「运气好」。 是它读到的那个东西没有中间状态可读。
一句话:值没有中间状态,位置才有。 你读两个位置就可能撕裂,读一个值就不可能。
这就是全书的主线
停下来看一眼你走过的路。第 1 章说「你以为你在改它」, 第 2 章说「改一个只新建 4 个节点」,第 3 章说「值 / 身份 / 状态是三样东西」。 当时这些听起来像是关于数据结构的讨论。
现在它们汇成一句关于时间的话:
你不可能读到一个正在变化的东西——因为没有东西在变化。 变化的只是「此刻这个名字指着哪个值」,而你读到的永远是某一个完整的值。
Rich Hickey 给这个模型起了个名字,叫epochal time model: 时间不是连续流动的,是一格一格的纪元。 每一格里,世界是静止的、完整的、可以随便看的; 格与格之间,是一次原子的整体切换。
# 你以为的时间:一个连续变化的量,随时可能被你逮到中间态
────────────────────────────────▶ 总额 1000 → 990 → 1000 → 990 …
# 实际的时间:一串完整的快照,切换在瞬间完成
◇ v1 ◇ v2 ◇ v3 ◆ v4
{a 500 {a 490 {a 483 {a 473
b 500} b 510} b 517} b 527}
────────────────────────────────▶ 时间
每一格都是完整的 1000,格与格之间没有缝
这个模型你已经用过很多次
数据库的 MVCC。你执行一个长查询, 期间别人在疯狂改数据,而你的结果永远是一致的—— 因为你读的是「事务开始那一刻的快照」。 PostgreSQL 的每一行都存着多个版本,就是为了这件事。 你早就依赖这个模型了,只是没把它当成一种编程方式。
Git。git log 的时候有人在推新提交,
你的输出不会错乱,因为你看的是某个 commit 往回的那条链——一个不可变的快照。
Datomic(Clojure 作者做的数据库)把这件事推到极致: 数据库本身是一个值。你可以把「上周二下午三点的整个数据库」 当成一个普通值传给函数,在上面跑任何查询。 没有「连接」这个概念,因为你查的不是一个正在变的东西。
你的显示器。屏幕不是连续更新的,是一帧一帧换的。 双缓冲(在后台画好一整帧,再一次性切换)就是为了避免撕裂—— 这个词在图形学里叫 screen tearing,和这一章的「撕裂读」是同一个词、同一件事、同一个解法。
怎么在你自己的代码里用它
不需要 Clojure。这一章的结论可以直接搬到 Kotlin:
// ✗ 会撕裂:两个可变字段
class Accounts { var a = 500; var b = 500 }
// ✓ 不会:一个不可变的值 + 一个原子引用
data class Accounts(val a: Int, val b: Int)
val state = AtomicReference(Accounts(500, 500))
// 写:算出完整的新值,一次换上
state.updateAndGet { s -> s.copy(a = s.a - 10, b = s.b + 10) }
// 读:一次读,拿到快照
val snap = state.get()
val total = snap.a + snap.b // 永远是 1000
就这么多。data class + AtomicReference +
「所有相关状态放进同一个值」。这是这本书里最容易带走的一条,
而且它不要求你的团队换语言。
唯一的要求是那个「一次换上」的新值造起来要便宜—— 而这正是第 2 章那 4 个节点的意义所在。如果 copy 一次要复制一百万个元素, 这个模式就用不起来了。
「每个字段的读写都是原子的(volatile / AtomicInteger), 所以读一组字段也是安全的。」
每一步原子,不等于整段原子。这是第 13 章那条直觉的升级版, 而且更难发现——因为你确实做了同步工作,只是做在了错误的粒度上。
把两个 AtomicInteger 换成一个 AtomicReference<Pair>,
问题立刻消失。不是因为后者「更原子」,
是因为需要保持一致的那些东西终于待在同一个值里了。
判断方法很简单,问一句:哪些数据必须一起变才有意义? 把它们放进同一个值。这句话可以直接当设计准则用。
《过冲》(反馈控制):那本书的主线是「你看到的是过去」, 并且证明了延迟会让环失控。这一章是它的搭档: 既然你看到的必然是过去,至少要保证那是一个真实存在过的完整时刻, 而不是几个时刻拼出来的、从未存在过的幻象。撕裂读的可怕就在这里—— 990 这个总额在任何一个瞬间都不曾是真的。
《当真》(PostgreSQL):MVCC 那一章可以直接对照着读。
答案是 C:30 次里错了 11 次,超过三分之一。
第二问:能——把两个余额放进同一个值,然后只读一次。
不需要锁,不需要事务。
A 「转账前后都是 1000,怎么查都对」—— 这个推理漏掉了「转账中」。而「转账中」不是一个可以忽略的瞬间: 在两次写之间,线程完全可能被切走。
B 「只有少数几次」——这是低估了撞车概率。 读要占两个时刻,写也要占两个时刻,两者都在持续进行,撞上是常态。
D 「每次都错」——也不对。大部分时候你读到的是稳定状态。 正是这种「大部分时候是对的」让这类 bug 极难排查: 它会以「偶尔对账差 10 块」的形式出现,重跑一遍又好了。
你进来时:并发的问题是「写」的问题,读是安全的, 顶多读到旧数据。
你出去时:读也会出错,而且错得更隐蔽—— 它会给你一个从未存在过的答案。 解法不是给读加锁,是让被读的东西没有中间状态。
卷 IV 四章合起来是一句话: 丢更新(13)、重试(14)、死锁(15)、撕裂(16)—— 四个问题,一个根源:可变的共享状态;一个方向:把要一起变的东西放进同一个值。
这一章的一句话
值没有中间状态,位置才有。所以读一个值不会撕裂,读两个位置会—— 时间是一串完整的快照,不是一个连续变化的量。
卷 IV 结束。卷 V 换一个方向:扩展。
你早就撞过这堵墙——想给一个不是你写的类加个方法,
于是你写了一个 StringUtils。下一章会告诉你,
那个工具类为什么解决不了问题:它不是多态的。
然后我们会不改任何源码、不做任何继承,
给 String、Long 和 nil 加上同一个方法。