服务端代码里最常见的一句日志大概是 context canceled。它出现在 gRPC handler、HTTP middleware、后台任务、数据库调用的错误分支上,几乎没有任何信息量:是客户端主动断开,是超时兜底,是服务优雅退出,还是上游逻辑判定参数非法主动放弃?这四种情况的处置方式完全不同,但日志里长得一模一样。
Go 1.20 给 context 包补了一个口子:context.WithCancelCause 与 context.Cause。它允许取消方在取消时附带一个 error 作为原因,消费方通过 context.Cause(ctx) 取回该原因,从而在日志里把「谁取消了、为什么取消」区分开。本文用一段可运行的示例把这件事讲透,包括取消原因的传播规则、日志埋点应该写在哪一层、与 context.WithTimeout 的配合方式,以及三个真实踩过的坑。
为什么需要取消原因:从一条无法定位的日志说起
先看一段没有使用取消原因的典型代码。一个 HTTP 服务收到请求后起一个 goroutine 做下游调用,客户端断开时通过 r.Context() 感知:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := doWork(ctx); err != nil {
log.Printf("doWork failed: %v", err)
return
}
}
func doWork(ctx context.Context) error {
select {
case <-time.After(3 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
客户端断开时,ctx.Err() 返回 context.Canceled,日志里就是 doWork failed: context canceled。如果同一份代码在优雅退出时也走 ctx.Done() 分支,日志完全一样。运维看到这个错误,无法判断是要排查客户端网络,还是要排查发布流程。
另一种更隐蔽的情况:业务层主动放弃。
func (s *Service) Fetch(ctx context.Context, id string) (*Item, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
if !s.validate(id) {
cancel() // 这里取消了,但没有告诉下游为什么
}
}()
return s.downstream.Get(ctx, id)
}
下游拿到 context.Canceled,只能记一句「调用被取消」。是参数校验失败导致的主动取消,还是父 context 被取消?信息在 cancel() 这一行就丢失了。WithCancelCause 解决的正是这个信息丢失问题:把「取消」和「原因」绑定在一起传递。
API 速览:WithCancelCause、Cause 与 CancelCauseFunc
Go 1.20 起 context 包新增以下接口,其余 API 不变:
| 符号 | 类型 | 说明 |
|---|---|---|
context.WithCancelCause |
func(Context) (Context, CancelCauseFunc) | 返回可取消的 ctx 与带原因取消函数 |
context.CancelCauseFunc |
type func(cause error) | 首次调用时设置 cause;后续调用被忽略 |
context.Cause |
func(Context) error | 取该 ctx 或其最近祖先被取消时的原因 |
context.WithCancel |
func(Context) (Context, CancelFunc) | 仍是 WithCancelCause 的特例,cause 为 context.Canceled |
context.WithTimeout |
func(Context, time.Duration) (Context, CancelFunc) | 超时后 cause 为 context.DeadlineExceeded |
几个语义细节需要先记住,后面踩坑都跟它们有关:
- 只有第一次取消生效。
CancelCauseFunc与CancelFunc一样,多次调用不会覆盖原因。所以父 context 先被取消后,子 context 再调用带原因的 cancel 是无效的。 - 取消会向上游「继承」原因。若父 ctx 先被取消,
context.Cause(childCtx)返回的是父 ctx 的 cause,而不是子 ctx 自己的。 - 传入 nil 会被替换。调用
cancelCause(nil)等价于cancel(),Cause返回context.Canceled。想传「无原因」应该显式构造一个 error。 ctx.Err()的返回值不变。它依旧是context.Canceled或context.DeadlineExceeded。原因只能通过context.Cause拿。这保证了老代码不受影响,但也意味着你需要主动改写日志埋点。
常见误解:以为
context.Cause能替代ctx.Err()做错误判断。它不能。判断「是否已取消」仍用ctx.Err()或errors.Is(err, context.Canceled);Cause只用于附加信息与日志。
端到端示例:一个可运行的取消原因演示程序
下面这段程序可直接 go run(Go 1.20+),演示三种取消路径以及日志输出的差异。
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
)
// 业务自定义哨兵错误,用 errors.Is 判断
var (
ErrInvalidParam = errors.New("invalid param")
ErrQuotaExceeded = errors.New("quota exceeded")
)
// downstream 模拟下游 RPC:只观察 ctx.Done 并返回 ctx.Err()
func downstream(ctx context.Context) error {
select {
case <-time.After(200 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// fetch 是业务逻辑:按 id 校验与配额检查,失败时带原因取消
func fetch(parent context.Context, id string, overQuota bool) error {
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil) // 正常返回时取消,避免 goroutine 泄漏;cause 为 nil 时记为 Canceled
go func() {
if id == "" {
cancel(fmt.Errorf("fetch id empty: %w", ErrInvalidParam))
return
}
if overQuota {
cancel(fmt.Errorf("user quota exceeded: %w", ErrQuotaExceeded))
}
}()
if err := downstream(ctx); err != nil {
// 关键:日志里同时记录 Err 与 Cause
cause := context.Cause(ctx)
if !errors.Is(cause, ErrInvalidParam) && !errors.Is(cause, ErrQuotaExceeded) {
// 不是业务主动取消,交回上层处理
return err
}
return fmt.Errorf("fetch aborted: err=%v cause=%v", err, cause)
}
return nil
}
func main() {
log.SetFlags(log.Ltime | log.Lmicroseconds)
// case 1: 参数非法,业务主动取消
err := fetch(context.Background(), "", false)
fmt.Printf("[case1 business cancel] err=%v\n\n", err)
// case 2: 父 ctx 超时,业务被动取消
parent, stop := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer stop()
err = fetch(parent, "item-42", false)
fmt.Printf("[case2 parent timeout] err=%v cause=%v\n\n", err, context.Cause(parent))
// case 3: 父 ctx 手动取消,子 ctx 的 cancel 原因会被父覆盖
parent2, cancel2 := context.WithCancelCause(context.Background())
go func() {
time.Sleep(10 * time.Millisecond)
cancel2(errors.New("shutdown signal received"))
}()
err = fetch(parent2, "item-42", true)
fmt.Printf("[case3 shutdown] err=%v cause=%v\n", err, context.Cause(parent2))
}
执行后输出(时间戳每次不同,错误文本一致):
13:41:02.101823 [case1 business cancel] err=fetch aborted: err=context canceled cause=fetch id empty: invalid param
13:41:02.151903 [case2 parent timeout] err=fetch aborted: err=context canceled cause=context deadline exceeded
13:41:02.162004 [case3 shutdown] err=fetch aborted: err=context canceled cause=shutdown signal received
三条日志的 err 字段一模一样,都是 context canceled,但 cause 字段给出了完全不同的处置线索:参数问题、超时、进程退出。这就是 WithCancelCause 的全部价值所在——它不改变错误判断逻辑,只补足了「为什么」。
注意 case2:父 ctx 由 context.WithTimeout 创建,其 cause 是 context.DeadlineExceeded。子 ctx 用 WithCancelCause 派生,当父先超时,context.Cause(childCtx) 会继承这个原因。无需在子 ctx 中额外处理超时。
日志埋点:写在哪一层、用什么字段、怎么分级
取消原因只在「取消发生的边界」有意义。不要在每个函数入口都打 context.Cause,那样日志会重复且噪声极大。推荐三个埋点位置:
1. 取消发起方:记录谁取消、为什么取消
调用 cancel(cause) 的地方通常知道语义,把 cause 构造得足够可检索:
cancel(fmt.Errorf("handler: client disconnected, remote=%s", r.RemoteAddr))
cancel(fmt.Errorf("shutdown: signal=%v grace=5s", sig))
cancel(fmt.Errorf("validate: field=%s reason=%w", "user_id", ErrInvalidParam))
原因文本里带上可枚举的键值对(namespace:key=value 风格),方便日志系统做字段提取。不要只写 errors.New("canceled"),那和没有原因没区别。
2. 取消消费方:统一记录 err + cause
消费方在返回错误前,把 ctx.Err() 与 context.Cause(ctx) 一起落到结构化日志:
func logCancel(ctx context.Context, op string) {
cause := context.Cause(ctx)
if cause == nil {
return
}
level := "warn"
// 优雅退出、客户端主动断开属于预期内,降级为 info
if errors.Is(cause, context.Canceled) || isShutdownCause(cause) {
level = "info"
}
log.Printf("level=%s op=%s err=%v cause=%q", level, op, ctx.Err(), cause.Error())
}
func isShutdownCause(err error) bool {
var sc *ShutdownCause
return errors.As(err, &sc)
}
这里用 errors.As 判断自定义原因类型,比字符串匹配可靠。建议为「预期内的取消」定义类型,比如:
type ShutdownCause struct {
Signal string
Grace time.Duration
}
func (c *ShutdownCause) Error() string {
return fmt.Sprintf("server shutdown: signal=%s grace=%s", c.Signal, c.Grace)
}
// 发起方
cancel(&ShutdownCause{Signal: "SIGTERM", Grace: 5 * time.Second})
// 消费方
var sc *ShutdownCause
if errors.As(context.Cause(ctx), &sc) {
// 走 info 级别,且不触发告警
}
用类型而非字符串,还有一个好处:日志里可以单独打结构化字段 signal、grace,而不是把整段文本塞进 message。
3. 入口中间件:兜底记录未被处理的取消
HTTP/gRPC 入口处加一层中间件,保证任何没被业务层记录的取消都能留痕:
func WithCancelLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
if err := context.Cause(r.Context()); err != nil {
log.Printf("request canceled path=%s dur=%s cause=%q",
r.URL.Path, time.Since(start), err.Error())
}
})
}
这个中间件在 handler 返回后检查 Cause:若请求正常完成,ctx 未被取消,Cause 为 nil,不产生日志;若期间被取消,无论取消发生在哪一层,都能记到入口路径与耗时。注意 r.Context() 在 handler 返回后仍然可读,取 Cause 是安全的。
与 WithTimeout、WithCancel、errgroup 的协作规则
实际项目里 WithCancelCause 很少单独使用,它总是和超时控制、并发控制混在一起。理清继承顺序很重要。
规则一:父子链上只有「最早的那个取消」有效
p, cancelP := context.WithCancelCause(context.Background())
c, cancelC := context.WithCancelCause(p)
cancelP(errors.New("parent canceled first"))
cancelC(errors.New("child canceled second")) // 无效,无日志、无报错
fmt.Println(context.Cause(c)) // parent canceled first
所以不要指望在 defer cancel(nil) 里能「补上」原因。原因必须在第一次取消时给出。
规则二:WithTimeout 的 cause 固定为 DeadlineExceeded
context.WithTimeout 内部用的是普通取消路径,其 cause 恒为 context.DeadlineExceeded。如果你希望超时带上业务上下文(例如「调用支付网关超时」),需要自己在超时后判断并补充日志,而不是指望 cause 里出现业务文案:
ctx, cancel := context.WithTimeout(parent, 1500*time.Millisecond)
defer cancel()
err := callGateway(ctx)
if errors.Is(err, context.DeadlineExceeded) {
log.Printf("gateway timeout: op=pay order_id=%s budget=1500ms cause=%q",
orderID, context.Cause(ctx).Error())
}
Go 1.21 之后 context.Cause(ctx) 对这种场景返回的就是 context.DeadlineExceeded,日志里 cause="context deadline exceeded" 属于预期输出。
规则三:errgroup 默认丢弃原因,需手动接管
golang.org/x/sync/errgroup 的 Group 内部调用 WithCancel,取消原因是固定的 context.Canceled。若想拿到具体原因,需要自己在任务里记录首个错误,或使用 WithContext 之外的方式。常见做法是维护一个受 mutex 保护的 firstErr,并在 Wait 之后打日志:
var (
mu sync.Mutex
firstErr error
)
g, ctx := errgroup.WithContext(ctx)
for _, job := range jobs {
job := job
g.Go(func() error {
if err := run(ctx, job); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("job=%s: %w", job.Name, err)
}
mu.Unlock()
return err
}
return nil
})
}
if err := g.Wait(); err != nil {
log.Printf("group aborted: err=%v ctxerr=%v first=%v",
err, ctx.Err(), firstErr)
}
这里 ctx.Err() 依然是 context.Canceled,真正有信息量的是你自己捕获的 firstErr。如果确实需要 WithCancelCause 的语义,就不要用 errgroup 的 ctx,而是自行用 sync.WaitGroup + WithCancelCause 组合。
三个真实踩过的坑
坑一:defer cancel(nil) 把原因覆盖成 Canceled
最常见的写法缺陷。函数里先 defer cancel(),中途某处 cancel(errors.New("...")),看着没问题。但如果顺序写反:
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil) // 若此处先执行(如提前 return),原因被定为 Canceled
...
cancel(myErr) // 若 defer 先跑,这句无效
实际上 defer 仅在函数返回时执行,影响的是函数返回之后才读取 Cause 的调用方。如果调用方在函数返回后打印 context.Cause(ctx),看到的可能不是你以为的业务原因。若函数内部已显式 cancel 过,后续 defer cancel(nil) 不会覆盖原因(首次生效原则),但若函数走的是正常返回路径而外部仍持有 ctx,你就会看到 context.Canceled。建议:只在确实需要释放资源时 defer,并在注释里写明「正常路径 cause 为 Canceled」。
坑二:用 ctx.Err() 判断原因,永远拿不到业务错误
if err := ctx.Err(); err != nil {
if errors.Is(err, ErrInvalidParam) { // 永远为 false
...
}
}
编译能过,运行不报错,逻辑静默失效。正确写法是判断取消状态用 ctx.Err(),取原因用 context.Cause(ctx):
if err := ctx.Err(); err != nil {
cause := context.Cause(ctx)
if errors.Is(cause, ErrInvalidParam) {
// 业务主动取消
}
}
如果在一段代码上同时看到 ctx.Err() 和 errors.Is(err, 自定义错误),基本可以判定是坑二。
坑三:旧版本 Go 编译报 undefined: context.WithCancelCause
报错原文:
./main.go:12:15: undefined: context.WithCancelCause
./main.go:20:9: undefined: context.Cause
WithCancelCause、CancelCauseFunc、Cause 是 Go 1.20 引入的。若 go version 显示 1.19 或更早,编译直接失败。处理方式:升级工具链,并在 go.mod 中声明 go 1.20 或更高。如果 CI 用固定镜像构建,改完 go.mod 还需同步更新构建镜像,否则本地能过、CI 挂掉。另外,若项目依赖老版本 golang.org/x/net 等库间接约束了 toolchain,请一并升级。
小结
context.WithCancelCause 是一个小面积、低侵入的增量能力:取消语义不变,ctx.Err() 行为不变,只在原有机制旁多挂一个可读的 error 原因。落地时有四条主线:取消发起方构造含可枚举字段的 cause;消费方统一记录 err 与 cause 两个字段;为预期内的取消(优雅退出、客户端断开)定义类型并在日志分级上降噪;牢记「首次取消生效、子继承父、超时 cause 固定」三条传播规则。做完这四步,context canceled 这条日志才真正变得可定位。
FAQ
Q1:context.Cause 和 errors.Unwrap 拿到的错误是一回事吗?
不是。Cause 返回取消方传入的原因 error,它是一个独立的错误对象;errors.Unwrap 用于拆解错误包装链。若原因本身用 fmt.Errorf("...: %w", ErrX) 包装,可以对 Cause 的返回值再用 errors.Is 判断底层哨兵。
Q2:父 ctx 被取消后,子 ctx 调用 cancel(bizErr) 会报错吗?
不会报错,也不会产生任何效果。首次取消生效,后续调用静默忽略。日志里看到的原因始终是父 ctx 的原因,排查时要沿 ctx 派生链找第一个取消点。
Q3:能不能在 defer 里根据返回值决定 cancel 的原因?
可以,常见写法:
func run(ctx context.Context) (err error) {
ctx, cancel := context.WithCancelCause(ctx)
defer func() { cancel(err) }()
// ...
return nil
}
这样函数返回时会把 err 作为取消原因,调用方在函数返回后读取 context.Cause(ctx) 能拿到具体错误。注意:err 必须是有名字的返回值,否则闭包里捕获不到;且只在 ctx 生命周期不超过该函数时如此使用,若 ctx 被缓存到别处,这个模式会让原因语义变得混乱。
Q4:日志里应该打 ctx.Err() 还是只打 context.Cause?
两个都打。原因有二:ctx.Err() 能区分「取消」和「超时」两大概括类别,适合做指标聚合与告警规则;context.Cause 提供具体上下文,适合人工排查。可以固定为 err=... cause=... 两个字段,避免日志消费方解析 message 文本。
Q5:这个机制能否替代显式的业务错误返回?
不能。Cause 是「取消」的附属信息,只对还在监听 ctx 的调用方可见;正常返回路径上的错误依旧应该通过 return err 逐层传递。把业务错误塞进 cancel 原因而不返回,会导致调用方在 ctx 未取消的场景下拿不到错误。