第 16 篇的生产者可能停在满通道的发送处,消费者可能停在空通道的接收处,也可能已经开始统计。此时调用方不再想等,应该做什么?直接返回会留下自己启动的 goroutine;关闭 inputs 会让仍在发送的生产者 panic。context 给了“请停止”的信号,却不负责证明接收者已经收到并执行。这里要分别验证两件事:取消请求能否打断每种等待,以及返回前是否真的收到这些工作的退出确认。

本章继续扩展同一个累计工程,没有另起服务。queue.go 的单生产者、单消费者现在接收 context.Context;原统计链新增逐 rune 检查的可取消版本。CLI 增加 --timeout,从文本/JSON/批次输入准备完以后才开始给任务处理计时。本章完整累计包、SHA-256与解包说明可下载;实验的实际命令、双流及退出码见 writing-plans/go-language/evidence/17/。

取消能解开哪几处阻塞

发送端调用 sendTask(ctx, inputs, task),内部 select 等待输入通道可写或 ctx.Done();接收端 receiveTask(ctx, inputs) 等待新任务或取消。消费者把结果送入无缓冲的 outputs 时也需要相同的取消分支,否则调用方停止读取结果后,消费者仍可能卡在发送处。保持第 16 篇的所有权:生产者自己关闭 inputs,消费者自己关闭 outputs;调用方不通过关闭别人的通道实施取消。

1
2
3
4
5
6
7
8
9
取消请求:cancel() / 截止时间
│
ctx.Done()
↙ ↓ ↘
生产者发送 消费者收/发 分段统计
\ | /
各自完成、发退出确认
│
CLI 确认后返回 context 错误

TestCancellationUnblocksFullSendAndEmptyReceive 分别用容量为 1、已经装满的通道,和没有发送者的空通道调用实际生产/消费辅助函数。在发出取消请求之前,没有接收者能替满通道腾出位置,也没有生产者能替空通道提供值;测试不靠 Sleep 等时长判断是否“看起来还在等”。测试先观察两次操作没有提前返回,再 cancel(),分别等到执行函数的 goroutine 返回 context.Canceled。这同时证明“已发起取消请求”与“被阻塞调用已返回”是两处不同的检查点。Go 文档也明确 Done() 是工作应停止的信号,不能把调用 cancel 的返回当作整条调用链的退出回执。

select 并不提供一个“取消分支必然先于已经准备好的发送”的优先级。如果通道操作与 ctx.Done() 同时可进行,不能仅从请求发出就推断连最后一个任务都没有送出。当前消费者在收到任务后再检查 ctx.Err(),最终调用方在退出确认后又检查一次 ctx.Err();取消情况下不把部分结果作为成功返回。这里没有外部副作用,舍弃暂存结果是当前 CLI 的输出策略;未来已提交的数据库记录或已发送的外部动作不能靠丢弃内存切片回滚。

计算自己也得检查取消

若统计函数是一口气跑完且从不看 context,队列外层 select 再多也不能强迫它中断。stats.AnalyzeContext 沿着 UTF-8 码点扫描,在每个码点前检查 ctx.Err():字节数沿用原文本长度,码点数按实际解码次数增长,词数按 unicode.IsSpace 判断字段边界。测试将 ASCII、中文、制表符、结合符、Unicode 空格、非法 UTF-8 字节和空串的结果与原 stats.Analyze 对照;这些样本均返回相同计数,不能当作未经 fuzz 的所有输入等价证明。

另一个测试在第一个码点后用闸门停住扫描:调用 cancel() 后,由于检查点仍被闸门拦住,计算函数没有返回;放行后下一次检查才得到 context.Canceled。这证明取消延迟取决于工作代码实际执行到检查点。当前做法对每个码点增加一次检查,既没有测量性能代价,也没有承诺巨型 --text 的绝对退出时延。--file 与 --stdin 在建立本章超时 context 之前被读取;这条截止时间根本不限制读取阶段,不能把它叫整个 CLI 请求的统一超时。

