Go 信号处理入门:让 CLI 和服务收到 Ctrl+C 后体面退出

讲 Go 程序如何使用 signal.NotifyContext 处理 Ctrl+C、优雅停止后台任务、关闭 HTTP 服务和避免资源泄漏。

你在终端里按 Ctrl+C,程序会收到中断信号。如果程序只是立刻退出,可能会留下半写入文件、未完成请求、没有关闭的连接。对小脚本来说这不一定严重,但对长期运行的服务、消费者、同步工具来说,体面退出很重要。

Go 标准库提供了 os/signal。现在更推荐用 signal.NotifyContext,它能把系统信号转换成 context 取消。本文从一个 CLI 后台任务开始,再讲 HTTP 服务如何优雅关闭。

用 NotifyContext

基本写法:

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	if err := run(ctx); err != nil {
		log.Fatal(err)
	}
}

当用户按 Ctrl+C 或进程收到 SIGTERMctx.Done() 会关闭。run 函数只要监听这个 context,就能停止工作。

后台循环响应取消

一个定时同步任务:

func run(ctx context.Context) error {
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			if err := syncOnce(ctx); err != nil {
				log.Printf("sync failed: %v", err)
			}
		case <-ctx.Done():
			log.Println("shutting down")
			return nil
		}
	}
}

syncOnce 也要接收 context:

func syncOnce(ctx context.Context) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com/data", nil)
	if err != nil {
		return err
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	_, err = io.Copy(io.Discard, resp.Body)
	return err
}

如果只在外层监听 context,但内部 HTTP 请求不用它,按 Ctrl+C 后仍然可能卡在网络调用里。取消要沿调用链传下去。

HTTP 服务优雅关闭

HTTP 服务可以这样写:

func serve(ctx context.Context, srv *http.Server) error {
	errCh := make(chan error, 1)
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			errCh <- err
			return
		}
		errCh <- nil
	}()

	select {
	case err := <-errCh:
		return err
	case <-ctx.Done():
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		return srv.Shutdown(shutdownCtx)
	}
}

Shutdown 会停止接收新连接,并等待已有请求结束,直到超时。这里的 shutdownCtxcontext.Background() 派生,是因为原来的 ctx 已经取消了;如果直接把已取消的 ctx 传给 Shutdown,它会立刻返回。

第二次 Ctrl+C 怎么办

有时第一次 Ctrl+C 触发优雅退出,但程序还在等待请求结束。用户再按一次,希望强制退出。可以在 defer stop() 后调用 stop() 释放信号处理,让第二次信号恢复默认行为:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
	<-ctx.Done()
	stop()
}()

入门阶段不一定每个程序都需要这个处理,但长期运行服务可以考虑。关键是明确第一次信号做优雅退出,第二次信号允许强制结束。

清理资源

退出时常见清理:

  • 关闭数据库连接
  • flush 日志或队列
  • 停止后台 goroutine
  • 完成正在写入的文件
  • 向外部系统报告消费者下线

可以在 run 里集中管理:

func run(ctx context.Context) error {
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		return err
	}
	defer db.Close()

	return serve(ctx, &http.Server{
		Addr:    ":8080",
		Handler: routes(db),
	})
}

资源创建和关闭放在同一层,最容易看清生命周期。不要把 defer Close() 藏在很深的构造函数里,否则调用方不知道什么时候释放。

测试可取消函数

run 这类无限循环可以拆小测试。比如测试 worker 能响应取消:

func TestWorkerStopsOnCancel(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	done := make(chan struct{})
	go func() {
		_ = run(ctx)
		close(done)
	}()

	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("worker did not stop")
	}
}

测试信号本身通常没必要。你要测的是收到取消后业务循环能不能退出。这样测试更稳定,也更贴近代码责任。

区分正常结束和取消结束

命令行工具还要考虑退出码。用户按 Ctrl+C 取消任务,和程序因为参数错误失败,不一定是同一种结果。可以让 run 返回 context 错误,再在 main 里决定日志和退出码:

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	if err := run(ctx); err != nil {
		if errors.Is(err, context.Canceled) {
			log.Println("canceled")
			os.Exit(130)
		}
		log.Println(err)
		os.Exit(1)
	}
}

130 常用于表示被 Ctrl+C 中断。不是所有程序都必须严格使用这个约定,但区分“用户取消”和“程序失败”很有价值。自动化脚本、CI 任务和运维平台都会关心退出码。

服务程序也类似。收到 SIGTERM 后正常完成 Shutdown,日志应该是平静的关闭信息;如果关闭超时或资源释放失败,再记录错误。不要把正常发布时的停止写成一堆 ERROR,否则真正的问题会被噪音淹没。

小结

Go 程序可以用 signal.NotifyContextCtrl+CSIGTERM 转换为 context 取消。后台循环、HTTP 请求、数据库调用和外部调用都应该沿用这个 context。HTTP 服务退出时使用 Server.Shutdown,并给关闭过程单独设置超时。

优雅退出的目标不是拖慢关闭,而是在可控时间内把正在做的事收尾。能响应取消、能关闭资源、能在超时后放弃,程序就更适合长期运行。

继续阅读

探索更多技术文章

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

全部文章 返回首页