STATION IV · 上游CH 15里程 15/20

并发与 STM:另一种无锁

并发容易让人想到隔壁 BEAM(Erlang/Elixir)的地盘,但 GHC 在这条河上另有一座名胜:软件事务内存(STM)。它对锁的批判直击要害——锁最深的毛病不是难写,是不可组合:两个各自正确的加锁操作,拼起来可能死锁。STM 的方案是把共享内存的操作变成数据库式的事务:读-改-写随便组合,提交时校验冲突,冲突就自动重跑。更妙的是,这个方案在别的语言里反复试验反复失败,偏偏在 Haskell 里成了——原因你已经学过了,就在第 11 章。

forkIO锁的不可组合性atomicallyretry

底盘:百万绿色线程

先交代这条河的跑道。GHC 的线程是运行时自己调度的绿色线程,创建一个几百字节、微秒级,百万并发是常规操作;运行时把它们复用到少量 OS 线程上(N:M 调度),阻塞 IO 自动让位,多核并行开个 -threaded 编译选项即得。这套模型你最近几年见过两个移植:Kotlin 协程、Java 21 的虚拟线程(Project Loom)——又是熟悉的水流方向。开线程是一个普通函数(不是关键字,呼应第 11 章「控制流是库」):

import Control.Concurrent

main = do
  forkIO (worker "甲")       -- forkIO :: IO () -> IO ThreadId
  forkIO (worker "乙")
  threadDelay 1000000        -- 微秒。主线程退场,全员退场——像不像 daemon 线程?

线程有了,问题也跟着来了:纯的世界共享随便(反正没人能改,这是不可变的并发红利),但 IO 世界的线程们要协作,就得有可变的共享点。它们怎么安全地共享数据?Haskell 给了一条阶梯:最底层是 MVar(一个带锁的单格信箱,take 空则阻塞),够用但会遇到锁的所有经典毛病。毛病里最深的那个,值得用一个完整例子钉死。

先认识一下阶梯的底层。MVar 是一个「至多装一个值的信箱」:takeMVar 取走内容(空则阻塞等待),putMVar 放入(满则阻塞)。拿它当锁、当一格队列、当结果回传信道都行:

main = do
  box <- newEmptyMVar
  forkIO $ do
    r <- expensiveQuery          -- 后台线程慢慢算
    putMVar box r                -- 算完投递
  putStrLn "主线程先干点别的……"
  result <- takeMVar box         -- 需要结果时再取,没到就等
  print result

这个「一格信箱」的通信模式,你在 Kotlin 里叫 CompletableDeferred,在 Java 里叫 CompletableFuture。MVar 简单直接,但两个 MVar 一组合,你就站在了下一节的悬崖边。

锁的原罪:正确 + 正确 = 死锁

银行账户,每个配一把锁,「取款」和「存款」各自加锁,各自完全正确。现在写「转账」:

fun transfer(from: Account, to: Account, amt: Long) {
    synchronized(from) {          // 线程1:transfer(a, b) 锁住 a,等 b
        synchronized(to) {        // 线程2:transfer(b, a) 锁住 b,等 a
            from.withdraw(amt)    // ——经典死锁,而且只在生产环境的月黑风高夜出现
            to.deposit(amt)
        }
    }
}

教科书解法是「全局按账户 ID 排序加锁」——能用,但注意它意味着什么:每个调用点都要知道并遵守一个全局约定,而编译器完全不检查,新同事也不会从任何签名里看到它。锁的组合性问题还有个更隐蔽的形态:库作者把加锁封装得再好,调用方想把两个库操作合成一个原子动作(「取款且积分」),要么拿不到锁的把手,要么拿到了就得自己重新推演整个加锁顺序。抽象在锁面前会漏——这是 Simon Peyton Jones 那篇著名论文《Beautiful Concurrency》的出发点。

STM:借数据库的脑子用一用

数据库领域四十年前就解决过「并发读写共享状态」这道题:答案叫事务。STM 把这套搬进内存——共享变量用 TVar,操作打包成 STM 动作,atomically 负责执行:要么整体生效,要么像没发生过:

transfer :: TVar Int -> TVar Int -> Int -> STM ()
transfer from to amt = do
  balance <- readTVar from
  when (balance < amt) retry        -- 余额不够?挂起,等有人存钱再自动重试
  writeTVar from (balance - amt)
  modifyTVar to (+ amt)

main = atomically (transfer alice bob 100)   -- 没有锁。没有加锁顺序。没有死锁。

机制是乐观并发:事务运行时对 TVar 的写全部记在私有日志里(不落地),同时记下「读了哪些 TVar 的哪个版本」,提交瞬间校验——期间有没有别的事务动过我读过的东西?没有,日志一次性落地;有,整本丢弃重跑,对你完全透明。转账台里两种模式各跑几遍,盯住裸写模式的「不变量被破坏」和 STM 模式的 retry 日志:

回到组合性,这才是 STM 的真正胜负手:两个 STM 动作用 do 一串,就是一个更大的原子动作。「取款且积分」?atomically (transfer a b n >> addPoints a 10),完事——不需要知道任何锁序约定,库作者也不需要为「可能被组合」预留设计。组合性还向上生长:标准库在 TVar 之上盖了整层并发数据结构——TChan(事务性队列)、TMVarTSem(信号量),它们全是普通 STM 代码,你也能盖自己的楼。「从队列 A 取一个、处理、放进队列 B,整体原子」这种跨结构事务,锁世界要精心设计,这里是三行 do。上面代码里的 retry 同样值得多看一眼:「条件不满足就等待」在锁世界是 wait/notify 迷宫(虚假唤醒、丢失通知……),这里是一个词,运行时知道你读过哪些 TVar,其中任何一个被改写就自动重跑你——条件变量被自动化了

