Go 1.20 context 取消原因入门:让并发任务知道为什么停下

本文讲解 Go 1.20 中 context.WithCancelCause 和 context.Cause 的基本用法,帮助初学者写出更容易排查的取消链路。

取消任务时,只知道 canceled 有时不够

context.Context 是 Go 并发和服务端代码里非常核心的工具。它可以传递超时、取消信号和请求范围数据。以前我们通常通过 ctx.Err() 判断 context 为什么结束,常见结果是 context.Canceledcontext.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 canceledcontext.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 仍然负责取消,不负责传业务数据。把原因写得短小、明确、可排查,就能让并发代码停止得更有解释力。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页