第 5 / 23 篇

第4章 channel、锁与内存可见性

先选需要维护的不变量

channel 适合表达事件交接与有容量的等待区,锁适合保护一组需要共同成立的状态。两者都可以用于并发控制,但表达能力并不相同。工作池的任务传递使用 channel 很自然;“已经关闭且禁止新任务进入”的状态,需要与提交动作形成同一个临界区。坚持所有问题只用 channel,往往会把简单状态机改造成复杂消息协议。

Go 内存模型关注的是并发读写之间如何建立同步关系。一个 goroutine 写完字段,另一个 goroutine 在没有同步的情况下轮询,并不因为“通常先启动前者”就安全。调度顺序、睡眠时长和本机输出都不能建立 happens-before。通过锁、channel 或适当原子操作建立顺序,才让另一个执行者获得可依赖的可见性。

channel 发送可以作为交接点,但发送的对象之后是否继续变化仍然重要。若发送指针后继续改它的字段,接收者可能与发送者并发访问同一内存。把交接当成逻辑所有权转移,可以避免许多共享写;这不是 channel 自动禁止修改,而是程序设计上的纪律。

关闭只表达不会再有值

关闭 channel 表示发送方承诺不会再发送。通常由能够证明所有发送者结束的一方关闭。接收者提前关闭共享任务 channel,会让仍在提交的请求 panic。多个 worker 也不应各自关闭同一个结果 channel,除非另有一次性关闭与发送完成的协议。

从已关闭 channel 接收会立刻得到零值与 false。忘记检查 ok 的循环可能高速运行,不断处理假任务。nil channel 的发送和接收则永远阻塞,可以用于动态禁用 select 分支,但不应成为未初始化字段的隐蔽故障。理解这三种状态,比背诵“channel 是安全队列”更有帮助。

关闭操作也不是取消所有工作。队列关闭后,range 会继续取出已经缓冲的任务。若希望排空,这正合适;若希望立即停止,就需要额外服务 context,并决定剩余任务如何计数。只写一个 close(queue) 无法同时表达停止接纳、取消执行与丢弃排队任务。

锁保护的是关系

一把锁可以保护 closed 标记与一次非阻塞入队之间的关系。提交者持锁检查可接纳状态,再完成发送或返回满;关闭者持同一把锁修改状态并关闭队列。这样就不会发生检查时未关闭、发送时已关闭的窗口。锁的作用在这里不是防止 map 崩溃,而是把协议边界做成不可分割的动作。

临界区必须短。不要持有接纳锁等待外部网络,也不要在持锁期间等待整个工作池退出,否则 worker 可能需要同一把锁更新状态,形成死锁。原则是先在锁内改变和提取状态,再在锁外执行耗时清理。需要同时获取多把锁时,统一顺序,并考虑能否减少状态交叉。

RWMutex 也不天然优于 Mutex。读操作是否短、写频率如何、持锁区是否只做几个整数操作,都影响实际成本。教学代码应先选容易证明正确的方案,再在有证据时优化。为了“高并发”提前拆出多把锁,会增加一致性快照和关闭协议的复杂度。

原子计数与一致快照

原子变量适合独立计数或状态发布,但多个原子值的读取不是事务。读取 accepted 后,另一个任务可能完成,再读取 active 时看到不同时间的状态。这种快照用于趋势观察通常足够,用它判断是否可以关闭资源则不够。关闭判断应依据专门同步,而不是监控数字碰巧归零。

同样,先 Load 再 Store 不是原子增量。需要 Add、比较交换或锁来维护读改写。不要混用普通访问与原子访问同一个字段;race detector 会帮忙发现部分问题,但建立清晰访问规则比依赖检测更可靠。字段注释可以简短写明“由 mu 保护”或“仅通过 atomic 访问”。

race 通过并不意味着没有死锁、丢任务或关闭顺序错误。一个程序可以完全没有数据竞争,却因为等待无人发送的 channel 而永远挂住。因此本书同时使用 race 和行为测试:前者检查执行过的共享访问,后者检查协议的可完成性和边界结果。

练习:构造关闭与提交的竞争

同时运行多组提交者与关闭者,记录每次成功接纳和拒绝。验收应要求无 panic、无重复关闭、成功接纳的任务都有终结结果,关闭完成后不再接纳。将测试重复执行,而不要求成功接纳的总数每次相同,因为竞争点之前有多少提交成功本来就可能变化。

再故意把 closed 检查与发送之间的锁移开,用 race 或重复测试观察故障。可能出现的是 send on closed channel,它不一定以数据竞争报告出现。恢复锁之后,写下这把锁维护的完整句子。如果只能解释成“防并发”,说明你还没有抓住它保护的真正关系。

本章实测证据

关闭者与多组提交者同时执行。测试允许合法接纳数量变化,要求无发送到已关闭channel、无重复终结。 以下摘自最终本机 go test -race -v ./...,完整命令及环境见附录B;括号中的耗时仅是当次运行记录。

--- PASS: TestPoolConcurrentCloseAndSubmit (0.00s)