第 12 章

Context:取消、超时与值传递

Context — 取消、超时与值传递

在服务器程序中,每个传入的请求通常在自己的 goroutine 中处理,而处理过程中可能会启动更多 goroutine 来访问数据库、调用 RPC、请求下游服务。当一个请求被取消或超时时,所有为该请求工作的 goroutine 都应该迅速退出,释放资源。这就是 context 包要解决的核心问题。

context 包由 Sameer Ajmani 设计,首次在 2014 年的博客文章 "Go Concurrency Patterns: Context"(The Go Blog)中介绍,Go 1.7 时正式纳入标准库。它的设计目标只有一个:跨 API 边界和 goroutine 传递截止时间(deadline)、取消信号(cancellation)和请求范围数据(request-scoped values)。

Level 1:你需要知道的

Context 是什么

context.Context 是一个接口,定义了四个方法:

type Context interface {
    // 返回截止时间。如果没有设置截止时间,ok = false
    Deadline() (deadline time.Time, ok bool)
    
    // 返回一个 channel,当 context 被取消时关闭
    Done() <-chan struct{}
    
    // Done channel 关闭后,返回取消原因
    Err() error
    
    // 返回与 key 关联的值,如果没有则返回 nil
    Value(key interface{}) interface{}
}

你不需要自己实现这个接口——标准库提供了所有你需要的实现。

两个根 Context

每个 context 树的根节点只有两种选择:

// 用于 main 函数、初始化和测试
ctx := context.Background()

// 用于你不确定该用什么 context 的地方(临时占位)
ctx := context.TODO()

两者在功能上完全相同——都是永远不会被取消的空 context。区别纯粹是语义和文档层面的:TODO() 表示"我还没想好这里应该传什么 context,之后要回来修"。

WithCancel:手动取消

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 确保函数退出时释放资源

    go worker(ctx)

    time.Sleep(3 * time.Second)
    cancel() // 通知 worker 退出
    time.Sleep(1 * time.Second) // 给 worker 时间清理
}

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("worker stopped:", ctx.Err())
            return
        default:
            fmt.Println("working...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

WithCancel 返回一个新的 context 和一个 cancel 函数。调用 cancel() 会:

  1. 关闭该 context 的 Done() channel
  2. 关闭所有子 context 的 Done() channel(级联取消)
  3. 设置 Err() 返回 context.Canceled

关键规则:创建了 cancel 函数就必须调用它。 不调用会导致资源泄漏——context 内部的 goroutine 和 channel 不会被释放。用 defer cancel() 是最安全的模式。

WithTimeout 和 WithDeadline:自动超时

// WithTimeout:从现在开始计时
func fetchData(ctx context.Context) ([]byte, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err // 可能是超时:ctx.Err() == context.DeadlineExceeded
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

// WithDeadline:指定绝对时间
func processBeforeClose(ctx context.Context) {
    deadline := time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)
    ctx, cancel := context.WithDeadline(ctx, deadline)
    defer cancel()

    select {
    case <-ctx.Done():
        fmt.Println("deadline reached:", ctx.Err())
    case result := <-doWork(ctx):
        fmt.Println("completed:", result)
    }
}

WithTimeout(parent, d) 等价于 WithDeadline(parent, time.Now().Add(d))

超时嵌套规则:子 context 的超时不能超过父 context。

parentCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

// 子 context 请求 30 秒超时——但实际上 10 秒后就会被取消
// 因为父 context 先超时
childCtx, childCancel := context.WithTimeout(parentCtx, 30*time.Second)
defer childCancel()

WithValue:传递请求范围数据

type contextKey string

const (
    keyRequestID contextKey = "request_id"
    keyUserID    contextKey = "user_id"
)

func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := uuid.New().String()
        ctx := context.WithValue(r.Context(), keyRequestID, requestID)
        ctx = context.WithValue(ctx, keyUserID, getUserFromAuth(r))
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func handler(w http.ResponseWriter, r *http.Request) {
    requestID := r.Context().Value(keyRequestID).(string)
    userID := r.Context().Value(keyUserID).(string)
    log.Printf("[%s] user %s requested data", requestID, userID)
}

使用自定义类型作为 key(避免冲突):

// 错误:用 string 作 key,可能与其他包冲突
ctx = context.WithValue(ctx, "user_id", "123")

// 正确:用未导出的自定义类型,保证唯一性
type contextKey string
const keyUserID contextKey = "user_id"
ctx = context.WithValue(ctx, keyUserID, "123")

Context 传递的黄金规则

  1. Context 作为第一个参数传递
// 正确
func FetchUser(ctx context.Context, id string) (*User, error)

// 错误:不要把 context 放在结构体里
type Client struct {
    ctx context.Context // 不要这样做!
}
  1. 不要传 nil context
// 如果不确定用什么,用 context.TODO()
func doSomething(ctx context.Context) {
    if ctx == nil {
        ctx = context.TODO() // 但最好在调用方就传正确的 context
    }
}
  1. 相同的 context 可以传给多个 goroutine
func processAll(ctx context.Context, items []Item) {
    var wg sync.WaitGroup
    for _, item := range items {
        wg.Add(1)
        go func(it Item) {
            defer wg.Done()
            process(ctx, it) // 同一个 ctx,取消时所有 goroutine 都收到信号
        }(item)
    }
    wg.Wait()
}

常见错误

错误 1:忽略 context 取消信号

// 错误:虽然接受了 ctx,但完全没检查
func badWorker(ctx context.Context) {
    for i := 0; i < 1000000; i++ {
        heavyComputation(i) // 即使 ctx 被取消,也会执行完所有循环
    }
}

// 正确:在长循环中定期检查
func goodWorker(ctx context.Context) error {
    for i := 0; i < 1000000; i++ {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        heavyComputation(i)
    }
    return nil
}

错误 2:在错误的层级取消

// 错误:在循环内部创建 WithCancel,但 cancel 从未被调用
func bad(ctx context.Context) {
    for i := 0; i < 100; i++ {
        ctx, _ := context.WithCancel(ctx) // 泄漏!cancel 被丢弃了
        go doWork(ctx)
    }
}

// 正确:保存 cancel 并在适当时机调用
func good(ctx context.Context) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()
    
    for i := 0; i < 100; i++ {
        go doWork(ctx)
    }
}

