ref 与 STM:转账问题的正确答案
atom 管一个身份,转账要同时动两个。这一章跑三种方案:第一种会当场死锁,第二种要求所有人守一条没写在代码里的规矩,第三种两样都不要——代价是 14 次重试。
两个账户 A 和 B,各 500 元。两个线程同时干活:
// 线程 1 // 线程 2 transfer(A, B, 10); transfer(B, A, 7); // 各做 12 轮 // 各做 12 轮
transfer 的实现是最直觉的那种:先锁住转出方,再锁住转入方,然后搬钱。
问:跑完之后会怎么样?
三种方案,一个模拟器
答案是 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 给了
agent:send出去的动作会等到事务成功提交后才执行一次。) - 竞争激烈时重试会失控。长事务撞上高竞争, 可能反复重试到没完。
- 性能不如手写的精细锁。版本检查、快照维护都要钱。
- 说实话,它用得不多。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 次—— 而后者没有加任何锁,也没有开任何事务。