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

时间是一根轴,不是一个变量

这一章不引入任何新语法。它只问一个问题:在别人正在转账的时候查总额,你会查到什么?同一个交错,读两个位置错 11 次,读一个值错 0 次——而后者没有加锁,也没有开事务。

撕裂读快照就是值MVCC / Git / Datomic

▷ 先猜一下

还是那两个账户,总额恒为 1000。有人在不停地转账, 你在旁边不停地查总额,查 30 次:

long total = accountA.balance + accountB.balance;

问:这 30 次里,有多少次算出来不等于 1000

A 0 次——转账前后总额都是 1000,怎么查都对 B 少数几次——只有正好撞上的时候 C 大约三分之一 D 每次都错——因为总有人在转账

顺带一问:如果只允许改这一行代码,不许加锁、不许开事务, 你能让它一次都不错吗?

钱不见的那一瞬

转账在物理上是两次写

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 的每一行都存着多个版本,就是为了这件事。 你早就依赖这个模型了,只是没把它当成一种编程方式。

Gitgit 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>, 问题立刻消失。不是因为后者「更原子」, 是因为需要保持一致的那些东西终于待在同一个值里了。

判断方法很简单,问一句:哪些数据必须一起变才有意义? 把它们放进同一个值。这句话可以直接当设计准则用。

◇ 另存一版

答案是 C:30 次里错了 11 次,超过三分之一。
第二问:能——把两个余额放进同一个值,然后只读一次。 不需要锁,不需要事务。

A 「转账前后都是 1000,怎么查都对」—— 这个推理漏掉了「转账中」。而「转账中」不是一个可以忽略的瞬间: 在两次写之间,线程完全可能被切走。

B 「只有少数几次」——这是低估了撞车概率。 读要占两个时刻,写也要占两个时刻,两者都在持续进行,撞上是常态。

D 「每次都错」——也不对。大部分时候你读到的是稳定状态。 正是这种「大部分时候是对的」让这类 bug 极难排查: 它会以「偶尔对账差 10 块」的形式出现,重跑一遍又好了。

你进来时:并发的问题是「写」的问题,读是安全的, 顶多读到旧数据。

你出去时:读也会出错,而且错得更隐蔽—— 它会给你一个从未存在过的答案。 解法不是给读加锁,是让被读的东西没有中间状态

卷 IV 四章合起来是一句话: 丢更新(13)、重试(14)、死锁(15)、撕裂(16)—— 四个问题,一个根源:可变的共享状态;一个方向:把要一起变的东西放进同一个值。

这一章的一句话

值没有中间状态,位置才有。所以读一个值不会撕裂,读两个位置会—— 时间是一串完整的快照,不是一个连续变化的量。

卷 IV 结束。卷 V 换一个方向:扩展。 你早就撞过这堵墙——想给一个不是你写的类加个方法, 于是你写了一个 StringUtils。下一章会告诉你, 那个工具类为什么解决不了问题:它不是多态的。 然后我们会不改任何源码、不做任何继承, 给 StringLongnil 加上同一个方法。