Go 中的 Context:超时、取消和传值

1. 概述

编写处理多个 goroutine 的 Go 服务时,你需要一种统一的方式来告诉子 goroutine:“停止吧,我不需要结果了”或者“只运行 2 秒然后放弃”。context.Context 正是为了解决这个问题而诞生的。

本文介绍了使用 context 的三个主要方面:手动取消、设置超时/截止时间,以及跨多个函数调用层级传递请求作用域的值。读完本文后,你将能够编写 HTTP 处理函数、数据库调用函数和子 goroutine 调用,它们能够在正确的时刻自动停止,避免 goroutine 泄漏。

2. Context 是什么以及为什么需要它

context.Context 是一个接口,用于描述处理流程(通常是一个请求)的“上下文”。它携带:

  • 取消信号。
  • 截止时间/超时。
  • 与请求相关联的一些键值对数据。
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
  • Done() 返回一个 channel,当 context 被取消或超期时该 channel 关闭。
  • Err() 说明原因:context.Canceled 或 context.DeadlineExceeded。
  • Value() 获取附加到 context 的值。

注意: Context 不应该用来传递任意的可选参数。它只应该承载控制信号和请求作用域的数据,如请求 ID、当前用户——而不是配置或重要的业务对象。

3. 使用 WithCancel 手动取消

当你想自己决定何时取消时使用,例如当多个 goroutine 中的一个已有结果时取消其他 goroutine。

func fetchFromMultipleSources(ctx context.Context) (string, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // 始终调用 cancel 来释放资源

	resultCh := make(chan string, 1)

	for _, src := range []string{"cache", "db", "api"} {
		go func(source string) {
			select {
			case <-time.After(100 * time.Millisecond):
				resultCh <- source
				cancel() // 已有结果,取消其他来源
			case <-ctx.Done():
				return // 被取消,提前退出
			}
		}(src)
	}

	select {
	case res := <-resultCh:
		return res, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}
  • context.WithCancel(ctx) 创建一个子 context 和一个 cancel 函数来取消它。
  • defer cancel() 是必须的:如果不调用它,子 context 会“悬挂”到 parent 被取消时,导致在 ctx.Done() 上 select 的 goroutine 泄漏。
  • 哪个 goroutine 先完成就调用 cancel(),其他 goroutine 通过 ctx.Done() 接收信号并停止。

常见错误:忘记 defer cancel()。如果函数提前返回(例如由于 error)而没有取消 context,子 goroutine 仍然存活直到被间接垃圾回收——实际上就是泄漏。

4. 使用 WithTimeout、WithDeadline 设置超时和截止时间

4.1. WithTimeout 设置相对时间限制

func callPaymentService(ctx context.Context, orderID string) error {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		"https://payment.local/orders/"+orderID, nil)
	if err != nil {
		return err
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err // 可能是 context.DeadlineExceeded
	}
	defer resp.Body.Close()
	return nil
}
  • context.WithTimeout(ctx, 2*time.Second) 在 2 秒后自动取消 context。
  • http.NewRequestWithContext 将 context 附加到请求;当 context 超期时,http.Client 自动取消正在运行的请求。
  • 如果超过 2 秒,resp, err := http.DefaultClient.Do(req) 返回的 err 包含 context.DeadlineExceeded。

检查超时错误:

if err != nil {
	if errors.Is(err, context.DeadlineExceeded) {
		log.Println("支付服务超时")
	}
	return err
}

4.2. WithDeadline 设置绝对时间截止

func processBeforeMidnight(ctx context.Context) error {
	deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.Local)
	ctx, cancel := context.WithDeadline(ctx, deadline)
	defer cancel()

	select {
	case <-time.After(5 * time.Hour):
		return errors.New("已完成处理")
	case <-ctx.Done():
		return ctx.Err()
	}
}
  • 当你有一个具体的时间点(例如一天结束时)时使用 WithDeadline,这与 WithTimeout 不同,WithTimeout 是从现在起的相对时间段。
  • 本质上,WithTimeout(ctx, d) 只是调用 WithDeadline(ctx, time.Now().Add(d)) 的一个薄包装。

5. 使用 WithValue 通过 context 传递值

用于与请求关联的数据,而不是业务参数。

type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

