Haskell 简明教程
在很多主流语言中,高并发编程需要在两种方案里取舍:使用操作系统内核线程,承担较大的内存与上下文切换开销;或者使用基于事件循环的异步回调模型,承担”函数着色(Function Coloring)”与回调嵌套的代价。
GHC 采用的是用户态 M:N 调度运行时。它在底层实现了类似 Node.js、Python asyncio 的事件循环,同时让代码保持同步风格。
操作系统内核线程(1:1 模型)通常要分配 1MB 到 8MB 的栈,线程切换需要进出内核态,单个进程开几千个线程就会接近上限。
GHC 运行时的线程模型不同:
初始开销小:GHC 的线程是完全在用户态调度的 绿色线程(Green Threads),初始栈只有约 1KB,按需增长;
数量可以很大:单个进程创建几十万甚至上百万个绿色线程是可行的;
切换开销小:上下文切换在用户空间完成,开销远小于内核线程切换。
Control.Concurrent 提供 forkIO 启动一个绿色线程:
import Control.Concurrent
import Control.Monad (forever)
simpleThreadDemo :: IO ()
simpleThreadDemo = do
-- 启动一个后台线程
threadId <- forkIO $ forever $ do
putStrLn "后台线程工作中..."
threadDelay 1000000 -- 暂停 1 秒(单位是微秒)
putStrLn $ "已启动线程,ID: " ++ show threadId
threadDelay 3000000 -- 主线程等待 3 秒后退出
putStrLn "主线程退出,其余线程随之终止。"
如果大量绿色线程都在等待网络 Socket,会不会把系统线程耗尽?
答案是不会:代码是同步风格的,底层执行的是非阻塞 I/O。
GHC 运行时内置了一个 I/O Manager,基于操作系统的多路复用接口:
Linux 上使用 epoll;
macOS / BSD 上使用 kqueue;
Windows 上有独立的实现(较新版本提供基于 IOCP 的 WinIO,需要显式启用)。
当一个绿色线程执行网络读取时,它不会让底层的操作系统线程休眠。它向 I/O Manager 注册一个”读就绪”事件,然后挂起让出控制权,调度器转而执行其他就绪的绿色线程。内核通过 epoll 通知数据就绪后,I/O Manager 唤醒对应的绿色线程继续执行。
小技巧
如果你熟悉其他语言:没有”函数着色问题”的异步:
对比 Python asyncio / C# / JavaScript:
这些语言的异步靠事件循环驱动,但存在“函数着色问题(Function Coloring Problem)”:一旦底层某个函数被声明为 async,调用链上的所有上层函数都得加上 async 与 await,同步代码和异步代码变成两个不能随意混用的世界。
Haskell 的做法:
所有 I/O 都是 IO 类型,没有同步与异步之分。代码按顺序书写,运行时在后台通过 epoll 完成非阻塞调度。
网络通信有 epoll 这类异步机制,但在现代操作系统内核里,本地文件读写、DNS 解析以及大部分外部 C 库(FFI)调用仍然是阻塞的。
如果使用 GHC 默认的单线程运行时(non-threaded RTS): 一旦某个绿色线程触发了阻塞的系统调用(比如一次耗时很长的磁盘读取或 C 函数调用),进程唯一的操作系统线程会被内核挂起,其余所有绿色线程也跟着停下来。
为了解决这个问题,实际项目通常在编译时开启 -threaded:
$ ghc -O2 -threaded -rtsopts -with-rtsopts="-N" MyApp.hs
-threaded:让运行时使用多个操作系统线程来调度绿色线程(M:N)。
-rtsopts -with-rtsopts="-N":让运行时按机器的核心数启动相应数量的工作线程(Capability),实现多核并行。
开启 -threaded 之后,绿色线程、Capability、操作系统线程与核心之间的关系如下:
graph TD
subgraph hs["Haskell 绿色线程(数量可以很大)"]
H1["线程 1"]
H2["线程 2"]
H3["线程 3"]
H4["..."]
Hn["线程 n"]
end
subgraph cap["Capability(数量由 -N 决定)"]
C1["Capability 1<br/>运行队列"]
C2["Capability 2<br/>运行队列"]
end
subgraph os["操作系统线程与核心"]
O1["OS 线程 1"] --> K1["核心 1"]
O2["OS 线程 2"] --> K2["核心 2"]
O3["OS 线程 3<br/>执行阻塞的系统调用或 FFI"]
end
IOM["I/O Manager<br/>用 epoll / kqueue 等待网络事件<br/>就绪后把线程放回运行队列"]
H1 --> C1
H2 --> C1
H3 --> C2
H4 --> C2
Hn --> C2
C1 --> O1
C2 --> O2
C1 -. "阻塞调用时让出 Capability" .-> O3
H2 -. "等待 Socket" .-> IOM
IOM -. "唤醒" .-> C1
一个 Capability 同一时刻只运行一个绿色线程。绿色线程发起阻塞调用时,运行时把 Capability 交给另一个操作系统线程继续调度,阻塞的调用则由原来的线程等待完成。
小技巧
如果你熟悉其他语言:类比 Go 的调度器与 Rust 的 Tokio:
Go 的 GMP 模型:Goroutine 遇到阻塞系统调用时,调度器会把阻塞的 M(系统线程)与 P(处理器)解绑,并新建或唤醒另一个系统线程接管其他 Goroutine。
Rust 的 Tokio:遇到无法异步化的本地文件操作或计算密集任务时,需要显式用 tokio::task::spawn_blocking 把任务交给阻塞线程池。
Haskell 的多线程运行时:原理相同。对支持异步的操作(如网络)使用 epoll 复用;遇到必须阻塞的系统调用时,交给单独的操作系统线程,其他核心上的线程继续调度剩余的绿色线程。
forkIO 本身很轻量,但手动管理线程生命周期容易出问题:子线程的返回值不好拿到、未捕获的异常让线程静默退出、父线程退出时子线程泄漏。
通常的做法是使用 async 库提供的结构化并发(Structured Concurrency)接口。
import Control.Concurrent.Async
import Control.Concurrent (threadDelay)
fetchUserData :: IO String
fetchUserData = do
threadDelay 200000
return "用户档案数据"
fetchUserOrders :: IO [String]
fetchUserOrders = do
threadDelay 300000
return ["订单 #1001", "订单 #1002"]
fetchCompleteDashboard :: IO (String, [String])
fetchCompleteDashboard =
concurrently fetchUserData fetchUserOrders
concurrently 在两个绿色线程中并发运行任务,等双方都完成后把结果组成二元组返回。如果其中一个抛出异常,另一个会被自动取消,不会留下孤儿线程。
超时兜底之类的场景需要”谁先完成用谁”:
-- 哪个任务先完成就取哪个的结果,另一个被取消:
fetchWithTimeout :: IO (Either () String)
fetchWithTimeout =
race (threadDelay 500000) fetchUserData
若 500ms 计时先完成,返回 Left ()(代表超时);
若网络请求先完成,返回 Right "用户档案数据",计时器被取消。
前面的例子里线程之间不共享任何数据,concurrently 把结果收回来就完事了。一旦几个线程要读写同一个计数器、同一个句柄、同一个队列,就要选一种装共享状态的容器。base 和 stm 提供了三层,从简单到强大:
容器 |
适合的场景 |
核心操作 |
|---|---|---|
|
单个变量:计数器、缓存、开关。只做原子更新,不需要等待别的线程 |
|
|
一把锁或一个单槽信箱:保护一个句柄、两个线程之间交接一个值、等待完成信号 |
|
|
多个变量必须一起改,或者需要按条件等待:余额够了再扣款,队列非空再取 |
|
IORef 是最轻的一层,就是一个可变的引用单元。多线程同时修改时不能先 readIORef 再 writeIORef,两步之间可能被别的线程插进来。要用 atomicModifyIORef',它把“读、算、写”作为一个原子操作完成;末尾的撇号表示对新值严格求值,避免累积 Thunk:
module Main (main) where
import Control.Concurrent
import Control.Monad (forM_, replicateM_)
import Data.IORef
main :: IO ()
main = do
counter <- newIORef (0 :: Int)
done <- newEmptyMVar
forM_ [1 .. 10 :: Int] $ \_ -> forkIO $ do
replicateM_ 1000 (atomicModifyIORef' counter (\n -> (n + 1, ())))
putMVar done () -- 完成后往信箱里放一个信号
replicateM_ 10 (takeMVar done) -- 主线程收满 10 个信号再继续
readIORef counter >>= print -- 10000
MVar 是一个要么空要么满的格子。takeMVar 在格子空时阻塞,取走后格子变空;putMVar 在格子满时阻塞。上面的 done 就是把它当信箱用:子线程放,主线程取,取不到就等。另一种常见用法是当锁。newMVar () 创建一个装着 () 的格子,withMVar 取出、执行、放回,同一时刻只有一个线程能进入临界区:
lock <- newMVar ()
forM_ ["甲", "乙", "丙"] $ \name -> forkIO $
forM_ [1 .. 3 :: Int] $ \i ->
withMVar lock $ \_ -> putStrLn (name ++ " 第 " ++ show i ++ " 行")
没有锁时三个线程的输出会交错,有了锁每一行都是完整的。withMVar 内部用了 IO 一章的 bracket,临界区抛出异常时锁也会被放回。
TVar 与 STM 是第三层,下一节展开。它解决的是 MVar 做不好的两件事:同时修改多个变量而不暴露中间状态,以及“条件不满足就等,满足了自动醒来”。
怎么选:单个变量、只做原子更新,用 IORef;需要互斥或者两个线程交接,用 MVar;拿不准就用 TVar,它的能力覆盖前两者,代价只是每次访问多一点开销。线程本身的管理也有一条默认规则:上面两个例子用 forkIO 加 MVar 计数是为了展示原语,实际代码里除了“发出去就不管”的守护线程,一律优先用 async 的 concurrently、race 和 mapConcurrently,它们会自动等待子线程、传播异常、取消兄弟线程。
小技巧
如果你熟悉其他语言:IORef 加 atomicModifyIORef' 相当于 Java 的 AtomicReference 或 Go 的 atomic 包;MVar 当锁用时相当于 Mutex,当信箱用时相当于 Go 里容量为 1 的 channel 或 Java 的 SynchronousQueue;TVar 在主流语言里没有直接对应物,最接近的是数据库事务。
传统多线程编程用互斥锁(Mutex / Lock)保护共享可变内存。基于锁的模型有几个众所周知的问题:
死锁(Deadlock):两个线程以不同顺序申请锁 A 与锁 B,就可能互相等待;
锁不能组合:模块 X 与模块 Y 各自线程安全,把它们组合成一个复合操作(如从账户 A 转账到账户 B)时,通常要暴露内部锁细节或引入更粗粒度的全局锁;
条件变量难写对:wait() / notify() 容易出现虚假唤醒(Spurious Wakeup)与丢失唤醒。
软件事务内存(Software Transactional Memory,STM) 把数据库事务的思路引入内存并发。这个概念由 Shavit 和 Touitou 在 1995 年提出,Haskell 的实现(Harris 等人,2005)是其中应用最成功的之一,现在是 GHC 标准库的一部分。
在 Control.Concurrent.STM 中:
import Control.Concurrent.STM
-- 1. 事务变量:只能在 STM 单子内读写
type BankAccount = TVar Int
-- 2. 事务动作(注意:类型是 STM (),不是 IO)
transfer :: BankAccount -> BankAccount -> Int -> STM ()
transfer fromAcc toAcc amount = do
fromBal <- readTVar fromAcc
if fromBal < amount
then retry -- 余额不足:挂起当前线程,等相关 TVar 变化后重试
else do
writeTVar fromAcc (fromBal - amount)
toBal <- readTVar toAcc
writeTVar toAcc (toBal + amount)
-- 3. 在 IO 中原子提交事务
executeTransfer :: BankAccount -> BankAccount -> IO ()
executeTransfer a b = atomically (transfer a b 100)
atomically 执行一个事务的流程如下。事务中对 TVar 的读写先记录在线程本地的日志里,提交时才与共享内存比对:
flowchart TD
S["atomically 开始事务"] --> L["在本地日志中读写 TVar<br/>不改动共享内存"]
L --> R{"事务中调用了 retry?"}
R -- "是" --> W["丢弃日志,挂起线程<br/>等待读过的 TVar 被其他线程修改"]
W --> S
R -- "否" --> V{"提交校验:<br/>读过的 TVar 是否被别的线程改过?"}
V -- "没有冲突" --> C["把日志写入共享内存<br/>事务完成"]
V -- "有冲突" --> D["丢弃日志"]
D --> S
因为事务内只有对 TVar 的读写,没有其他副作用,所以丢弃日志重跑是安全的。
STM 的另一个组合子是 orElse:
-- 优先从主账户取款,若余额不足(触发 retry),改为从备用账户取款:
smartWithdraw :: BankAccount -> BankAccount -> Int -> BankAccount -> STM ()
smartWithdraw primary secondary amount target =
transfer primary target amount `orElse` transfer secondary target amount
基于锁的模型很难提供 orElse 这样的能力:第一步失败后,需要撤销已经获得的锁并且不留下任何副作用,再去尝试第二步。
小技巧
如果你熟悉其他语言:为什么 STM 在 Haskell 里比较成功:
命令式语言的尝试: STM 在 Java、C++ 等语言里都有过实现,但落地都遇到了困难。一个主要原因是这些语言无法阻止在事务内执行不可回滚的副作用:如果事务里调用了打印、网络请求,当检测到冲突需要回滚时,已经发出的数据包和已经打印的日志是收不回来的。Clojure 的 STM 是一个相对成功的例子,它依赖的正是不可变数据结构。
Haskell 的类型系统:
在 STM 单子内不能执行 IO 动作,编译器会拒绝这样的代码。事务里能做的只有读写 TVar,这些操作全部记录在事务日志里。因为没有不可逆的副作用,运行时可以安全地回滚并重试事务(乐观并发控制,Optimistic Concurrency Control)。
对比其他语言的并发状态模型:
Go:推荐通过 Channel 传递数据(CSP 模型),处理复杂共享状态时仍然需要 sync.Mutex,死锁和锁顺序由开发者自己保证。
Rust:通过所有权系统(Arc<Mutex<T>>)在编译期排除了数据竞争(Data Race),但运行期仍可能因为加锁顺序不当发生死锁。
Haskell STM:事务之间是可串行化(Serializable)的,没有死锁,支持条件等待(retry)与事务组合(orElse)。
GHC 的绿色线程初始栈约 1KB,用 forkIO 创建,单机可以开到百万级。
I/O Manager 基于 epoll / kqueue 实现非阻塞调度,代码保持同步风格。
涉及文件 I/O 或 FFI 等阻塞调用时,需要开启 -threaded,让多个操作系统线程参与调度。
并发任务用 Control.Concurrent.Async 的 concurrently 和 race,避免手动管理线程。
共享状态分三层:单个变量用 IORef 加 atomicModifyIORef',锁和信箱用 MVar,多变量和条件等待用 TVar;拿不准就用 TVar。
STM 用事务代替锁来处理共享状态,没有死锁,事务可以用 retry 与 orElse 组合。