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

ref 与 STM:转账问题的正确答案

atom 管一个身份,转账要同时动两个。这一章跑三种方案:第一种会当场死锁,第二种要求所有人守一条没写在代码里的规矩,第三种两样都不要——代价是 14 次重试。

死锁锁顺序这条隐形约定软件事务内存

▷ 先猜一下

两个账户 A 和 B,各 500 元。两个线程同时干活:

// 线程 1                    // 线程 2
transfer(A, B, 10);          transfer(B, A, 7);
// 各做 12 轮                // 各做 12 轮

transfer 的实现是最直觉的那种:先锁住转出方,再锁住转入方,然后搬钱

问:跑完之后会怎么样?

A 正常完成 24 笔,总额还是 1000 B 总额会算错,因为两把锁之间有空隙 C 会死锁,卡在某个地方不动了 D 偶尔会死锁,概率很低,压测才能发现

三种方案,一个模拟器

答案是 C(严格说是 D 描述的现实,但在这个交错下它确实死了, 而且这正是问题所在——它取决于运气)。切换看三种方案:

方案              完成的转账   死锁   重试   总额
────────────────────────────────────────────────────────
各锁各的           12 / 24      是     0      1000(但系统卡死了)
约定锁的顺序       24 / 24      否     0      1000
STM 事务           24 / 24      否     14     1000

三种方案的钱都没算错。差别不在正确性上,在别的地方。

第一种:死锁是怎么发生的

线程 1(A→B)            线程 2(B→A)
─────────────────────────────────────────
锁住 A ✓
                         锁住 B ✓
想锁 B … 等着            想锁 A … 等着
      ▲                        ▲
      └────── 互相等,永远 ─────┘

两个线程各拿着对方要的那把锁,谁也不肯先放。这就是死锁。

要命的地方在于:这段代码单看每一行都是对的。 「转账要锁住两个账户」——完全合理。 「先锁转出方」——也合理。问题只在两个线程同时这么做, 而且方向相反。

更要命的是它不一定会发生。上面那个模拟器用的是固定种子, 所以每次都死;真实系统里它取决于线程调度, 可能测试一万次都不出事,上线第三天凌晨两点出一次。

第二种:约定一个顺序

标准解法:所有人都按同一个顺序加锁—— 比如永远先锁账号小的那个。

Account first  = (a.id < b.id) ? a : b;
Account second = (a.id < b.id) ? b : a;
synchronized (first) { synchronized (second) { ... } }

这有效,24 笔全部完成。但请看清楚你买到的是什么:

◆ 可以带走的判断

「所有锁都按账号大小的顺序获取」这句话不在代码里。 它在文档里、在你脑子里、在你和同事的默契里。

编译器不检查它,测试很难覆盖它。 半年后有人加了第三个账户、或者在持锁时调了一个也会加锁的函数, 约定就破了——而破了之后不会报错,只是偶尔卡死。

这就是锁的根本问题:它是不可组合的。 两段各自正确的加锁代码,拼在一起可能就不对了, 而你没有任何办法在局部看出这一点。

第三种:事务

(def account-a (ref 500))
(def account-b (ref 500))

(defn transfer [from to amt]
  (dosync                        ;; ← 一个事务
    (alter from - amt)
    (alter to   + amt)))

没有锁,没有顺序,没有约定。dosync 里的几步要么全部生效, 要么全部不生效,而且别人绝不会看到「转了一半」的状态。

它怎么做到的?和 swap! 是同一个思路,只是范围更大:

1. 事务开始,记下现在的版本
2. 在<自己的快照>上跑完整段代码,改动先记在一边,不动真的 ref
3. 提交时检查:我读过的那些 ref,有没有被别人改过?
     没有 → 一次性全部生效
     有   → 丢掉,整段重来        ← 这就是那 14 次重试

因为改动是一次性生效的,所以不存在「转了一半」的瞬间; 因为它不持有任何锁,所以不可能死锁—— 没有人在等任何人,冲突的处理方式永远是「我重来」。

✎ 术语正名

STM(软件事务内存):把数据库事务那套搬到内存里。 你写过 BEGIN ... COMMIT,这就是同一件事, 只不过操作的是内存里的值而不是表里的行。