func RequestIDFromContext(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok
}
func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		ctx := WithRequestID(r.Context(), id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func orderHandler(w http.ResponseWriter, r *http.Request) {
	if id, ok := RequestIDFromContext(r.Context()); ok {
		log.Printf("[%s] 处理订单", id)
	}
}
  • 用 type requestIDKey struct{} 作为唯一的 key——避免直接使用 string,因为容易与其他包的 key 冲突。
  • WithRequestID 和 RequestIDFromContext 封装了 set/get 逻辑,其他包不需要知道真正的 key 是什么。
  • r.WithContext(ctx) 创建一个新请求,携带已附加值的 context,并通过 middleware 链传递。

注意: 不要使用 context.WithValue 来传递 *sql.DB、配置或函数正确运行所需的任何东西。这些应该是显式参数或结构体字段。Context value 只适合“锦上添花”的数据,如请求 ID、trace ID、登录用户。

6. context.TODO 和 context.Background

func main() {
	ctx := context.Background() // 根 context,永远不会被取消
	run(ctx)
}

func legacyFunctionBeingRefactored() {
	ctx := context.TODO() // 占位符,之后会替换为真实的 context
	_ = ctx
}
  • context.Background() 是根 context,用于 main、test 或顶层初始化的地方。
  • context.TODO() 用于当你正在重构并且不确定应该传入哪个 context 的情况——功能上类似于 Background(),但清楚地表明了对代码读者和 linter 工具的意图。

7. 在多个函数调用层级中传递 context

约定:ctx context.Context 始终是第一个参数。

func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
	if err := s.validate(ctx, order); err != nil {
		return err
	}
	return s.repo.Save(ctx, order)
}

func (r *OrderRepository) Save(ctx context.Context, order Order) error {
	_, err := r.db.ExecContext(ctx,
		"INSERT INTO orders (id, total) VALUES (?, ?)", order.ID, order.Total)
	return err
}
  • ExecContext 接收 context,如果上面的 HTTP 请求被取消(客户端关闭连接),DB 驱动会立即取消正在运行的查询——避免不必要地占用连接。
  • 原则:任何调用 I/O(HTTP、DB、gRPC)的函数都应该接收 context 并将其传递给支持 context 的 API(带 *Context 后缀)。

8. 用 test 检查行为

func TestFetchFromMultipleSources_ReturnsFastestSource(t *testing.T) {
	ctx := context.Background()

	result, err := fetchFromMultipleSources(ctx)

	require.NoError(t, err)
	assert.Contains(t, []string{"cache", "db", "api"}, result)
}

func TestCallPaymentService_TimesOutWhenSlow(t *testing.T) {
	slowServer := httptest.NewServer(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			time.Sleep(3 * time.Second)
		}))
	defer slowServer.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
	_, err := http.DefaultClient.Do(req)

	assert.ErrorIs(t, err, context.DeadlineExceeded)
}
<!-- go.mod: 添加 assertion 库(不是 XML,只是备注) -->
go get github.com/stretchr/testify
  • 第一个测试使用 context.Background(),因为通常的单元测试不需要控制超时。
  • 第二个测试故意设置 500ms 的超时,短于服务器处理时间 (3s),以确认返回的错误确实是 context.DeadlineExceeded。
  • httptest.NewServer 创建一个虚拟服务器,不需要复杂的 mock。

9. 常见错误

  • 不调用 cancel():在调用 WithCancel/WithTimeout/WithDeadline 后始终 defer cancel(),即使 context 会自动超期——Go vet 会在你忘记时发出警告。
  • 将 context 存储在结构体字段中:context 应该作为函数参数传递,而不是保存为结构体字段(除了像 http.Request 这样的特殊情况)。存储在字段中容易导致 context 陈旧。
  • 使用 context 传递必需参数:如果函数在缺少该值时无法正确运行,请将其作为普通参数传递,不要隐藏在 context 中。
  • 创建新 context 而不是使用父 context:如果你在接收来自调用者的 ctx 的函数内调用 context.Background(),你会失去来自上层的取消/超时能力——始终从接收到的 context 派生:context.WithTimeout(ctx, ...),而不是 context.WithTimeout(context.Background(), ...)。

何时不需要 context: 对于纯函数(pure function),没有 I/O,没有 goroutine,你不需要 context 作为参数——添加它只会增加噪音。

10. 总结

Context 解决了 Go 并发中的三个核心问题:取消信号、时间限制和在函数调用层级间传递一些小数据。最安全的规则是始终将 ctx 作为第一个参数,始终 defer cancel(),并仅将 WithValue 用于真正必要的请求作用域数据。掌握这些原则,你将能够编写不会泄漏 goroutine 的 Go 服务,并在客户端超时或取消请求时正确地做出反应。

golangcontextconcurrencygoroutinetimeout