retry 还有个双生组合子把「等待」也变得可组合:orElse a b——先试事务 a,a 调用了 retry 就换 b,两个都 retry 才整体等待。于是「从任何一个有货的队列取一件」写成 foldr1 orElse (map takeQueue qs)(第 5 章的 fold 在并发层再就业);「等结果,但最多等三秒」是 readResult `orElse` timeoutSignal。锁世界里,「等 A 或 B 任一条件」要把两个条件变量的通知逻辑手工缝合;这里是一个二元操作符,而且天然满足结合律——可组合的阻塞,是 STM 最被低估的一件货。

为什么偏偏是 Haskell 做成了

STM 的点子不新,C++/Java/C# 都轰轰烈烈试过,基本都退潮了(Clojure 是少数例外,靠的是全家不可变)。死因清一色:事务可能被重跑任意多次,如果事务体里有不可回滚的副作用——发个网络包、弹个窗、写行日志——重跑三次就发三个包。命令式语言拦不住你在事务里干这些,于是 STM 的安全性靠文档和祈祷。

现在把第 11 章的国境线搬出来:Haskell 的事务体类型是 STM a,这个 Monad 里只有 TVar 操作——IO 动作类型不对,放不进去,编译不过。「事务内禁止副作用」从道德约束升格为类型检查。这是全书「类型系统当架构执行者」的最佳单例:同一个点子,有国境线的语言做成了工业特性,没有的做成了考古遗迹。GHC 的 STM 从 2005 年稳定服役至今,支撑着 Meta Sigma 每秒百万请求的规则引擎。

▸ 下游对照 · 你身边的乐观并发

STM 的思想其实早就包围你了:数据库的乐观锁(版本号字段 + CAS 更新)、Java 的 AtomicReference.compareAndSet 循环、Git 的「提交前 rebase 检查冲突」、Android Room/Firestore 的事务重跑回调——全是「先干活,提交时验冲突,冲突则重来」。差别在粒度和组合性:CAS 只保护单变量,数据库事务不跨进程内存;STM 把同款语义带进了任意多个内存变量的任意组合。下次写 CAS 重试循环时,你会认出自己正在手工展开一个 atomically。

◆ 水文记录 · async:结构化并发的上游版

日常并发的另一半是「同时做几件事,收齐结果」。Haskell 的 async 库把它做成了组合子:concurrently a b(并行跑俩,都完才返回)、race a b(谁先完用谁,另一个自动取消)、mapConcurrently f xs(并行 map)。关键性质:异常会正确传播,失败方会取消同伴,没有孤儿线程——这正是 Kotlin 结构化并发(coroutineScope/supervisorScope)承诺的东西,async 库 2012 年就把这套语义用 20 行核心代码立起来了。读它的源码(Control.Concurrent.Async,真的只有几百行)是理解结构化并发本质的最短路径。

感受一下组合子风格的并发长什么样——「三个接口并行拉,任何一个失败整体失败并取消其余,全部成功则合并」:

fetchAll :: IO Dashboard
fetchAll = do
  (user, (orders, stats)) <- concurrently fetchUser
                               (concurrently fetchOrders fetchStats)
  pure (Dashboard user orders stats)

没有 scope builder、没有 job 层级声明——并发结构直接就是表达式的形状,取消与传播语义由 concurrently 这个携带。对照你写过的 coroutineScope { val a = async{..}; val b = async{..}; ... },谁在管「失败取消同伴」这件事,两边的答案分别写在哪里?

⚠ 暗礁 · STM 的账单

其一,长事务饥饿:事务越长,读集越大,被别人「撞版本」的概率越高——极端情况下大事务反复重跑,被小事务们活活饿死。军规:事务要短,重计算放事务外(纯函数算好,事务内只做读写)。其二,retry 等待的是 TVar 变化,拿它等时间、等网络是用错了工具。其三,吞吐不是免费的:无冲突时 STM 比裸锁略慢(记账开销);它买的是正确性和组合性,不是极限性能——高争用的计数器场景,该用原子操作还是用原子操作。

✚ 动手泅渡

1. 在转账台把种子换几个值,找到一个「裸写模式恰好没出错」的种子——然后解释为什么「跑了三次都对」在并发世界里什么都证明不了。(这题顺便复习第 13 章:并发 bug 为什么特别适合属性测试 + 种子复现?)

2. 用 retry 的语义设计「有界队列」:put 在队满时等待、take 在队空时等待。写出两个操作的 STM 伪代码——对比你写过的 wait/notify 版本少了哪些坑。

3. 死锁考古:回忆(或搜索)你团队遇过的一次死锁。用本章的语言分析:它是不是「两个各自正确的加锁操作被组合」的产物?STM 能救吗?如果不能,是因为涉及了什么?

4. 思考:为什么 atomically :: STM a -> IO a 的方向是「STM 进 IO」,而没有 IO a -> STM a?用第 11 章的国境线语言回答。