atom:世界上最简单的并发原语
上一章说 atom 一次都没丢,代价是重试了 131 次。这一章把这笔代价量清楚:抢的人越多、重试越多,那到什么程度就不划算了?以及一个每个人都会踩一次的坑——为什么那个函数必须是纯的。
(def log (atom []))
(swap! log conj (do (send-email! "有人登录了")
{:event :login}))
四个线程同时执行这段代码。
问:邮件会发出去几封?
这道题的重点在于:那句 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 笔,然后永远停在那里。