你在终端里按 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 或进程收到 SIGTERM,ctx.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 会停止接收新连接,并等待已有请求结束,直到超时。这里的 shutdownCtx 用 context.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.NotifyContext 把 Ctrl+C 和 SIGTERM 转换为 context 取消。后台循环、HTTP 请求、数据库调用和外部调用都应该沿用这个 context。HTTP 服务退出时使用 Server.Shutdown,并给关闭过程单独设置超时。
优雅退出的目标不是拖慢关闭,而是在可控时间内把正在做的事收尾。能响应取消、能关闭资源、能在超时后放弃,程序就更适合长期运行。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。