它和数据库事务共享同一组性质: 原子(要么全成要么全不成)、一致(别人看到的永远是完整状态)、隔离(事务之间互不干扰)。 少的那个是持久(内存断电就没了)。

它和 atom 的关系很简单:atom 是只涉及一个身份的事务。 一个 ref 的场合,用 atom 就够了。

代价,以及为什么它不流行

这本书答应过不吹。STM 有真实的代价,也确实没有成为主流:

  • 还是那条规矩:事务体必须是纯的。会重试, 所以里面不能发邮件、不能写文件。这是第 14 章那条规则的放大版。 (真要在事务里做副作用,Clojure 给了 agentsend 出去的动作会等到事务成功提交后才执行一次。)
  • 竞争激烈时重试会失控。长事务撞上高竞争, 可能反复重试到没完。
  • 性能不如手写的精细锁。版本检查、快照维护都要钱。
  • 说实话,它用得不多。Clojure 社区的实际经验是: 绝大多数应用一个 atom 装一个大 map 就够了—— 如果所有相关状态都在同一个值里,那本来就不需要协调多个身份

那这一章为什么还值得读?因为它把「锁的问题到底是什么」讲清楚了: 不是锁慢,是锁不可组合,而且它的正确性依赖于一条写不进代码的全局约定。 这个认识在你用任何语言写并发时都成立。

▸ 在现实里
  • 数据库事务:你每天都在用 STM,只是它在数据库里。 SELECT ... FOR UPDATE 是悲观(锁), 乐观锁 + 版本号是乐观——和这一章的三种方案一一对应。
  • Git 的合并:你在自己的分支上(快照)改完, 合并时检查有没有冲突,有就让你重来。分布式版本控制就是一个 STM。
  • Haskell 的 STM:那边的实现更彻底, 用类型系统保证事务里不能有 IO——把「必须是纯的」这条规矩变成了编译错误。 这是 Clojure 用文档要求、Haskell 用类型强制的典型对比。
✗ 这个直觉是错的

「死锁是因为锁用得不够小心。只要每个人都仔细一点、 review 的时候多看两眼,就能避免。」

死锁不是「不够仔细」,是局部正确性无法保证全局正确性

你可以把每一个函数都写对:每个都正确加锁、正确释放。 然后 A 调 B、B 调 C,三个正确的函数组合出一个死锁。 没有任何一次 code review 能看出这件事, 因为你要看的不是某一段代码,是所有可能的调用路径的笛卡尔积。

这就是为什么解法都是结构性的:要么规定全局顺序(把问题挪到人的纪律上), 要么干脆不持有锁(事务)。「更仔细一点」从来不在选项里。

◇ 另存一版

答案是 C:会死锁,这个交错下 24 笔只完成了 12 笔。

A 「正常完成」——如果你选这个,多半是因为 「锁住两个账户」听起来已经足够周全了。它确实周全, 问题不在一个线程内部,在两个线程相反的加锁顺序上。

B 「总额会算错」——不会。锁确实保住了正确性, 三种方案的总额都是 1000。死锁是活性问题,不是安全性问题: 系统不会给出错误的答案,它只是不再给出答案了。

D 「概率很低」——这是最接近真实世界的答案, 也是最危险的:概率低意味着测不出来,只在生产环境出现。 模拟器用固定种子把它变成必现,正是为了让你看清它长什么样。

你进来时:多个东西要一起改,就得加锁,小心一点就好。

你出去时:锁的问题不是慢,是不可组合—— 它的正确性依赖一条写不进代码的全局约定; 而事务把冲突的处理方式从「等」换成了「重来」,于是这条约定不需要了。

第 14 章的 atom 完全没被取代——单个身份用 atom, 它就是一个只涉及一个 ref 的事务。这一章只重建了一条路径: 多个身份怎么办

这一章的一句话

锁的问题不是慢,是不可组合:它的正确性依赖一条写不进代码的全局约定。 事务把「等」换成「重来」,于是那条约定就不需要了。

下一章是卷 IV 的收口,也是这本书主线落地最狠的一章。 我们不改任何写法,只问一个更基本的问题: 在转账进行的同时去查总额,会查到什么? 同一个交错下,读两个位置错了 11 次,读一个值错了 0 次—— 而后者没有加任何锁,也没有开任何事务。