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

性能与严格性:惰性的账单

第 7 章立过牌子:「惰性是默认,不是免费」。这一章收账。空间泄漏是 Haskell 工程圈最著名的坑——程序逻辑全对,内存曲线一路向天,最后栈也炸了。好消息有两个:病灶极其单一(没被强迫的 thunk 攒成了长链),武器也就一小把(seq、感叹号、带撇的 fold)。本章的观测站会让你亲眼看着 thunk 链把浏览器的调用栈压爆——见过一次,你这辈子都记得。

空间泄漏seq / !foldl'profiling

解剖一次泄漏:一行代码的血案现场

total :: Int
total = foldl (+) 0 [1..1000000]      -- 求和一百万,能出什么事?

第 5 章埋的雷,现在正式引爆。逻辑无懈可击,类型完美通过,QuickCheck 都挑不出错——然后生产环境内存报警。foldl 沿列表走的时候,累加器是这样长的:

acc₀ = 0
acc₁ = ⟨0 + 1⟩                        -- 没人要求它的值,所以是 thunk
acc₂ = ⟨⟨0 + 1⟩ + 2⟩                  -- 引用上一个 thunk 的 thunk
acc₃ = ⟨⟨⟨0 + 1⟩ + 2⟩ + 3⟩            -- ……链子每步长一环
-- 走完列表:一条一百万环的 thunk 链,一个加法都还没做

两笔账同时爆:内存——每环几十字节,百万环几十 MB,而「正确答案」本来只需要 8 字节,GC 还帮不上忙(链上每一环都被下一环引用着,全是活对象);——最后 print 强迫这条链时,「算我得先算里面那个」的递归一百万层,栈溢出。症状与病灶隔着十万八千里:泄漏发生在 foldl 那行,爆炸发生在 print 那行——这也是空间泄漏难查的根源。惰性本身没错,错的是把惰性用在了一个「注定每一环都要算」的累加器上:这里的延迟没有任何收益(答案必然需要全部加法),只有纯支出——支票攒了一百万张,到期日还是同一天,一张都躲不掉。到观测站围观事故现场——逐档调大 N,看 thunk 链深度飙升,直到真的把栈压爆:

止血钳全家福

确诊之后,治疗原则一句话:在攒 thunk 的位置,强迫求值——把「欠着」改成「现结」,链就长不起来,内存曲线应声躺平。注意所有武器强迫的都是 WHNF(第 7 章:剥到最外层构造子),对累加器这种扁平的 Int 足够;嵌套结构另有深度武器,见下文 deepseq。武器从底层到顺手排列:

-- ① seq:第 7 章见过的原语。「先把 a 敲到 WHNF,再给你 b」
foldl'' f z []     = z
foldl'' f z (x:xs) = let z' = f z x
                     in  z' `seq` foldl'' f z' xs   -- 每步敲实,链长恒为 1

-- ② $!:严格版函数应用。f $! x ≡ x `seq` f x
sumTo acc n = go $! acc + n