错误 3:用 WithValue 传递非请求范围数据

// 错误:数据库连接不是请求范围数据
ctx = context.WithValue(ctx, "db", dbConn) // 不要这样!

// 错误:可选参数不应该通过 context 传
ctx = context.WithValue(ctx, "verbose", true) // 不要这样!

// 正确的 WithValue 用途:
// - Request ID
// - 认证信息(user ID, token)
// - 分布式追踪 span
// - 请求的 locale/language

实战:HTTP 中间件链

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", getUsers)
    
    // 中间件链:logging -> timeout -> auth -> handler
    handler := loggingMiddleware(
        timeoutMiddleware(5*time.Second,
            authMiddleware(mux)))
    
    http.ListenAndServe(":8080", handler)
}

func timeoutMiddleware(timeout time.Duration, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), timeout)
        defer cancel()
        
        // 用新的 context 替换请求的 context
        r = r.WithContext(ctx)
        
        done := make(chan struct{})
        go func() {
            next.ServeHTTP(w, r)
            close(done)
        }()
        
        select {
        case <-done:
            // handler 正常完成
        case <-ctx.Done():
            // 超时
            http.Error(w, "request timeout", http.StatusGatewayTimeout)
        }
    })
}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        user, err := validateToken(token)
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), keyUser, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Level 2:它是怎么运行的

Context 在 HTTP 请求中的完整生命周期

当一个 HTTP 请求到达 Go 服务器时:

客户端请求 → net/http.Server
    → Server 为请求创建 context(基于连接的 BaseContext)
    → 请求对象 r.Context() 返回该 context
    → 如果客户端断开连接,context 被取消
    → handler 通过 r.Context() 获取 context
    → handler 传递 context 给下游调用
// net/http 内部(简化)
func (srv *Server) Serve(l net.Listener) error {
    baseCtx := context.Background()
    if srv.BaseContext != nil {
        baseCtx = srv.BaseContext(l)
    }
    
    for {
        conn, _ := l.Accept()
        connCtx := srv.ConnContext(baseCtx, conn) // 可定制
        go srv.serveConn(connCtx, conn)
    }
}