退出确认在谁手里

processTasksContextUsing 沿用一个生产者和一个消费者,新增 producerDone、workerDone。生产者退出时关闭自己的输入及完成信号;消费者退出时关闭自己的输出及完成信号。调用者先排空 outputs,再等待两个完成信号,然后才读取 ctx.Err() 并返回。消费者调用的 analyzeTaskAsyncContext 仍在自身计算 goroutine 上关闭 done,等待者收到了它才返回。三处确认分别归相应的工作者负责,不是“发出了请求,大家应该都停了”。

最尖锐的反例是故意注入不响应 context的分析函数。TestCancelRequestWaitsForWorkerExit 让它已经进入统计、随后停在测试闸门处。此时调用 cancel();外层队列仍不能返回。测试日志先记录 cancel requested; worker blocked; caller still waiting,打开闸门以后才记录 worker confirmed exit before caller returned context.Canceled。如果将外层写成 cancel(); return ctx.Err(),这个测试会在工作者尚未释放闸门时报告违反契约。真实生产代码无法替任意不合作的外部调用保证有限时间退出;本章明确选择等待自己启动的工作,不用一个短超时伪造“已退出”。

正常路径并未为了取消改掉旧 CLI:构建后 --text 'Go 写 Go' 的 stdout 仍是 words=3 bytes=9 runes=7,--batch 'Go|one two' 仍按任务 ID 输出两条结果,两者 stderr 为空且退出 0。空批次项及缺失查询的旧错误路径分别保留各自 stderr、退出 2。两包 go test -count=1 -v ./... 与取消相关测试重复 50 次退出 0;重复次数不是无 race 的证明。本轮 CGO_ENABLED=0,并没有运行 race detector。

截止时间选用 CLI 的 --timeout:显式写 --text 'Go 写 Go' --timeout 0s 会在进入任务处理时得到已过期的 context。本次直接运行的二进制 stdout 空、stderr 为 task deadline exceeded,退出 2;同样的 --batch 'Go|one two' --timeout 0s 也得到相同约定错误且无半份输出。TestDeadlineReturnsClassifiedError 同时用 errors.Is(err, context.DeadlineExceeded) 判断内部错误,避免只比较打印文字。--timeout 1h 在本次短输入实测继续成功并退出 0,不代表任意慢任务在一小时内必成。未给出 --timeout 时使用不可取消的 context.Background(),此时 Done() 可为 nil;select 的对应分支不可用,不影响有数据的正常通道收发。

练习:某个 HTTP handler 将 r.Context() 传给后台任务,写完 HTTP 202 后结束,能否把“返回了 202”记成“任务已执行完”?答案:不能。HTTP 应答只是请求层的事实;若任务生命周期独立于请求,它需要自己的所有者、取消策略、结果存储和退出确认。这个 CLI 目前等待自己启动的任务才写结果,尚无后台任务,也没有 HTTP handler。把本章的 --timeout 日后直接绑定到 HTTP 请求还会引出“请求取消是否应该取消已接受后台任务”的决策;不能在出现真实请求前假设它们是同一个生命周期。

当前完成的是阻塞发送/接收、可合作分段计算与退出确认;没有 Mutex 保护多字段共享终态,也没有 worker 池和持久状态。数据库提交、外部副作用、HTTP 应答与业务完成的区别仍须到对应篇目以实测论证。全站构建、race、其他平台、SQL、HTTP、发布均 NOT_RUN。实际工具链是 Go 1.27.1;源码 tag go1.27.1、镜像 SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 沿用基线,官方 Gitiles 交叉验证仍 NOT_RUN。本章只援引公开 API 与本次观测,不把固定源码中的实现细节说成取消时限保证。下一篇要在有竞争的状态更新上建立 happens-before,并让反例与修复在相同访问路径下检查。

参考资料