-- ③ BangPatterns:最常用的写法,感叹号=「绑定时就敲实」
{-# LANGUAGE BangPatterns #-}
go !acc []     = acc
go !acc (x:xs) = go (acc + x) xs

-- ④ 严格字段:在 data 声明里下钉子,构造时字段必被求值
data Stats = Stats !Int !Int          -- 第 5 章的一趟统计,累加器字段全加 !

而日常代码里你连这些都不用写——直接用库里的严格版函数:foldl'(那一撇就是内置的 seq)、Data.Map.Strict(存值时敲实,不然 Map 里攒的全是 thunk)、Text/ByteString(天生严格)。库作者已经替你在正确的位置下过钉子了,你要做的只是选对进口。第 5 章的口诀现在有了完整证明:规约到单个值用 foldl',生成惰性结构用 foldr,裸 foldl 进博物馆

λ 源头碑 · 心法:累加器严格,结构惰性

什么时候该敲实,什么时候该放懒?一条心法覆盖九成场景:「注定要算的,立刻算(严格);可能不用算的,先欠着(惰性)」。累加器、计数器、running total——每一环都注定要算,严格;无限流、分支里的备用值、大结构中只取一角——可能整段跳过,惰性。第 7 章的 head . sort 和本章的 foldl' 不矛盾,它们分别站在这条线的两侧。写代码时问一句「这个 thunk 有机会被跳过吗?」——没有机会,就别让它出生。

病例集:三种最常见的泄漏形态

foldl 是教学标本,真实世界的泄漏爱穿这三件马甲——每件都配「病灶在哪 + 一刀止血」,建议按图索骥对照自己的代码:

病例一:容器里的懒值。往 Map 里累计计数,Map.insertWith (+) key 1 m——插入本身是严格的,但值的合并 (+) 是懒的:同一个 key 被命中一万次,Map 里存的是一条一万环的 ⟨⟨…+1⟩+1⟩ 链。监控系统、词频统计、按用户聚合,全是高发区。止血:用 Data.Map.Strict(它的 insertWith 会敲实新值),这也是为什么第 17 章军规要求默认 import Strict 版。

病例二:只取一半的元组let (总和, 个数) = summarize xs in 总和——个数没人要,但它的 thunk 拽着整条 xs 不能被 GC(thunk 的环境引用着列表)。病理学名叫「thunk 持有的意外引用」,症状是「一个早该释放的大结构迟迟不走」。止血:要么两个都算(bang 模式解构),要么别捆在一起返回。

病例三:长服务里的累积状态。服务器的会话状态、游戏的世界状态,每次更新 modifyIORef ref (update x)——modifyIORef 是懒的,更新函数不被求值,状态变成一条随运行时间无限增长的更新链,直到某次读取时一口气爆炸(或者 OOM 先到)。止血:modifyIORef'(又是那一撇)。规律浮出水面:凡是「写入者不看结果」的位置,thunk 就会攒——因为求值的责任被无限期推给了未来的读取者。API 设计者们为此提供了成对的懒/严格版本,带撇号和 Strict 后缀的那半边,才是累积场景的正确默认。

测量,别猜:profiling 三板斧

止血钳还差最后一号:前面的武器全部只敲到 WHNF(第 7 章:剥一层)。要「一撸到底」用 deepseq 库——force :: NFData a => a -> a 递归强迫整个结构,NFData 类型类可 derive。两个法定用途:跨线程传数据前(把计算真正留在工作线程,别把 thunk 扔给接收方去算——这坑在上一章的并发代码里格外阴险);以及下一节 criterion 基准里的 nf。日常代码里 force 出现频繁反而是坏味道:说明严格性没有在正确的位置(数据构造时)解决,在靠末端补票。

心法防新账,旧账要靠仪器。GHC 的性能工具链意外地硬核:

-- ① 总账:跑完打印内存/GC 统计。「productivity 43%」= 一半时间在 GC = 有泄漏
$ ./myapp +RTS -s

-- ② 定位:堆剖析,按「谁创建的」拆分内存曲线,输出火焰图式的 .hp 文件
$ ghc -prof -fprof-auto myapp.hs
$ ./myapp +RTS -hc -p        # 曲线里鼓包的那条线,就是攒 thunk 的元凶

-- ③ 基准:criterion 库,自动预热、多轮采样、给置信区间
benchmark = bench "parse 1MB" (nf parseLog sample)   -- nf = 深度求值再计时!

第三行藏着一个易错点:给惰性语言做基准,必须强迫结果到底(criterion 的 nf,normal form),否则你测的是「构造一个 thunk 要多久」——纳秒级,快得感人,毫无意义。这个坑下游同样有:给 Kotlin Sequence 或 Java Stream 计时,末尾不接终端操作,测的也是空气。惰性世界的性能座右铭:先确认「什么时候真的在算」,再谈快慢

说完病灶与止血,公道话也要讲:GHC 并不是坐视你踩雷的旁观者。严格性分析会自动证明「这个参数横竖都要被求值」并偷偷加上感叹号(开 -O2 后,本章开头那个 foldl 案例常常被编译器自己救活);fusionsum . map (*2) . filter even 熔成一个不生成任何中间列表的循环(第 9 章 Functor 定律在此兑现成优化许可)。工程姿态:相信优化器的日常,但别把正确性押在它身上——热点路径的严格性,自己写清楚。

那么,Haskell 到底快不快?

收账收到这里,该给性能一个全景答案了。编译到本机码、优化极重的 GHC,把 Haskell 放在「比 C/Rust 慢、和 Java/Go 一档、远快于 Python/Ruby」的区间:语言基准游戏(Benchmarks Game)里典型成绩是 C 的 2–4 倍耗时,和 Java 互有胜负。更重要的是形状:吞吐通常不是问题(Sigma 的百万 QPS 为证),尾延迟受 GC 影响(默认分代 GC 停顿毫秒级,低延迟场景可换增量 GC);而性能的方差比命令式语言大——同一段逻辑,懒对了地方飞快(fusion 熔掉整条中间结构),懒错了地方就是本章的泄漏。所以 Haskell 工程师的性能功课不在「抠指令」,在「管求值」:这正是本章存在的理由,也是它和你在 JVM 上「管对象分配、管 GC 代龄」的功课遥相呼应的地方——高级语言的性能,永远是「理解运行时模型」的函数。

▸ 下游对照 · 这些直觉你带得走

泄漏的具体形态是 Haskell 特产,但三条直觉全球通用。其一:「懒」永远有簿记成本——Kotlin Sequence 每个元素都要过一层迭代器包装,数据小时反而比 List 慢,和 thunk 的几十字节一个道理;其二:中间结构是吞吐杀手——fusion 之于 Haskell,如同「用 asSequence 避免三个中间 List」之于 Kotlin、「别在循环里 boxing」之于 Java;其三:性能问题先上仪器再动手——GHC 的 -hc 堆剖析对应 Android Profiler 的内存曲线,「哪条线在鼓包」的读图技能完全同构。你在上游学的是同一门课的困难模式。

⚠ 暗礁 · 感叹号不是撒的

见泄漏就全文撒 ! 是新手第二阶段病。代价真实存在:严格字段会把「本可跳过的计算」变成必付账单;在惰性流水线中间加 seq,可能直接打断 fusion,让内存暴涨(把整条流敲实成列表);头号惨案是把无限结构敲实——直接不终止。感叹号是手术刀,下刀前先用 profiling 确认病灶。顺带一条历史注脚:GHC 9.2 起可开 StrictData 扩展让 data 字段默认严格,不少工程团队(以及 Standard Chartered 的百万行代码库)就是这么配的——「结构默认严格、显式声明惰性」,等于把语言的默认值局部反转,也是一种务实。

✚ 动手泅渡

1. 在观测站找到你的浏览器的爆栈阈值(N 调到多大 foldl 崩、foldl' 稳)。解释为什么两者的「thunk 链最深」一个随 N 线性长、一个恒定。

2. 诊断题:length xs 会泄漏吗?sum (map expensive xs) 呢(sum 是严格的)?let avg = (sum xs, length xs) 只用 fst 呢?对每个说出「thunk 在哪、有没有机会被跳过」。

3. 把心法带回家:在你的 Kotlin/Java 项目里找一个「循环里累积大对象」的热点(StringBuilder 之外的字符串拼接是常客),用「注定要算的立刻算」重述它为什么慢。

4. 思考:第 12 章说 Writer 容易泄漏——现在你能给出病理了吗?(提示:日志列表的 <> 拼接,谁强迫它?什么时候?)