第 3 / 23 篇

第2章 context 是取消协议,不是终止开关

一条从调用者延伸到依赖的链

context 携带截止时间、取消信号和请求范围的信息。HTTP 服务的请求已经有 context,处理函数通常从 r.Context() 出发,把它传给下游函数和 http.NewRequestWithContext。这条链必须延伸到实际可能阻塞的位置。只在最外层创建超时,内层却使用 context.Background(),得到的只是一个会准时放弃等待的调用者。

取消并不执行 kill goroutine。调用 cancel 后,Done() 关闭,Err() 可以报告取消原因,真正的工作仍需观察信号并返回。CPU 循环、等待 channel、睡眠和网络调用分别需要自己的响应方式。不能因为函数签名接受了 ctx,就认为它已经实现了取消。

随书 /work 的延迟使用可取消等待:工作等待计时器或 ctx.Done(),哪个先到就结束。若改成无条件 time.Sleep,客户端超时之后 handler 的 goroutine 仍会继续存活,直到睡眠结束。这个端点不进入后台工作池,因此也不占用 Pool 的 active 名额。响应可能已经无法写给客户端,资源却继续活着。这正是取消测试必须检查工作退出、不能只检查客户端报错的原因。

预算不能在下游重新充满

父请求只剩两百毫秒,下游却新建三秒超时,父 context 仍会把有效截止限制在较早的时间。如果下游改用后台 context,这一限制就消失了。服务应把总预算拆成接纳等待、连接、响应头、读取响应体和结果处理,而不是每一层重新许诺完整超时时间。

WithTimeout 返回的 cancel 应当调用,即使计时器最终会自行触发。提前释放关联资源比等待超时更明确。常见写法是创建后立即 defer cancel();但在长期循环里,每次迭代都 defer 会延迟到整个函数返回。把单次操作抽成小函数,或者在该次操作结束时显式取消,才能让生命周期与作用域对齐。

截止时间是一个绝对时间点,超时是从当前计算的时长。日志中同时记录操作阶段和剩余预算,比只记录配置中的“三秒”更有用。收到 context deadline exceeded 时,原因可能是此前排队已经消耗预算,而非真正执行的那段代码运行了三秒。

等待也需要退出路径

任何可能一直等下去的发送或接收,都应考虑取消分支。典型形式如下:

select {
case result := <-results:
    return result, nil
case <-ctx.Done():
    return zero, ctx.Err()
}

这段代码保证等待者可以离开,但不保证生产者可以离开。生产者若随后向无缓冲结果 channel 发送,会因接收者已退出而阻塞。解决办法取决于所有权:生产者发送时也观察取消,或者为只发送一次的结果准备容量为一的 channel。缓冲区只是容纳这一条结果,不能替代生产者对其他资源的清理。

select 不为取消提供绝对优先级。当结果和取消都已就绪,执行哪个分支取决于调度。因此若业务规定取消后不能提交副作用,需要在提交边界重新校验状态,必要时用锁或事务把检查和动作组合起来。context 的传播解决通知问题,不自动解决提交仲裁。

原因、值和生命周期各司其职

context.Cause 与带原因取消可以帮助区分服务关闭、依赖失败和请求取消,但错误分类仍应保持稳定。不要把用户输入原样作为取消原因记录到日志,也不要依赖错误文本做状态机判断。HTTP 返回给客户端的说明可以简短,内部证据则包含阶段、原因类别与请求标识。

context 的 Value 适合请求范围、跨接口传播的小型元数据,例如已经验证过的追踪标识。它不是依赖注入容器,不应藏数据库连接、锁或一大块请求体。值可以被多个 goroutine 读取,值所指向的对象却不会自动变成并发安全。将可变 map 放进 context 并不能获得同步保障。

WithoutCancel 可以保留值而脱离父级取消,但它不会替你确定新任务的截止和关闭责任。后台任务如果使用它,必须再挂到服务拥有的 context,并明确完成与失败记录。随书工程通过任务数据复制和工作池 context 建立所有权转移,避免让脱离取消成为随手绕过超时的办法。

练习:观察取消后的第二个结果

启动服务后,每次只发一个请求,避免没有请求 ID 的演示日志互相混淆。例如运行 curl --max-time 0.05 'http://127.0.0.1:8080/work?delay=5s',先确认客户端超时退出,再检查对应的 request started 和 request finished 日志。正常实现应很快记录 request finished 且 error 为 context canceled,表示可取消等待已经结束。若故意把等待替换成 time.Sleep,客户端仍可能按同样预算返回,结束日志却要等五秒睡眠之后才出现。

不要用 /stats 的 active 判断这次请求是否释放:它只统计后台任务,对 /work 始终没有这项观察能力。若要严格确认整个 handler 已返回,参考 TestDrainRequestsWaitsForHandlerAndGatesAdmission:在 ServeHTTP 返回之后关闭测试专用的 finished 通知,再由测试等待它。日志定位阶段,通知证明返回,两者的证据范围不同。恢复实现后运行 race 与重复测试。

本次补丁在真实服务子进程中复核上述方法:五秒 /work 请求在客户端五十毫秒预算下返回 curl 退出码 28;随即观察到 request finished 的 context canceled,再发 SIGTERM,进程正常退出。这个结果没有使用 Pool 计数推断请求生命周期。

继续把取消放在工作刚开始、工作即将结束、结果已经生成三个位置。测试不应要求某一种调度顺序,而应要求不会永远阻塞、不会重复完成、资源最终释放。练习的产物是一张状态表:调用者观察到什么,执行者最终发生什么,两者在哪些竞争窗口可以不同。

本章实测证据

读取在响应头之后阻塞,取消必须使读取返回,并让下一次请求恢复。它验证的不是仅有调用者离开,而是实际网络资源释放。 以下摘自最终本机 go test -race -v ./...,完整命令及环境见附录B;括号中的耗时仅是当次运行记录。

--- PASS: TestFetcherBlockedBodyCancellationReleasesConnection (0.05s)