第 12 / 23 篇

第11章 优雅退出是一段有截止的协议

信号只是开始

收到 SIGTERM 并不表示进程应该立刻结束,也不表示所有 goroutine 会自动停止。服务必须协调接纳、HTTP 在途请求、后台任务、外呼连接和诊断端口。任何一个环节没有截止,都可能让部署长期卡住;任何一个环节过早关闭,都可能让已经承担的工作中途失去资源。

随书进程使用 signal.NotifyContext 接收退出信号,并把关闭步骤放在 main 控制流程。实际顺序是停止 HTTP 监听并等待现有处理函数,随后关闭任务接纳并排空工作池;共享预算不足时关闭 HTTP 连接、取消任务,再额外等待最多一秒合作清理,确认拥有的执行者结束,最后清理共享连接与管理端口。具体步骤可以按业务调整,但必须把依赖顺序解释清楚。

HTTP Shutdown 等待期间,已进入处理函数的请求仍可提交后台任务;本书选择让这些请求完成自己的接纳责任,再关闭工作池。若预算耗尽,两者仍可能存在并发窗口。工作池自己的关闭门闩保证这个请求要么成功接纳且被后续排空,要么得到关闭拒绝,不能发到已关闭 channel 上。不能仅依赖停止监听来保证业务接纳已经停止。

Server.Shutdown 不拥有所有资源

HTTP Server.Shutdown 会关闭监听器、处理空闲连接并等待活动连接变为空闲,但它不会自动等待业务自己启动的 goroutine。它也不直接管理被 hijack 的连接,例如某些 WebSocket。后台任务、独立管理服务器和业务连接仍需要各自的退出协议。

Shutdown 的 context 过期意味着等待预算结束,不意味着所有处理函数都被强制杀死。必要时可以调用 Close 终止连接,并取消服务拥有的工作 context;工作函数仍需合作返回。正常服务请求 context 不应简单绑定在 signal context 上,否则收到信号的一刻就取消所有在途工作,失去排空机会。

使用已经取消的信号 context 作为 Shutdown 参数是常见错误。它会让等待立即失败。应创建一份独立、有限的清理 context,例如从 Background 派生超时。独立并不意味着无限;它表示清理阶段有自己的预算,不被触发退出的那次取消提前清空。

排空和强制取消分开记录

工作池 Close 表示不再接纳;Shutdown 在预算内等待队列排空,预算到达后发出取消。Done 则表示 worker 真正结束。三个动作的区分让主进程不会把“已经通知停止”当成“已经停止”。如果工作不合作,必须明确返回失败、保留诊断并由进程边界结束,而不是声称已经优雅完成。

本书正常退出实验在真实子进程中保持一个一百五十毫秒请求和两个后台任务,发送 SIGTERM 后检查请求与任务全部完成再退出。超时实验给退出阶段八十毫秒预算,让五秒任务被取消,确认 active 归零后进程以非零状态退出。这两类退出需要不同结论:前者兑现排空,后者兑现有界取消并报告未正常完成。

非零退出不是证明代码坏了,而是让编排器和操作者知道本次不能宣称全部正常完成。对于已接纳但未持久化的任务,退出日志应提供必要数量与原因。若业务要求部署期间不丢任务,应该增加可靠任务存储或外部协调,而不是把退出预算无限调大。

总预算需要统一管理

如果 HTTP 等五秒、工作池等五秒、管理端口再等五秒,总退出可能达到十五秒。外部编排器却可能只给十秒。较稳妥的做法是创建共享绝对截止,各步骤消耗剩余预算;或者显式分配阶段预算并保证总和有余量。容器停止宽限期、负载均衡摘流延迟也需要计入部署设计。

Readiness 摘除与监听关闭不是同一事件。负载均衡可能仍发送旧连接请求,客户端也可能已有 keep-alive 连接。服务必须在自己的业务接纳处继续守住关闭状态,而不是假设外部已经完全没有流量。这个原则同样适用于消息消费者停止拉取之后仍有在途回调。

第二次信号如何处理也应决定。常见策略是第一次触发清理,再次信号允许立即终止,防止操作员无法结束卡住进程。signal.NotifyContext 返回的 stop 函数应在不再需要通知时调用。退出代码应尽量直接,避免深层 goroutine 随意 os.Exit 导致 defer 不执行。

练习:证明进程而不是函数退出

构建二进制,用测试启动子进程,通过就绪信息获得实际监听地址。发出任务与在途请求,等待它们真正开始,再发送 SIGTERM,最后 Wait 子进程。断言请求结果、任务统计、退出码和最长等待;不能只调用某个 Shutdown 函数就说已经验证信号退出。

再让任务超过清理预算,要求取消后资源最终归零,并确认退出码体现失败边界。测试自身必须有总截止和失败清理,以免坏实现遗留子进程。这个练习产生的证据,比一条“优雅退出支持”功能列表更值得信任。

本章实测证据

真实子进程同时运行五秒请求和任务,八十毫秒清理预算耗尽后,检查请求结束日志、任务active归零和非零退出码。 以下摘自最终本机 go test -race -v ./...,完整命令及环境见附录B;括号中的耗时仅是当次运行记录。

--- PASS: TestSIGTERMDeadlineCancelsBlockedTask (0.11s)