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 服务,并在客户端超时或取消请求时正确地做出反应。