func (c *conn) serve(ctx context.Context) {
    // 当连接关闭时取消 context
    ctx, cancelCtx := context.WithCancel(ctx)
    defer cancelCtx()
    
    // ... 读取请求 ...
    req.ctx = ctx // 请求绑定到连接的 context
}

关键点: 当客户端断开 TCP 连接时,net/http 会自动取消请求的 context。这意味着如果你的 handler 正在做一个耗时操作(比如数据库查询),你可以通过检查 ctx.Done() 来提前终止,而不是白白做完一个客户端已经不需要的请求。

Context 与 Goroutine 生命周期管理

Context 的最大价值在于管理 goroutine 的生命周期——特别是"级联取消"。

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // 启动 3 个并行的数据获取
    userCh := make(chan *User, 1)
    ordersCh := make(chan []*Order, 1)
    recsCh := make(chan []*Recommendation, 1)
    
    go func() {
        user, _ := fetchUser(ctx, r.URL.Query().Get("id"))
        userCh <- user
    }()
    go func() {
        orders, _ := fetchOrders(ctx, r.URL.Query().Get("id"))
        ordersCh <- orders
    }()
    go func() {
        recs, _ := fetchRecommendations(ctx, r.URL.Query().Get("id"))
        recsCh <- recs
    }()
    
    // 等待所有结果或 context 取消
    select {
    case <-ctx.Done():
        // 客户端断开或超时——3 个 goroutine 都会收到取消信号
        http.Error(w, "request cancelled", http.StatusServiceUnavailable)
        return
    case user := <-userCh:
        // ... 继续等其他结果
        _ = user
    }
}

当请求被取消时,ctx.Done() 被关闭,传递给 fetchUserfetchOrdersfetchRecommendations 的同一个 ctx 也会"感知"到取消。如果这些函数内部正在做 HTTP 请求或数据库查询(且正确使用了 ctx),它们会提前终止。

Context 树的结构

Context 形成一棵树——每个 With* 调用都创建一个子节点:

Background
├── WithCancel (请求 A)
│   ├── WithTimeout (数据库查询, 5s)
│   ├── WithTimeout (外部 API 调用, 10s)
│   │   └── WithValue (trace span)
│   └── WithValue (user info)
└── WithCancel (请求 B)
    └── WithTimeout (处理超时, 30s)

取消是自上而下传播的:取消一个节点会取消它的所有后代,但不影响兄弟节点或祖先节点。

Context 内部实现

标准库中有几种 context 实现:

// emptyCtx: Background() 和 TODO() 的实现
type emptyCtx struct{}

func (emptyCtx) Deadline() (time.Time, bool) { return time.Time{}, false }
func (emptyCtx) Done() <-chan struct{}        { return nil } // 永远不会被取消
func (emptyCtx) Err() error                  { return nil }
func (emptyCtx) Value(key any) any           { return nil }

// cancelCtx: WithCancel 的实现
type cancelCtx struct {
    Context                        // 嵌入父 context
    mu       sync.Mutex
    done     atomic.Value          // chan struct{}, 懒创建
    children map[canceler]struct{} // 子 context 集合
    err      error
}

// timerCtx: WithTimeout/WithDeadline 的实现
type timerCtx struct {
    *cancelCtx
    timer    *time.Timer
    deadline time.Time
}

// valueCtx: WithValue 的实现
type valueCtx struct {
    Context      // 嵌入父 context
    key, val any
}

WithValue 的查找是链表遍历:

func (c *valueCtx) Value(key any) any {
    if c.key == key {
        return c.val
    }
    return value(c.Context, key) // 递归向上查找
}

这意味着 Value 的时间复杂度是 O(n),n 是从当前节点到根节点的路径长度。不要在 context 链中存储大量值——它不是用来当 map 的。

cancel 传播机制

当调用 cancel() 时:

func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // 已经被取消过了
    }
    c.err = err
    c.cause = cause
    
    // 关闭 done channel
    d, _ := c.done.Load().(chan struct{})
    if d == nil {
        c.done.Store(closedchan) // 复用一个已关闭的全局 channel
    } else {
        close(d)
    }
    
    // 级联取消所有子 context
    for child := range c.children {
        child.cancel(false, err, cause)
    }
    c.children = nil
    c.mu.Unlock()
    
    if removeFromParent {
        removeChild(c.Context, c) // 从父 context 中移除自己
    }
}

