取消任务时,只知道 canceled 有时不够
context.Context 是 Go 并发和服务端代码里非常核心的工具。它可以传递超时、取消信号和请求范围数据。以前我们通常通过 ctx.Err() 判断 context 为什么结束,常见结果是 context.Canceled 或 context.DeadlineExceeded。这对很多场景已经够用,但在复杂任务里,有时你还想知道更具体的原因。
比如一个批处理任务启动了多个 worker,其中一个 worker 发现配置错误,于是取消整个任务。下游只看到 context canceled,排查时还要翻日志找第一个失败点。Go 1.20 提供了取消原因:取消时附带一个错误,之后可以用 context.Cause(ctx) 读取。
这篇文章讲普通取消、带原因取消,以及在 worker 场景中的用法。
普通取消复习
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-ctx.Done()
fmt.Println(ctx.Err())
}()
cancel()
输出:
context canceled
超时:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
case <-time.After(3 * time.Second):
fmt.Println("done")
case <-ctx.Done():
fmt.Println(ctx.Err())
}
输出通常是:
context deadline exceeded
在 HTTP handler 里,你应该把 r.Context() 传给下游:
user, err := service.GetUser(r.Context(), id)
这样客户端断开或请求超时时,下游数据库查询、HTTP 调用和业务任务都有机会停止。
WithCancelCause
带原因取消:
ctx, cancel := context.WithCancelCause(context.Background())
cancel(fmt.Errorf("config file is invalid"))
<-ctx.Done()
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))
ctx.Err() 仍然是通用的 context canceled,context.Cause(ctx) 会返回你传入的具体错误。这样既兼容原有 context 使用方式,也能给需要更多信息的地方提供原因。
一个任务函数:
func runTask(ctx context.Context) error {
select {
case <-time.After(5 * time.Second):
return nil
case <-ctx.Done():
return fmt.Errorf("task stopped: %w", context.Cause(ctx))
}
}
如果取消原因是 config file is invalid,返回错误就会带上这个上下文。
在 worker 组里使用
type Job struct {
ID int64
}
func processJob(ctx context.Context, job Job) error {
select {
case <-time.After(100 * time.Millisecond):
if job.ID == 3 {
return fmt.Errorf("job data is broken")
}
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}
运行多个任务:
func RunJobs(parent context.Context, jobs []Job) error {
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)
errCh := make(chan error, len(jobs))
var wg sync.WaitGroup
for _, job := range jobs {
job := job
wg.Add(1)
go func() {
defer wg.Done()
if err := processJob(ctx, job); err != nil {
cancel(fmt.Errorf("job %d failed: %w", job.ID, err))
errCh <- err
}
}()
}
wg.Wait()
close(errCh)
if cause := context.Cause(ctx); cause != nil {
return cause
}
return nil
}
第一个失败的 job 会取消整个 context,并记录失败原因。其他 worker 感知到取消后可以尽快退出。
这个示例为了入门保持简单。真实项目里你可能还要收集多个错误、限制并发数量、区分可重试错误和不可重试错误。
不要把 cause 当业务数据通道
取消原因应该是“为什么停止”的错误,不应该塞入大量业务数据。比如不要把完整用户对象、请求体或配置内容放进 cause。context 的职责仍然是取消和超时传播,不是替代函数参数。
好的原因:
cancel(fmt.Errorf("job %d failed: %w", job.ID, err))
不好的原因:
cancel(fmt.Errorf("full request body: %s", body))
后者可能泄露敏感信息,也让日志变得混乱。
测试取消原因
取消逻辑也可以写测试。不要只在真实服务里按 Ctrl+C 观察日志。
func TestRunTaskCanceledWithCause(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(fmt.Errorf("manual stop"))
err := runTask(ctx)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "manual stop") {
t.Fatalf("error = %v, want manual stop", err)
}
}
如果你希望程序能用 errors.Is 判断原因,可以定义哨兵错误:
var ErrManualStop = errors.New("manual stop")
func TestCauseIsPreserved(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(ErrManualStop)
if !errors.Is(context.Cause(ctx), ErrManualStop) {
t.Fatalf("cause = %v", context.Cause(ctx))
}
}
测试的重点是确认取消原因没有在封装过程中丢失。并发代码一旦变复杂,只有日志很难证明行为稳定,单元测试能把关键语义固定下来。
小结
Go 1.20 的取消原因让 context 更容易排查。context.WithCancelCause 可以在取消时附带错误,context.Cause 可以读取具体原因。它适合 worker 组、批处理、服务关闭和复杂请求链路。
使用时保持克制:context 仍然负责取消,不负责传业务数据。把原因写得短小、明确、可排查,就能让并发代码停止得更有解释力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。