15.8 并发编程模型 (线程 / 协程 / Actor / CSP)

一句话

四种模型回答的是同一个问题——“状态由谁串行化”:线程模型靠锁让共享内存安全,CSP 靠通道传递所有权,Actor 靠 mailbox 后面的单线程消费,数据流靠不可变数据与算子组合。

四种模型对比

模型 状态在哪 串行化方式 典型失败 代表
线程 + 共享内存 共享堆 锁/原子操作 死锁、竞态、锁粒度过粗 Java、C++
CSP 消息里 通道阻塞/交接 死锁(互等 channel)、泄漏 Go、Occam
Actor Actor 内部 每 Actor 单线程处理 mailbox 邮箱爆炸、背压缺失 Erlang、Akka
数据流 不可变数据 算子拓扑 反压与状态膨胀 Flink、RxJava

Go 的取向是 “CSP 为主,sync 为辅”:go 关键字起协程,channel 传数据;共享状态仍可用 Mutex,但官方建议是 “不要通过共享内存通信,而要通过通信共享内存”。

CSP 的五个编排模式

多 channel 的编排不需要发明新原语,稳定下来的是五种。它们的共同点:每个模式都同时定义了“怎么开始”和“怎么退出”——只写开始的版本,最后都会泄漏。

模式 解决什么 关键手法 失败方式
Or-Done 多个下游任一完成就收工(向 N 个副本发同一请求,取最快) N 个 <-chan struct{} 合成一个 done;>2 个用二分或 reflect.Select 只退出了聚合协程,其余仍在阻塞 → 泄漏
扇入 Fan-In 多源 → 一个目的,接收方只监听一个 channel 合并时把已关闭的输入置 nil(nil channel 永久阻塞,正好禁用该分支) 忘记置 nil,循环读到零值 → CPU 打满
扇出 Fan-Out 一源 → 多目的(观察者、广播) 每个消费者一个 channel;慢消费者要么异步、要么丢弃 同步发送被最慢的消费者拖住整条流
Stream 把 channel 当流做 take / skip / filter 每个算子都接一个 done,用 select 响应取消 算子不接 done → 无法中断
Map-Reduce 单机并行处理再归约 map 阶段并行、reduce 阶段单点合并 reduce 成串行瓶颈,或合并顺序影响结果

两个实现纪律:

  • channel 数量在编译期未知(比如 N 个下游)时用 reflect.Select,但它比静态 select 慢一个量级,只放在编排层、不要放进热路径;
  • select 的 nil 分支是特性不是坑:把已完成的输入设成 nil channel,它自然被跳过——扇入的标准写法。

先把这些原语捡现成的

“一组任务怎么办”不该自己写。按需求对照:

需求 用什么 关键约束
一组任务 + 错误传播 golang.org/x/sync/errgroup 只返回第一个 error;SetLimit(n) 限并发(超限时 Go 阻塞);传入 ctx 被取消不会杀子任务,要靠子任务自己监听
并发度上限(带权重) golang.org/x/sync/semaphore 的 Weighted 申请/释放必须成对,释放量大于持有量直接 panic;申请量超过 size 只能靠 ctx 逃生
同 key 并发请求合并 golang.org/x/sync/singleflight 只合并在飞请求,不是缓存(返回后即删 key);fn 必须幂等,且不能返回可被多方修改的共享引用
固定参与者的循环栅栏 github.com/marusama/cyclicbarrier 实际 Await 的数量必须等于 parties,少一个永久阻塞、多一个提前放行

自己实现这些的代价不在代码量,而在边界条件:错误只留第一个还是全收、限流超限时阻塞还是拒绝、合并请求时 panic 怎么传播——每一个都要在文档里说清,否则使用者只能靠读源码。

背压:所有模型的共同难题

做法 表现 什么时候错
无界队列 生产者永不阻塞 内存被打爆,延迟无界
有界阻塞 生产者在满时阻塞 需要保证不死锁(下游必须继续消费)
丢弃/降级 满时返回错误或丢最旧 只适合可容忍丢失的场景
限流(令牌桶) 入口就控制速率 需要容量估算(见 35.7 限流)

Actor 与 CSP 都容易 “看起来解耦了,其实把压力堆在 mailbox/channel 里”——背压必须显式设计,它不会自动出现。

选型表

你的情况 建议
IO 密集、高并发、团队熟悉 Go CSP + goroutine(配 context 取消)
大量共享状态、读多写少 共享内存 + RWMutex / 原子操作
需要 “每实体独立状态 + 顺序处理”(如每个连接/每个账户) Actor 风格:单 goroutine 持有状态,channel 收消息
流式数据处理、需要容错与状态 数据流模型(见 62.1 流处理模型与时间语义)
CPU 密集计算 线程/协程池 + 分片,避免细粒度同步

