Haskell 简明教程
Haskell 最显著的语言特征是默认的非严格求值(通常称为惰性求值,Lazy Evaluation)。它让我们可以处理无限数据流、把算法的生成和消费分开写,也会在某些场景下带来”空间泄漏”(Space Leak)的问题。
理解 Thunk、求值范式,以及 GHC 提供的性能剖析工具,是写出内存表现可控的 Haskell 程序的前提。
严格求值的语言(如 C、Go、Python)采用”按值调用”(Call by Value):函数参数在传入前必须先求值。
Haskell 采用按需调用(Call by Need):
Thunk:表达式在真正被需要之前,只以一个未求值的闭包(Thunk)形式存在于堆上。
共享(Sharing):Thunk 一旦被首次求值,其内存单元会被结果就地覆盖。之后对同一个值的引用直接读取结果,不会重复计算。
以 let x = expensive 10 in x + x 为例,x 在堆上只有一个 Thunk,两处引用指向同一个位置:
graph LR
subgraph before["求值前"]
P1["x + x<br/>左操作数"] --> T["Thunk<br/>expensive 10"]
P2["x + x<br/>右操作数"] --> T
end
subgraph after["求值后"]
Q1["x + x<br/>左操作数"] --> V["已求值<br/>42"]
Q2["x + x<br/>右操作数"] --> V
end
before -. "第一次需要 x 时求值<br/>并覆盖 Thunk" .-> after
expensive 10 只会执行一次,第二次读取 x 时直接拿到已经算好的 42。
当表达式的最外层已经求值为数据构造器或 Lambda 抽象 时,该表达式就处于 WHNF。内部的字段可以仍然是未求值的 Thunk:
-- 处于 WHNF 的例子:
(1 + 1, 2 + 2) -- 最外层是元组构造器 (,),内部表达式未求值
Just (take 5 [1..]) -- 最外层是数据构造器 Just
\x -> x * 2 -- 最外层是函数抽象
1 : [2 + 3, 4 + 5] -- 最外层是列表构造符 (:)
表达式被递归求值到底,内部不再包含任何 Thunk。例如整数 42,或者每个元素都已求值的列表 [1, 2, 3]。
在循环累加等不需要保留 Thunk 的场景,可以用以下手段主动引入严格求值:
seq 先把第一个参数求值到 WHNF,再返回第二个参数:
seq :: a -> b -> b
$! 是结合了 seq 的严格函数应用:
f $! x = x `seq` f x
seq 只求值到 WHNF,对嵌套结构内部的 Thunk 无能为力。Control.DeepSeq 模块的 deepseq 与 $!! 通过 NFData 类型类把整个结构递归求值到 NF:
import Control.DeepSeq (force)
import Data.IORef
summarize :: [Int] -> [(Int, Int)]
summarize xs = [(x, x * x) | x <- xs]
main :: IO ()
main = do
cache <- newIORef []
-- 不加 force,写进去的只是一个 Thunk,真正的计算被推迟到第一次读取时
writeIORef cache (force (summarize [1 .. 5]))
readIORef cache >>= print
force x 等价于 x `deepseq` x:把值求到 NF 再原样返回。上面如果不加 force,IORef 里存的是一个引用着整个输入的 Thunk,输入本身也因此无法回收;什么时候有人读它,什么时候才真正计算。
在函数参数或记录字段前加感叹号 !,指示编译器在进入函数体或构造数据时立即把它求值到 WHNF:
{-# LANGUAGE BangPatterns #-}
-- 严格累加器,避免空间泄漏
sumStrict :: Num a => a -> [a] -> a
sumStrict !acc [] = acc
sumStrict !acc (x:xs) = sumStrict (acc + x) xs
上面四种手段各有位置。日常写代码时不需要每处都想一遍求值顺序,记住下面几条默认做法就能避开绝大多数泄漏:
累加器用 foldl’ 或 bang。对有限列表做求和、计数、建 Map 时用 Data.List.foldl',手写递归时给累加器加 !。折叠一章的对比表已经说明不带撇号的 foldl 没有使用理由。
记录字段默认严格。数据字段如果只是用来存结果,就加 !,或者在模块顶部开启 {-# LANGUAGE StrictData #-} 让整个模块的字段默认严格。单子变换子一章的 Env 把 appPort 写成 !Int 就是这个原因:配置在启动时读一次,之后只读不写,没有必要留 Thunk。
容器选严格版本。Data.Map.Strict 在插入时就把值求到 WHNF,Data.Map.Lazy 会把 insertWith (+) 累积成一串加法。Text 和 ByteString 也各有严格与惰性两种模块,处理整块数据用严格版本,流式处理才用惰性版本。
存起来之前先 force。写进 IORef、TVar、MVar,通过通道发给另一个线程,或者在计时、缓存之前,用 force 或 $!! 把值算完。否则存进去的是一个 Thunk,计算会推迟到另一个线程、另一个时刻发生,并且拖住它引用的所有输入。
不用惰性 IO。readFile 和 hGetContents 返回的字符串是边读边生成的,句柄什么时候关闭取决于字符串什么时候被消费完。IO 一章讲了这个问题和替代写法,读整块用 Data.Text.IO 或 Data.ByteString,按行处理用循环。
惰性是朋友的场合。无限生成器(iterate、[1 ..])配合 take;take 5 (filter p xs) 找到五个就停;any、all、&& 短路;where 里绑定几个备选值,最后只用到一个,没用到的不会计算。这些场合下加严格性反而是错的。
不确定就先量。+RTS -s 不需要重新编译,一行就能看到最大驻留内存。只有数字异常时才动手加严格性,不要凭感觉到处加 !。
下面是一个真实的泄漏。求平均值,写法看起来再自然不过:
mean :: [Double] -> Double
mean xs = sum xs / fromIntegral (length xs)
main :: IO ()
main = print (mean [1 .. 10000000])
用 -O 编译后带 +RTS -s 运行:
$ ghc -O Mean.hs -o mean
$ ./mean +RTS -s
5000000.5
234,643,296 bytes maximum residency (9 sample(s))
537 MiB total memory in use (0 MiB lost due to fragmentation)
Total time 0.930s ( 0.928s elapsed)
一千万个 Double 占了五百多兆。问题不在 sum 或 length 本身,而在 xs 被用了两次:sum 遍历时列表元素本来可以边算边回收,但 length 还要再遍历一遍,于是整个列表被完整保留在内存里。修法是一次遍历同时算出和与个数,累加器用严格元组:
{-# LANGUAGE BangPatterns #-}
import Data.List (foldl')
mean :: [Double] -> Double
mean xs = total / fromIntegral count
where
(total, count) = foldl' step (0, 0 :: Int) xs
step (!s, !n) x = (s + x, n + 1)
$ ./mean +RTS -s
5000000.5
44,328 bytes maximum residency (2 sample(s))
7 MiB total memory in use (0 MiB lost due to fragmentation)
Total time 0.107s ( 0.107s elapsed)
最大驻留从两百多兆降到几十 KB,时间也从接近一秒降到零点一秒,省下的主要是垃圾回收的时间。注意 step 里的两个 !:foldl' 只把元组求到 WHNF,元组里面的 s + x 仍然可以是 Thunk。开 -O 时优化器通常能自己推断出这里需要严格求值,但不要依赖它:同一段代码去掉 bang 用 -O0 编译,最大驻留会涨到八百多兆,比原始版本还糟。
+RTS -s 能告诉你有没有泄漏,但不能告诉你泄漏在哪个函数。程序大了以后要靠下面的剖析工具。
当程序内存占用异常上升时,可以用 GHC 自带的剖析工具定位问题。
使用 -prof 与 -fprof-auto 编译:
$ ghc -prof -fprof-auto -rtsopts Main.hs -o myprog
-prof:开启剖析信息收集。
-fprof-auto:自动在所有顶层绑定处插入成本中心(Cost Centre)。
-rtsopts:允许运行时通过 +RTS 传递参数。
如果是 Cabal 项目,用 cabal build --enable-profiling 即可,依赖库也会以剖析模式重新编译。
运行程序并附带 RTS 参数:
$ ./myprog +RTS -p
运行结束后会在当前目录生成 myprog.prof。文件里列出每个成本中心占用的 CPU 时间百分比 与 内存分配量,可以直接找到热点。
用 -hc 按成本中心采样堆内存:
$ ./myprog +RTS -hc -p
运行完毕后生成 myprog.hp。用 GHC 自带的 hp2ps 转换成 PostScript 图:
$ hp2ps -e8in -c myprog.hp
查看生成的 myprog.ps:
正常的堆:呈现规律的锯齿状(垃圾回收周期性回收内存)。
有空间泄漏的堆:呈现持续上升的斜坡。图中会标出是哪个成本中心在不断堆积数据,据此就能定位需要加严格性的位置。
Haskell 采用按需调用,表达式只在被需要时求值一次,结果被共享。
WHNF 只求值最外层构造器,NF 求值到底。
seq、$!、deepseq 与 BangPatterns 用于在需要的位置引入严格性。
默认做法:累加器用 foldl' 或 bang,记录字段加 !,容器选严格版本,存进 IORef 或跨线程之前先 force,不用惰性 IO。
同一个大列表被遍历两次就会整个留在内存里,改成一次遍历的严格折叠。
+RTS -s 看有没有泄漏,-prof 配合 +RTS -p 和 -hc 定位泄漏在哪。