注意 closedchan 优化:如果在取消之前没有人调用过 Done()(没有人在等待取消信号),就不需要创建和关闭一个真正的 channel——直接使用一个预先关闭的全局 channel。

WithTimeout 的实现

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    // 如果父 context 的 deadline 更早,直接创建 cancelCtx
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        return WithCancel(parent)
    }
    
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }
    propagateCancel(parent, c) // 将自己注册为父的子 context
    
    dur := time.Until(d)
    if dur <= 0 {
        c.cancel(true, DeadlineExceeded, nil) // 已过期
        return c, func() { c.cancel(false, Canceled, nil) }
    }
    
    c.timer = time.AfterFunc(dur, func() {
        c.cancel(true, DeadlineExceeded, nil)
    })
    
    return c, func() { c.cancel(true, Canceled, nil) }
}

为什么即使超时后也要调用 cancel? time.AfterFunc 创建了一个 Timer。如果操作在超时前完成了,调用 cancel 会停止 Timer 并释放关联资源。如果不调用,Timer 会一直存活到超时——虽然不会导致功能错误,但会浪费一小块内存。

Context 与数据库操作

func queryUsers(ctx context.Context, db *sql.DB) ([]User, error) {
    // database/sql 原生支持 context
    rows, err := db.QueryContext(ctx, "SELECT id, name FROM users WHERE active = true")
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    var users []User
    for rows.Next() {
        // 如果 ctx 被取消,rows.Next() 会返回 false
        var u User
        if err := rows.Scan(&u.ID, &u.Name); err != nil {
            return nil, err
        }
        users = append(users, u)
    }
    return users, rows.Err()
}

database/sql 在 Go 1.8 引入了 Context 方法族(QueryContextExecContext 等)。当 context 被取消时:

  1. 如果查询还没发送到数据库——直接返回错误
  2. 如果查询正在执行——向数据库发送 cancel 信号(MySQL 的 KILL QUERY
  3. 如果结果正在读取——停止读取,关闭连接

gRPC 中的 Context

gRPC 是 context 的另一个重度用户。客户端设置的 deadline 会通过 gRPC 协议传播到服务端:

// 客户端
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

resp, err := client.GetUser(ctx, &pb.UserRequest{Id: "123"})
if err != nil {
    if status.Code(err) == codes.DeadlineExceeded {
        log.Println("server took too long")
    }
}

// 服务端
func (s *server) GetUser(ctx context.Context, req *pb.UserRequest) (*pb.UserResponse, error) {
    // ctx 已经带有客户端设置的 deadline!
    user, err := s.db.QueryContext(ctx, "SELECT * FROM users WHERE id = ?", req.Id)
    if err != nil {
        return nil, err
    }
    return &pb.UserResponse{User: user}, nil
}

Deadline 通过 gRPC 的 metadata 传播:客户端计算剩余时间,编码到 grpc-timeout header 中,服务端据此设置本地 context 的 deadline。

优雅关闭(Graceful Shutdown)

func main() {
    // 创建一个根 context,用于关闭信号
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()
    
    srv := &http.Server{Addr: ":8080", Handler: mux}
    
    // 在后台启动 server
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    
    // 等待关闭信号
    <-ctx.Done()
    log.Println("shutting down...")
    
    // 给正在处理的请求 30 秒时间完成
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatal("forced shutdown:", err)
    }
    log.Println("server stopped gracefully")
}

注意:Shutdown 用的是一个新的 context.Background() 为基础的 context,而不是已经被取消的 ctx——因为我们需要给 Shutdown 一个新的超时期限。

Level 3:规范怎么定义的

Context 的设计背景和争议

context 包的设计来自 Google 内部的实践。Sameer Ajmani 在 2014 年的博客文章 "Go Concurrency Patterns: Context" 中首次公开介绍了这个设计。它解决的是 Google 内部大规模微服务架构中的实际问题:一个用户请求可能触发数十个 RPC,每个 RPC 可能触发更多 RPC,形成一棵调用树。当用户取消请求时,整棵调用树都需要知道。

