context.WithCancelCause 在 Go 中传递取消原因并区分日志

服务端代码里最常见的一句日志大概是 context canceled。它出现在 gRPC handler、HTTP middleware、后台任务、数据库调用的错误分支上,几乎没有任何信息量:是客户端主动断开,是超时兜底,是服务优雅退出,还是上游逻辑判定参数非法主动放弃?这四种情况的处置方式完全不同,但日志里长得一模一样。 Go 1.20 给 context

服务端代码里最常见的一句日志大概是 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 未取消的场景下拿不到错误。