部署 Go 服务时,经常能在退出日志中看到一句 server stopped: context canceled

它说明 Context 已经取消,却没有回答最关键的问题:程序究竟收到了用户按下 Ctrl+C 产生的 SIGINT,还是容器平台发来的 SIGTERM?前者可能是开发者主动结束程序,后者则可能来自滚动发布、Pod 驱逐或系统关机。

Go 1.26 改进了 signal.NotifyContext。由系统信号触发取消时,开发者可以通过 context.Cause 获取包含信号信息的原因,退出日志不必再只剩一句含糊的 context canceled

NotifyContext 以前缺少什么

signal.NotifyContext 负责把系统信号转换成 Context 取消事件。程序收到指定信号后,ctx.Done() 会关闭,HTTP 服务、后台任务和消息消费者便能沿用同一套 Context 机制执行关闭流程。

问题在于,传统代码通常只检查 ctx.Err()。无论由 SIGINT 还是 SIGTERM 触发,它返回的都是 context.Canceled

这是因为 ctx.Err() 描述的是 Context 状态:它只关心任务被取消还是超过截止时间,并不负责记录具体的取消来源。

这里需要区分版本:signal.NotifyContext 从 Go 1.16 就已存在,context.CauseWithCancelCause 则由 Go 1.20 引入。直到 Go 1.26,NotifyContext 才改用 WithCancelCause,并把收到的具体信号写入取消原因。

因此,在 Go 1.20 至 Go 1.25 中,即使调用 context.Cause(ctx),由信号触发时得到的仍是 context.Canceled。本文描述的信号原因能力要求 Go 1.26 或更高版本。

简单来说,ctx.Err() 回答“发生了什么状态”,context.Cause(ctx) 回答“为什么会发生”。

获取具体的信号原因

创建信号 Context 时,可以同时监听用户中断和终止信号:

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

等待 Context 结束后,分别输出状态与原因:

<-ctx.Done()

log.Printf("context error: %v", ctx.Err())
log.Printf("cancel cause: %v", context.Cause(ctx))

在类 Unix 系统中,按下 Ctrl+C 后通常可以看到:

context error: context canceled
cancel cause: interrupt signal received

收到 SIGTERM 时,Cause 通常为 terminated signal received。需要注意,错误文本适合日志和诊断,不应被当作稳定的业务协议。

不同取消来源的结果

NotifyContext 返回的 Context 不只会被系统信号取消。父 Context 主动取消、超过截止时间或调用 stop(),同样会关闭它的 Done 通道。

几种常见情况的结果如下:

取消来源 ctx.Err() context.Cause(ctx)
收到 SIGINT context canceled interrupt signal received
主动调用 stop() context canceled context canceled
父 Context 主动取消 context canceled 继承父级 Cause
父 Context 超时 context deadline exceeded context deadline exceeded 或父级自定义 Cause

如果父 Context 使用 WithCancelCause 携带原因,子 Context 会继承它。Context 遵循“第一次取消决定原因”的规则:父级取消、主动停止和系统信号中,谁先发生,谁就决定最终的 Cause。

优雅退出与第二次信号

在服务程序中,可以把信号 Context 传给各个后台任务。收到信号后,先记录原因,再开始关闭服务:

<-ctx.Done()
cause := context.Cause(ctx)
log.Printf("shutdown requested: %v", cause)

stop()
shutdownServer()

NotifyContext 捕获信号后不会自动注销监听。如果关闭过程很慢,后续同类信号仍可能被程序接管。

在开始优雅关闭前调用 stop(),会注销当前 Context 的信号监听。如果程序没有为该信号注册其他监听或忽略规则,系统默认行为可能随之恢复。这样第一次信号用于优雅退出,后续信号才有机会直接终止程序。

stop 可以被重复调用,因此同时使用 defer stop() 和主动调用是安全的。前者确保资源最终释放,后者负责及时恢复信号行为。

何时仍然使用 signal.Notify

context.Cause 返回的是 error,不是 os.Signal。Go 1.26 提供了可读的信号描述,但没有公开可以转换回 syscall.Signal 的错误类型。

如果程序需要根据不同信号执行不同动作,例如使用 SIGHUP 重载配置、使用 SIGTERM 关闭服务,就应该使用 signal.Notify

ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGHUP, syscall.SIGTERM)
defer signal.Stop(ch)
sig := <-ch
switch sig {
case syscall.SIGHUP:
    reload()
case syscall.SIGTERM:
    shutdown()
}

NotifyContext 适合把“应该停止工作”广播给调用链,signal.Notify 则适合识别和分派具体信号。

跨平台使用需要留意

os.Interrupt 通常由 Ctrl+C 触发,是更通用的选择。SIGHUP 等信号主要属于类 Unix 系统,平台专用信号可以放进带构建标签的文件。

Windows 的关闭、注销和关机事件可能以 SIGTERM 通知程序,但进程仍会被系统终止,这个通知只提供一次尽快清理的机会。

在类 Unix 系统中,SIGKILLSIGSTOP 无法被程序捕获、忽略或转换成 Context 取消事件。

此外,stop() 还会释放 NotifyContext 注册的信号资源。即使没有收到信号,也应该在不再需要监听时调用它。

写在最后

Go 1.26 让 signal.NotifyContext 的退出原因变得更清晰:ctx.Err() 继续表达取消状态,context.Cause(ctx) 则补充导致取消的具体信号。

只需要统一停止调用链时,选择 NotifyContext;需要按信号执行不同逻辑时,选择 signal.Notify。理解两者的边界,再及时调用 stop(),才能同时获得清晰的退出日志、可靠的资源释放和符合预期的信号处理。