争议 1:Context 应该显式传递还是隐式传递?

Go 选择了显式传递(作为第一个参数),而不是像 Java 的 ThreadLocal 那样隐式关联到线程。理由:

争议 2:WithValue 是否是好设计?

这是 context 包争议最大的部分。批评者(包括 Dave Cheney, "Context is for cancelation" 2017)认为:

支持者认为:

争议 3:Context 放在第一个参数是否合适?

Russ Cox 在 Go issue #28342 中讨论了一种替代方案:把 context 放在方法的 receiver 中。最终未采纳,因为:

Go 标准库中 Context 的演进

版本 变化
Go 1.7 context 包从 golang.org/x/net/context 移入标准库
Go 1.8 database/sql 增加 Context 方法
Go 1.14 os/signal.NotifyContext
Go 1.16 signal.NotifyContext 简化信号处理
Go 1.20 context.WithCancelCause 允许传递取消原因
Go 1.21 context.WithoutCancel 创建不会被父取消的子 context
Go 1.21 context.AfterFunc 注册取消回调

Go 1.20: WithCancelCause

ctx, cancel := context.WithCancelCause(parent)

// 取消时提供原因
cancel(fmt.Errorf("user %s rate limited", userID))

// 获取取消原因
err := context.Cause(ctx)
fmt.Println(err) // "user xxx rate limited"

这解决了一个长期痛点:以前 ctx.Err() 只返回 context.Canceled,你无法知道为什么被取消。

Go 1.21: WithoutCancel

// 创建一个不会被父 context 取消的子 context
// 但仍然继承父 context 的 Value
func WithoutCancel(parent Context) Context

使用场景:当你需要在请求 handler 返回后继续做一些异步工作(比如异步写日志),但仍然需要请求中的 trace ID:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // 请求返回后继续执行的后台任务
    bgCtx := context.WithoutCancel(ctx) // 不会被请求取消影响
    go func() {
        // bgCtx 仍然有 trace ID 等 Value
        // 但不会因为请求结束而被取消
        asyncLog(bgCtx, "request processed")
    }()
    
    w.WriteHeader(http.StatusOK)
}

Go 1.21: AfterFunc

stop := context.AfterFunc(ctx, func() {
    // 当 ctx 被取消时执行
    cleanup()
})

// 如果不再需要这个回调:
stop() // 防止回调执行(如果还没被触发)

这为 context 取消提供了一个回调机制,替代了"启动一个 goroutine 监听 ctx.Done()"的模式:

// 旧模式:需要启动 goroutine
go func() {
    <-ctx.Done()
    conn.Close()
}()

// 新模式:更简洁,不需要额外 goroutine
stop := context.AfterFunc(ctx, func() {
    conn.Close()
})
defer stop()

context.Context 接口的设计决策

为什么 Done() 返回 channel 而不是提供 Wait() 方法?

Channel 可以和 select 一起使用,这是 Go 并发编程的核心模式:

select {
case <-ctx.Done():
    return ctx.Err()
case result := <-ch:
    return result, nil
}

如果用 Wait() 方法,就无法与其他 channel 组合使用——你只能在单独的 goroutine 中调用 Wait(),这使得代码更复杂。

为什么 Value 的 key 是 interface{} 而不是 string?

为了避免不同包之间的 key 冲突。如果 key 是 string,两个不相关的包可能用相同的 key(如 "user")存储不同的值。用未导出的自定义类型作为 key,保证了命名空间隔离:

// package auth
type contextKey struct{}
var userKey = contextKey{}

// package logging
type contextKey struct{} // 不同的类型,即使名字相同也不冲突
var userKey = contextKey{}

与其他语言的取消机制比较

特性 Go context Java CancellationToken C# CancellationToken Rust tokio
传递方式 显式参数 显式参数 显式参数 隐式(task local)
取消传播 树形级联 链式 链式 树形
值传递 WithValue ThreadLocal AsyncLocal task_local!
超时 WithTimeout 外部 Timer WithTimeout tokio::time::timeout
同步/异步 同步(Done channel) 异步(callback) 同步(WaitHandle) 异步

Go 的设计与 C# 的 CancellationToken 最为相似——都是显式传递、支持级联取消。主要区别在于 Go 用 channel 实现等待,而 C# 用 WaitHandle/callback。