落到数字:模型的失败模式

模型 扩展方式 典型失败 观测手段
线程 + 锁 加线程 死锁、锁竞争 阻塞/互斥 profile
CSP 加 goroutine + channel 互等 channel、泄漏 goroutine profile
Actor 加 Actor 邮箱爆炸、无背压 邮箱长度、处理延迟
数据流 加并行度 反压失控、状态膨胀 算子背压指标、checkpoint 时长
go tool pprof -top -sample_index=block http://localhost:6060/debug/pprof/block   # 阻塞画像
go tool pprof -top -sample_index=mutex http://localhost:6060/debug/pprof/mutex   # 锁竞争

反模式

反模式 后果
既有锁又有 channel,两套并用 死锁原因复杂化,review 成本高
Actor 邮箱无界 内存膨胀,故障时雪崩
用 “每个请求一个 goroutine” 代替背压 高峰期 goroutine 爆炸
数据流里做阻塞 IO 拓扑被拖住,反压失控
认为 “用了 CSP 就没有竞态” 共享变量仍可能被多处读写
扇入时没把已关闭的输入置 nil 循环读到零值,CPU 打满
把 reflect.Select 放进热路径 比静态 select 慢一个量级、分配更多
把 singleflight 当缓存 请求结束即删 key,没有缓存效果,只省并发

三条铁律

🔑 每个模型都要回答 “状态谁串行化”;答不出来就会同时踩两种坑。

🔑 背压必须显式设计:有界队列 + 明确拒绝策略,不要指望 “不会满”。

🔑 同一模块只用一种主模型;混用会让问题从 “性能” 变成 “不可推理”。

自检

Q1 “不要通过共享内存通信,而要通过通信共享内存” 具体指什么?

▸ 答案:指用 channel 把数据的独占权交给一个 goroutine,而不是多个 goroutine 同时读写同一变量再靠锁保护。前者把 “并发问题” 变成 “所有权问题”(一段时间只有一个持有者),后者要证明每个访问路径都加对了锁——前者更容易在 code review 中被验证。

Q2 Actor 模型为什么天然适合 “每实体状态独立” 的场景?

▸ 答案:因为每个 Actor 内部是单线程处理消息,状态不需要加锁;并发发生在 Actor 之间,通过消息解耦。所以 “每个连接/每个账户/每个房间” 一个 Actor 时,既保证顺序又避免锁。代价是 mailbox 容量与调度开销。

Q3 CSP 里最典型的死锁是什么样?

▸ 答案:两个 goroutine 互相等待对方的 channel(A 等 B 发、B 等 A 发),且都没有缓冲或 select 兜底,形成环形等待。防护:给 channel 加缓冲、用 select + default/超时、或规定 “单向通道” 避免循环依赖。

Q4 为什么 “无界队列” 在并发模型里等于隐藏故障?

▸ 答案:因为它把 “下游变慢” 转成 “上游内存增长”,故障从 “延迟上升” 变成 “OOM”。而且队列越长,排队延迟越大,等它爆掉时已经积压了几十分钟的数据。有界 + 拒绝策略 + 监控队列深度,才是可运维的设计。

Q5 Go 里什么时候该主动用 Mutex 而不是 channel?

▸ 答案:保护简单共享状态时(计数器、缓存 map、配置结构):锁的语义直观、开销小。channel 更适合 “交接所有权/事件通知”。判据是 “这是状态还是消息”:状态用锁/原子,消息用 channel;把状态当消息传,会让代码变成复杂的消息协议。

Q6 为什么 “用了 Actor 就不用担心并发” 是误解?

▸ 答案:因为 Actor 只保证单个 Actor 内部串行;跨 Actor 的消息顺序、丢失、重复与背压仍需设计。邮箱无界时,压力会以 “内存增长 + 处理延迟” 的形式累积,最后同样雪崩。

Q7 扇入时把已关闭的输入 channel 置成 nil,为什么是标准写法而不是 hack?

▸ 答案:因为 nil channel 的收发永久阻塞,在 select 里等于「这个分支永不就绪」——正好等价于「把它从 select 集合里删掉」,而且不用重建 case 列表。不置 nil 的话,已关闭的 channel 立刻可读并返回零值,循环会空转(CPU 打满)或把零值当数据。同一个性质反过来用,就是「禁用某个分支」的开关。

关联