《Go 语言编程入门》12.3 优雅退出与信号处理

前两节让 context 能取消,但取消信号从哪来?本节把信号处理接上:用 signal.Notify 与 signal.NotifyContext 捕获 Ctrl-C 和 SIGTERM,讲清「中断」与「排空」两种停止语义,加二次信号强杀与超时兜底,最后给出 TaskAPI 完整的 main。

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本地开发、交互式运行
SIGTERMkill <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 函数有两个作用:

  1. 取消 ctx 并停止监听信号(defer stop() 用于清理);
  2. 恢复信号的默认行为——所以在收到第一次信号、开始排空之后调用 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 小结与练习

  1. SIGINT / SIGTERM 可以捕获,SIGKILL 不能。
  2. signal.NotifyContext 把信号直接变成 ctx,是推荐写法;返回的 stop() 调用后即可「二次信号强杀」。
  3. 「中断」与「排空」是两种语义:中断靠 ctx,排空靠关闭 jobs,不能混用。
  4. 优雅退出的每一步都要有超时兜底,否则会卡住。
  5. 清理阶段用全新的 context.Background() + 超时,不要复用已取消的 ctx。
  6. 不要用 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 与路由 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练