Level 4:边界与陷阱

陷阱 1:不要把 Context 放到 struct 里

这是 context 官方文档中最强调的规则:

// 错误
type Server struct {
    ctx context.Context
    // ...
}

// 错误
type Request struct {
    ctx    context.Context
    url    string
    method string
}

为什么?

  1. 生命周期不匹配:struct 通常是长寿命的,context 是短寿命的。把 context 存在 struct 中模糊了"这个 context 代表哪个操作"。

  2. 不清楚该用哪个:如果 struct 有一个 ctx 字段,同时方法又接收 ctx 参数,到底用哪个?

  3. 无法更新:一旦存储在 struct 中,你无法在调用链深处替换为子 context(比如添加 timeout)。

// 正确做法:每个方法接收 context 参数
type Server struct {
    db *sql.DB
}

func (s *Server) HandleRequest(ctx context.Context, req *Request) error {
    // ctx 代表这个具体请求的生命周期
    return s.db.QueryContext(ctx, "...")
}

唯一例外: http.Request 将 context 存储在 struct 中,但这是历史原因(context 在 Request 之后引入),且 http.Request 本身就是请求范围的对象。

陷阱 2:WithValue 只传请求范围数据

什么是请求范围数据? 与单个请求的生命周期绑定的数据——请求开始时创建,请求结束时不再需要。

// 正确的 WithValue 用途
ctx = context.WithValue(ctx, traceIDKey, "abc-123")  // Trace ID
ctx = context.WithValue(ctx, userKey, authenticatedUser) // 认证用户
ctx = context.WithValue(ctx, spanKey, opentelemetry.Span{}) // 追踪 span

// 错误的 WithValue 用途
ctx = context.WithValue(ctx, "db", database)     // 不是请求范围
ctx = context.WithValue(ctx, "logger", logger)   // 应该用依赖注入
ctx = context.WithValue(ctx, "config", appConfig) // 应该用参数或 struct 字段
ctx = context.WithValue(ctx, "retries", 3)       // 应该是函数参数

经验法则: 如果数据在不同请求之间是相同的,它就不是请求范围数据。

陷阱 3:Context 泄漏

每个 WithCancel/WithTimeout/WithDeadline 都会在内部创建 goroutine 或 timer。如果不调用 cancel,这些资源不会被释放:

// 泄漏示例
func leak(ctx context.Context) {
    ctx, _ = context.WithTimeout(ctx, 5*time.Second) // cancel 被丢弃了!
    // 5 秒后 timer 才会清理
    // 如果这个函数被高频调用,会累积大量未释放的 timer
}

// 正确
func noLeak(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // 函数退出时立即释放
}

go vet 检测: 从 Go 1.16 开始,go vet 会警告未使用的 cancel 函数。

陷阱 4:Context.Value 的性能

Value() 的查找是沿着 context 链线性遍历的:

// 查找路径:current → parent → grandparent → ... → Background
func value(c Context, key any) any {
    for {
        switch ctx := c.(type) {
        case *valueCtx:
            if key == ctx.key {
                return ctx.val
            }
            c = ctx.Context
        case *cancelCtx:
            c = ctx.Context
        case *timerCtx:
            c = ctx.cancelCtx.Context
        case emptyCtx:
            return nil
        default:
            return c.Value(key)
        }
    }
}

如果 context 链很深(比如中间件嵌套 20 层,每层加一个 Value),最深处的 Value 查找需要遍历 20 个节点。

缓解策略:

  1. 减少 Value 的数量:把多个值打包成一个 struct
// 不好:3 次 WithValue,查找深度增加 3
ctx = context.WithValue(ctx, requestIDKey, reqID)
ctx = context.WithValue(ctx, userKey, user)
ctx = context.WithValue(ctx, spanKey, span)

// 更好:1 次 WithValue,打包所有请求元数据
type RequestMetadata struct {
    RequestID string
    User      *User
    Span      trace.Span
}
ctx = context.WithValue(ctx, metadataKey, &RequestMetadata{
    RequestID: reqID,
    User:      user,
    Span:      span,
})
  1. 在热路径上缓存 Value 结果
