本节摘要:锁在并发世界里干两件事——互斥(同一时刻只有一人能改数据)与协调(多人按正确顺序接力)。不可变数据直接让互斥失业:数据不改,何须排他。剩下协调职责由三种函数式原语接管:Actor 的消息隔离、STM 的事务重试、纯函数的无锁并行。本节拆解三者的机制、用同一致谢场景做三方对照,并给出选型表。
用一个最小场景建立坐标。两个任务同时给同一个账户加钱,命令式的守护手段:
// 锁体系:互斥靠 synchronized,协调靠调用顺序纪律 class Account { private long cents = 0; public synchronized void deposit(long amount) { // 互斥:一次只许一人进入 cents += amount; } public synchronized long balance() { return cents; } }
这段代码的隐患不在眼前这一处锁,而在锁的组合方式:转账要同时锁两个账户(锁序不一致即死锁);读余额再扣款不是原子操作(检查与行动之间的竞态,要再加条件变量);锁粒度粗了吞吐崩、细了一致性崩。并发系统九成的事故出在"多把锁的组合"上,而不是单把锁忘了加。函数式的攻击点正是"组合":先让数据不可变(互斥失业),再把协调收拢进少数几种可推演的原语。
Actor 模型的规则极简:每个 Actor 独占自己的状态,外界永远不能直接碰它,只能给它发消息;Actor 从信箱逐条取消息、串行处理、可回复或转发。状态从未被共享,锁就无事可做——串行化不是靠互斥实现的,而是靠"每个 Actor 天然单线程"的结构保证:
%% Erlang:一个账户 Actor,不足二十行完整可用 account(Cents) -> receive {deposit, Amount, From} -> NewCents = Cents + Amount, From ! {ok, NewCents}, account(NewCents); %% 尾递归携带新状态——第3.3节的技术 {balance, From} -> From ! {balance, Cents}, account(Cents) end. %% 使用:并发存款天然安全——消息在信箱排队,逐条处理 %% Pid = spawn(fun() -> account(0) end), %% Pid ! {deposit, 100, self()}, Pid ! {deposit, 50, self()}, %% 先后必然有序,状态从未被两个进程同时看见
看这个例子里第 3 章的旧相识:account/1 是尾递归,参数就是 Actor 的全部状态——"下一帧携带新状态"与"Actor 循环携带新余额"是同一形态。消息本身也不可变(Erlang 数据天然不可变),从状态到消息,整个模型找不出一块可共享的可变内存。代价同样清楚:跨 Actor 的多步业务(转账要动两个账户)要么引入新的协调 Actor,要么接受"消息驱动 saga"的复杂度——锁的复杂度没有消失,换形成了消息协议。

软件事务内存(STM)的思路一句话:把一组内存修改包进事务,要么全部生效、要么全部不作数,冲突自动重试。Clojure 的实现只用两个语法件——ref(可事务修改的引用)与 dosync(事务块):
(def alice (ref 100)) (def bob (ref 50)) (defn transfer [from to amount] (dosync (when (< @from amount) ;; 事务内检查 (throw (ex-info "余额不足" {:need amount}))) (alter from - amount) ;; 修改在事务内暂存 (alter to + amount))) ;; 要么两笔都生效,要么都不 ;; 并发转账:无需任何锁 (future (transfer alice bob 30)) (future (transfer alice bob 40)) (println @alice @bob) ;; 结果总是 30/150 或 20/160 一致态,绝不会出现中途态
机制拆开看三步:事务开始时给涉及的 ref 建快照;修改打在私有副本上;提交时校验"这些 ref 自快照后没被别人动过",动过则整体重来。重试之所以零风险,是因为不可变结构让快照永远有效——命令式数据做不到这一点(别人随时可能改了你正在读的内存)。对比锁方案:转账双锁的锁序问题、检查与行动间的竞态,在 STM 里都从"工程师要防的坑"变成"机制保证不存在"。代价在热点:一万个事务同时改一个 ref,重试率飙升,吞吐崩塌——STM 适合冲突面广而深度浅的场景,不适合单点超热。
第三条路线最激进:任务本身拆成相互独立的纯函数,连"协调"都省了。经典演示是并行 map——数据分片、各片独立映射、结果拼接:
import Control.Parallel.Strategies (parMap, rpar, rseq) import Data.Char (toUpper) -- 四百万个元素的变换,自动分片到多核 processAll :: [String] -> [String] processAll = parMap rpar (map toUpper) main = do let bigData = replicate 4_000_000 "sample-text" print (length (processAll bigData)) -- 4000000,多核并行完成
为什么敢直接并行?纯函数的每次调用相互独立、无共享状态——这是第 1.2 节引用透明兑换的"安全并行权"。命令式语言做同样的事要保证循环体内无隐藏共享写,靠人工审计;纯函数语言由类型保证。工程上这条路线的适用面:批量变换、指标计算、编译各文件——"映射类"工作负载。有共享聚合的场景(全局计数、分组统计)要退回前两种方案或用专门的并行折叠。
三方案对照收束:Actor 适合"状态天然分属不同实体、跨实体协作走协议"的系统(IM、物联网网关、游戏服务器);STM 适合"多变量一致性强需求、冲突面广"的场景(账户网络、会话状态簇);纯函数并行适合"映射类"批处理。三条军规:其一,数据能不可变就不可变,这是三条路线共同的地基;其二,一个系统选一种主协调原语,混用超过两种,事故排查成本指数上升;其三,协调原语解决正确性,不解决性能——吞吐问题到 6.3 算账。
💡 关键直觉:锁的问题从来不是"忘记加锁",而是锁的组合不可推演。三种函数式原语的共同点是可推演:Actor 的串行是结构保证,STM 的原子性是机制保证,纯函数并行是类型保证。
协调问题有了函数式答案。下一节转向事件驱动场景:界面点击、消息推送、实时行情这些"流",如何用同一套函数式工具组织。