12.3 优雅退出与信号处理
12.1 和 12.2 讲的都是「取消信号在程序内部怎么传」。但第一个问题还没回答:最初那个取消信号,是从哪来的?
最常见的来源是操作系统信号。你在终端按 Ctrl-C,或者容器编排平台停掉一个 Pod 时发 SIGTERM,都会给进程发信号。默认行为是进程立刻死掉——正在写的数据库事务被切断、正在处理的请求被丢弃、缓冲区里的日志丢失。
「优雅退出」(graceful shutdown)就是:收到停止信号后,不再接新活,把手上的活干完,然后干净地退出。
本节把 TaskAPI 推进到:Ctrl-C 之后停止接收新任务、排空在途任务、带超时兜底,最后干净退出。
12.3.1 默认行为有多糟
写一个最简单的常驻程序:
func main() {
jobs := make(chan int64)
go func() {
for id := range jobs {
time.Sleep(100 * time.Millisecond)
fmt.Printf("任务 #%d 完成\n", id)
}
}()
for id := int64(1); ; id++ {
jobs <- id
}
}
按 Ctrl-C,进程立刻消失。那个正在 time.Sleep 的「任务」再也不会完成,也不会打印任何东西。如果那一步是「把任务标记为已完成」,数据库里就会留下一条状态不一致的记录。
更隐蔽的问题是退出码。默认被信号杀死时,进程的退出码是 128 + 信号号(SIGINT 是 130,SIGTERM 是 143)。编排系统看到这个退出码,通常认为「异常退出」,可能触发告警或重启策略。而优雅退出的进程应该返回 0。
12.3.2 两个必须处理的信号
| 信号 | 触发方式 | 场景 |
|---|---|---|
SIGINT | 终端 Ctrl-C | 本地开发、交互式运行 |
SIGTERM | kill <pid>、容器停止 | 生产环境的标准停止信号 |
Go 里对应 os.Interrupt 和 syscall.SIGTERM。注意 os.Interrupt 就是 SIGINT 的别名,用它是为了跨平台(Windows 上也有中断信号的概念)。
SIGKILL(kill -9)无法被捕获,任何程序都拦不住它。所以优雅退出只在 SIGINT / SIGTERM 下有效——这也是为什么生产环境停止服务时必须用 SIGTERM 而不是 -9。
12.3.3 手动版:signal.Notify
最直接的写法是让 signal.Notify 把信号送进一个 channel:
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)
sig := <-sigCh
fmt.Printf("收到信号 %v,开始退出\n", sig)
实测(kill -TERM):
收到信号 terminated,开始退出
实测(kill -INT):
收到信号 interrupt,开始退出
三个细节:
- channel 必须有缓冲,通常是 1。
signal.Notify内部是非阻塞发送的——如果接收方还没准备好,没有缓冲的信号会被直接丢弃。缓冲为 1 能保证至少收到一个。 signal.Stop(sigCh)要 defer,退出时恢复默认的信号行为。- 一旦
Notify生效,该信号的默认行为(终止进程)就被取消了。所以如果程序卡住不退出,你会「按了 Ctrl-C 但没反应」——这是设计使然,程序必须自己决定什么时候退。
手动版的问题是:信号 channel 和已有的 ctx.Done() 是两套东西,你需要在每个等待点上同时 select 两个 channel,代码会变得很啰嗦。
12.3.4 推荐版:signal.NotifyContext
signal.NotifyContext(Go 1.16+)把信号直接变成一个 context:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
它的语义是:收到任一信号时,返回的 ctx 被取消。之后所有代码只需要关心 ctx.Done(),不用再碰 os.Signal。
它返回的 stop 函数有两个作用:
- 取消 ctx 并停止监听信号(
defer stop()用于清理); - 恢复信号的默认行为——所以在收到第一次信号、开始排空之后调用
stop(),第二次按 Ctrl-C 就会走默认行为(立即终止进程)。这正是我们要的「二次信号强杀」。
这个「第一次优雅、第二次强杀」的交互几乎是所有成熟服务的标配,用 NotifyContext 可以免费拿到。
12.3.5 两种停止语义:中断 vs 排空
收到停止信号后,在途任务有两种处理方式,语义完全不同:
| 语义 | 做法 | 适用场景 |
|---|---|---|
| 中断 | worker 监听 ctx.Done(),立即放弃手上的任务 | 任务可重试、可丢弃(比如缓存刷新) |
| 排空 | worker 只监听 jobs 关闭,把手上的任务做完 | 任务不可丢(比如写订单、扣款) |
中断版的 worker 长这样:
for id := range jobs {
fmt.Printf("worker 开始处理任务 #%d\n", id)
select {
case <-time.After(300 * time.Millisecond):
fmt.Printf("worker 完成任务 #%d\n", id)
case <-ctx.Done():
fmt.Printf("worker 收到取消,任务 #%d 中断\n", id)
return
}
}
排空版的 worker 完全不看 ctx:
for id := range jobs {
time.Sleep(150 * time.Millisecond)
fmt.Printf("任务 #%d 已处理完毕\n", id)
}
排空版的关键在于:「停止」是通过关闭 jobs 实现的,而不是通过 ctx。生产者收到取消后停止投递并关闭队列,worker 把手上的任务做完、range 自然结束。
这两种语义不能混。如果选了排空,就不能让 worker 监听 ctx——否则 ctx.Done() 一关,worker 直接返回,手上的任务被丢了,排空就名存实亡。
TaskAPI 的「关闭到期任务」属于不可丢的那类(关闭动作会写库),所以下面用排空版。
12.3.6 完整流程
把上面所有部件串起来,一个完整的优雅退出流程是五步:
1. 捕获信号 → ctx 被取消
2. 生产者停止接收新任务,关闭 jobs
3. worker 把手上任务做完,range 结束
4. wg.Wait() 等到所有 worker 退出
5. 关闭资源(数据库、日志),返回 0
每一步都要有超时兜底,否则某一步卡住,整个进程就永远退不出——那时你只能 kill -9,前面所有的优雅都白费。
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
jobs := make(chan int64, 20)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Go(func() {
for id := range jobs {
time.Sleep(100 * time.Millisecond)
fmt.Printf("任务 #%d 完成\n", id)
}
})
}
feederDone := make(chan struct{})
go func() {
defer close(feederDone)
for id := int64(1); id <= 100; id++ {
select {
case jobs <- id:
case <-ctx.Done():
fmt.Println("停止接收新任务")
return
}
}
}()
<-ctx.Done()
fmt.Println("收到退出信号,开始排空……")
stop() // 恢复默认:再按一次 Ctrl-C 立即强杀
select {
case <-feederDone:
close(jobs)
case <-time.After(2 * time.Second):
fmt.Println("生产者退出超时,强制关闭队列")
close(jobs)
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
fmt.Println("全部排空完成,正常退出")
case <-time.After(3 * time.Second):
fmt.Println("排空超时,强制退出")
}
实测(运行 0.5 秒后发 SIGINT):
任务 #3 完成
任务 #1 完成
任务 #2 完成
...
任务 #12 完成
收到退出信号,开始排空……
停止接收新任务
任务 #15 完成
...
任务 #35 完成
全部排空完成,正常退出
输出里有两个关键信息:
停止接收新任务紧跟在取消信号之后出现:生产者收到取消后立刻停止投递,没有继续往队列里塞。注意它原本要投 100 个,本机实测在 0.5 秒时只投出了 30 多个(信号到来前已经有十来个任务跑完,所以这些任务 #N 完成会出现在收到退出信号之前)。- 最后一个任务完成后才打印
全部排空完成:说明wg.Wait()确实等到了所有 worker 退出,没有任务被半途丢弃。
另外注意 stop() 的位置:它在「开始排空」之后立刻调用。这样第一次 Ctrl-C 触发优雅退出,如果排空过程中再按一次 Ctrl-C,信号会走默认行为,进程立即终止——给运维留一个「实在等不及」的出口。
12.3.7 三个容易忽略的点
一、不要用 log.Fatal 退出。log.Fatal 内部调用 os.Exit(1),它会跳过所有 defer,包括 defer stop()、defer cancel()、defer db.Close()。退出路径上的清理逻辑必须用 return 走完,或者显式调用清理函数。
二、退出路径也要有 ctx 超时。第 5 步的清理动作(刷日志缓冲、等待连接池归还、上传指标)本身也可能卡住。常见做法是给清理阶段一个独立的、带超时的 context:
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := flushMetrics(cleanupCtx); err != nil {
log.Println("上报指标失败:", err)
}
注意这里用的是 context.Background() 而不是那个已经被取消的 ctx——因为我们要在「已取消」的状态下继续做完清理,用一个全新的 ctx 才能拿到完整的 5 秒预算。这正是 12.2.7 讲的 WithoutCancel 想表达的同一件事:清理阶段要脱离原取消链。
三、幂等的退出处理。信号可能重复到达,清理逻辑要能安全地执行多次。如果担心,用 sync.Once 包一层(11.2 节的内容)。
12.3.8 TaskAPI 的 main
把本节和前三节的东西组装成 TaskAPI 的入口:
func main() {
// 1. 信号 → ctx
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// 2. 装配
store := NewMemStore()
app := &App{store: store}
log.Println("TaskAPI 启动,等待信号……")
// 3. 后台扫描器(第 10 章)
scanDone := make(chan struct{})
go func() {
defer close(scanDone)
scanDueTasks(ctx, app)
}()
// 4. 等待停止信号
<-ctx.Done()
log.Println("收到停止信号,开始优雅退出……")
stop() // 二次信号强杀
// 5. 排空 + 超时兜底
drained := make(chan struct{})
go func() {
// 先等扫描器退出,再关资源
select {
case <-scanDone:
case <-time.After(2 * time.Second):
log.Println("扫描器退出超时")
}
close(drained)
}()
select {
case <-drained:
log.Println("排空完成,TaskAPI 正常退出")
case <-time.After(5 * time.Second):
log.Println("排空总超时,强制退出")
}
}
实测(运行 0.7 秒后 kill -TERM):
TaskAPI 启动,等待信号……
收到停止信号,开始优雅退出……
排空完成,TaskAPI 正常退出
退出码是 0,不是被信号杀死时的 143。
这个 main 的结构可以直接搬到任何常驻服务上,只需要替换「装配」和「排空」两段。它体现了一条原则:main 函数只做编排,不写业务逻辑——启动什么、等什么、怎么退,一目了然。
第 13 章接上 net/http 之后,第 4 步的「排空」会变成调用 server.Shutdown(ctx),其余结构完全不变。
12.3.9 小结与练习
SIGINT/SIGTERM可以捕获,SIGKILL不能。signal.NotifyContext把信号直接变成 ctx,是推荐写法;返回的stop()调用后即可「二次信号强杀」。- 「中断」与「排空」是两种语义:中断靠 ctx,排空靠关闭 jobs,不能混用。
- 优雅退出的每一步都要有超时兜底,否则会卡住。
- 清理阶段用全新的
context.Background()+ 超时,不要复用已取消的 ctx。 - 不要用
log.Fatal退出,它会跳过所有 defer。
练习:
- 把 12.3.6 的排空版改成中断版(worker 监听 ctx),发信号后对比「完成的任务数」差异。
- 去掉
time.After(3 * time.Second)那个兜底,把 worker 改成故意卡住(比如time.Sleep(time.Hour)),观察程序是否能退出。 - 把
defer stop()换成log.Fatal退出,验证清理逻辑确实被跳过了。 - 用
echo $?检查进程退出码:对比「优雅退出」(0)与被信号直接杀死(130 / 143)。
到这里,卷一的并发三章结束。你现在应该能做到:任何并发程序都能说清「谁启动、谁等待、谁取消、谁关闭」,并且能用 -race 验证它。更深的并发话题(errgroup、结构化并发、调度器内部原理)留给《Go 语言高级编程》卷。
下一章我们给 TaskAPI 接上 net/http,把这些并发组件真正暴露成一个 REST API。
阅读导航:上一节:12.2 超时、截止时间与 WithValue · 下一节:13.1 Handler、ServeMux 与路由 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。