func handler(ctx context.Context) {
    // 一次查找,多次使用
    meta := ctx.Value(metadataKey).(*RequestMetadata)
    // ... 在函数内多次使用 meta,而不是每次都调用 ctx.Value()
}

陷阱 5:取消后的清理时间

取消是即时的,但有时你需要给 goroutine 一些时间来清理:

func worker(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            // 取消了,但需要时间清理
            cleanupCtx, cancel := context.WithTimeout(
                context.Background(), // 注意:用 Background,不是已取消的 ctx
                5*time.Second,
            )
            defer cancel()
            return cleanup(cleanupCtx)
        case task := <-taskCh:
            process(task)
        }
    }
}

注意:清理用的 context 必须基于 context.Background(),不能用已经取消的 ctx——否则清理操作也会立即被取消。

陷阱 6:select 中 ctx.Done() 的优先级

// 问题:如果 ctx 被取消,但 ch 也有数据就绪,select 随机选择
for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case v := <-ch:
        process(v)
    }
}

如果需要确保取消信号优先被处理:

for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case v := <-ch:
        // 处理前再检查一次
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        process(v)
    }
}

陷阱 7:Context 的不可变性

Context 是不可变的——每次 With* 调用都创建一个新的 context,原来的不受影响:

func middleware(ctx context.Context) context.Context {
    ctx2 := context.WithValue(ctx, keyA, "value")
    // ctx 没有变化!
    fmt.Println(ctx.Value(keyA))  // nil
    fmt.Println(ctx2.Value(keyA)) // "value"
    return ctx2
}

这意味着你必须返回新的 context(或传递给后续调用),否则修改不可见:

// 错误:middleware 修改了 ctx 但没传回来
func bad(ctx context.Context) {
    enrichContext(ctx) // 内部的 WithValue 创建了新 ctx,但没人用
    doWork(ctx)        // 用的还是原来的 ctx,没有新值
}

// 正确
func good(ctx context.Context) {
    ctx = enrichContext(ctx) // 用返回的新 ctx
    doWork(ctx)
}

真实案例:Context 在 Kubernetes 中的使用

Kubernetes API Server 处理一个请求的 context 链:

// 1. Server 创建请求 context
ctx := request.WithRequestInfo(req.Context(), requestInfo)

// 2. 认证中间件添加用户信息
ctx = request.WithUser(ctx, user)

// 3. 审计中间件添加审计 ID
ctx = audit.WithAuditID(ctx, auditID)

// 4. 超时中间件设置请求超时
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()

// 5. Handler 内部:创建 watch 时使用 context
watcher, err := storage.Watch(ctx, key, opts)
// 当请求被取消时,watcher 自动停止

Kubernetes 还定义了 wait.PollUntilContextCancel 来替代旧的 wait.Poll

// 旧 API(deprecated)
err := wait.Poll(interval, timeout, conditionFunc)

// 新 API(context-aware)
err := wait.PollUntilContextCancel(ctx, interval, immediate, conditionFunc)

面试考点

  1. context.Background() 和 context.TODO() 的区别?

    • 功能相同,语义不同。Background 用于根节点,TODO 是"临时占位,之后要改"
  2. 为什么不应该把 Context 放在 struct 里?

    • 生命周期不匹配、不清楚该用哪个 ctx、无法在调用链中替换
  3. WithTimeout 超时后还需要调用 cancel 吗?

    • 需要。超时前完成时调用 cancel 可以提前释放 timer 资源
  4. context.Value 的查找复杂度是什么?

    • O(n),n 是 context 链的深度(线性遍历到根)
  5. 如何在取消后做清理工作?

    • 基于 context.Background() 创建新的 timeout context,不能复用已取消的 ctx
  6. WithValue 应该存什么,不应该存什么?

    • 应该:request ID、认证信息、trace span
    • 不应该:DB 连接、logger、config、函数参数

总结

Context 是 Go 服务端编程的基础设施——它解决的是分布式系统中最基本的问题:取消传播和请求范围数据传递。

核心要点:

正确使用 context 是写好 Go 服务的必备技能。它不复杂,但需要纪律——每个函数都传 ctx,每个 cancel 都 defer,每个长操作都检查 Done()。

本章评分
4.6  / 5  (32 评分)

💬 留言讨论