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

atom:世界上最简单的并发原语

上一章说 atom 一次都没丢,代价是重试了 131 次。这一章把这笔代价量清楚:抢的人越多、重试越多,那到什么程度就不划算了?以及一个每个人都会踩一次的坑——为什么那个函数必须是纯的。

swap! 的四步重试率纯函数为什么是硬要求

▷ 先猜一下
(def log (atom []))

(swap! log conj (do (send-email! "有人登录了")
                    {:event :login}))

四个线程同时执行这段代码。

问:邮件会发出去几封?

A 4 封——每个线程一封 B 4 封以上——重试会导致重复发送 C 1 封——atom 保证整段代码只执行一次 D 4 封。send-email! 在 swap! 之外,不受重试影响

这道题的重点在于:那句 do 到底在什么时候执行?

swap! 到底做了什么

先把机制摊开。(swap! a f) 是一个循环:

loop:
  old ← 读 atom 当前的值
  new ← (f old)            ← 你的函数在这里被调用
  如果 atom 里还是 old:
      原子地换成 new,返回 new
  否则:
      goto loop            ← 有人插队了,old 已经过期,整个重来

四行。这就是 atom 的全部。没有锁,没有等待,没有队列。 冲突的处理方式是「重做一遍」,而不是「让别人等」。

拖动线程数看重试率的变化:线程越多,撞车越频繁,重试越多。 这就是乐观并发的形状——竞争不激烈时几乎零成本,竞争激烈时成本上升

那个坑

现在回答开头的题。答案是 B:4 封以上

为什么?因为 (do (send-email! ...) {:event :login}) 这个表达式是 swap!参数—— 它在调用 swap! 之前就求值了,只执行一次。

等等,那不是应该正好 4 封吗?

是的——就这段代码而言,答案确实是 4 封。 我把这道题写成一个陷阱,是因为绝大多数人在写它时想的是另一种形状, 而那种形状会出事:

;; ✗ 危险:副作用在函数<里面>,会跟着重试一起重复
(swap! log (fn [entries]
             (send-email! "有人登录了")     ;; ← 每次重试都发一封
             (conj entries {:event :login})))

这一版里 send-email! 在传给 swap! 的函数体内, 于是每重试一次就发一封。上面那台 demo 显示, 四个线程 100 次操作能撞出 131 次重试——那就是 131 封多余的邮件。

◆ 可以带走的判断

swap! 的函数可能被调用任意多次, 而且没有任何办法预测多少次。所以它必须是纯的: 只根据输入算输出,不碰外面的世界。

要副作用,就把它挪到 swap! 外面—— 用返回值来决定要不要做:

(let [new-log (swap! log conj {:event :login})]
  (send-email! "有人登录了"))     ;; ✓ 在外面,只执行一次

这条规则不只是 Clojure 的规矩。上一章说过, AtomicInteger、数据库乐观锁、git pull --rebase 全都是同一个模式,也全都有同一条要求:重来一次必须是安全的。 乐观并发的本质就是「先干,冲突了再来一遍」, 而「再来一遍」这件事的前提是第一遍没留下痕迹。

什么时候不该用 atom

老实说三种情况。

一、要协调两个以上的身份。

;; ✗ 转账:两次 swap! 之间,别人可能看到「钱不见了」的中间状态
(swap! account-a - 100)
;; ← 这一瞬间,总额少了 100
(swap! account-b + 100)

每个 swap! 各自是原子的,但两个合起来不是。 这正是下一章 ref 和事务要解决的问题。

(顺带一提:如果这两个账户能放进同一个 map 里, 那就用一个 atom 装那个 map,一次 swap! 改两个键—— 问题就没了。这是 Clojure 社区的第一反应,也常常是对的。)

二、竞争极其激烈。几十个线程猛抢同一个 atom 时, 重试会变成实实在在的浪费——大家都在算,只有一个能提交。 这时候要么分片(多个 atom 各管一段),要么换成队列或 agent。

三、更新函数很贵。重试意味着重算, 如果 f 要跑 50 毫秒,重试的代价就不再是可以忽略的。

顺手:几个常用招式

;; 读:@ 就是 deref
@counter

;; 无条件设置(不关心旧值)
(reset! counter 0)

;; 想知道改之前是什么
(swap-vals! counter inc)      ;; => [旧值 新值]

;; 自己做条件更新
(compare-and-set! counter 5 6)   ;; 只有当前是 5 才改成 6,返回 true/false

;; 监听变化(比如同步到 UI)
(add-watch counter :log
  (fn [key ref old new] (println old "→" new)))

add-watch 值得单独说一句:它是「状态一变就通知我」的钩子。 如果你写过 Android 的 LiveData 或 Compose 的 State, 这就是同一件事的最小版本——而且因为值不可变, 通知里带的 old 和 new 都是完整可靠的快照,不会在你处理时又变了

▸ 在现实里

一个真实场景:应用级缓存

(def cache (atom {}))

(defn get-user [id]
  (if-let [hit (get @cache id)]
    hit
    (let [user (fetch-from-db id)]        ;; ← 副作用在 swap! 外面
      (swap! cache assoc id user)          ;; ← 这里只是纯粹地放进去
      user)))

注意结构:慢的、有副作用的 fetch-from-db 在外面, swap! 里只有一个纯粹的 assoc。 代价是两个线程可能同时 fetch 同一个用户(重复一次查询), 但绝不会重复写坏缓存。

这是一个典型的取舍:用「偶尔多做一次读」换「完全不用锁」。 大多数缓存场景里这笔账是划算的。

✗ 这个直觉是错的

swap! 是原子的,所以它里面那段代码 整体只会执行一次,就像 synchronized 块一样。」

「原子」说的是结果—— 要么这次更新完整生效,要么完全没发生,别人看不到中间状态。 它说明那个函数被调用了几次。

synchronized悲观的:先占住,所以里面只跑一次。
swap!乐观的:先干,冲突了重来,所以可能跑很多次。

两者都能给你「原子的结果」,但对你写的代码要求完全不同。 这个区别是所有乐观并发方案的入场须知。

◇ 另存一版

答案是 B:4 封以上——但要看你把 send-email! 写在哪。 写成题目里那样(作为参数)确实只有 4 封; 而绝大多数人真正想写的是「在更新函数里做点事」, 那一版会重复 131 次。这道题的意义就是让你注意到这两种写法的区别。

A 「正好 4 封」——对题目里那段代码是对的, 但它对的原因是求值顺序,不是 atom 的保证。 代码稍微一挪就不对了,而这种「碰巧对」最危险。

C 「1 封,atom 保证只执行一次」——把 atom 当成了锁。 atom 谁也不拦,它只是在提交时检查。

D 「不受重试影响」——这句话对这一版成立, 它其实和 A 是同一个答案;选它的人往往已经看出了求值顺序, 但还没意识到「换个写法就会重复」。

你进来时:atom 是个线程安全的容器,用它就不用操心并发了。

你出去时:atom 用「重来」换掉了「等待」, 于是并发的负担从「记得加锁」变成了 「保证函数是纯的」——后者是你写代码时自己就能看出来的性质。

上一章的结论完全成立。这一章只重建了一条路径: 那 131 次重试是谁在付账

这一章的一句话

swap! 不锁,它重来——所以它的函数可能跑很多次,必须是纯的; 要做副作用,拿它的返回值在外面做。

下一章处理 atom 解决不了的那件事:两个账户之间转账。 我们会用同一个模拟器跑三种方案, 看着「各锁各的」那一版当场死锁—— 24 笔转账只完成了 12 笔,然后永远停在那里。