Go Context 完全指南

从「它到底是什么」到「源码怎么实现」——讲清 Context 的功能、解决什么问题、四种构造方式的用法差异,以及生产环境的最佳实践与常见陷阱。

取消传播 超时控制 请求域数据 goroutine 泄漏 cancelCtx / timerCtx 最佳实践

01Context 到底是什么

一句话:Context 是「一次请求的生命周期 + 取消信号 + 请求域数据」的载体,沿着调用链一路往下传

一句话定义

Context(上下文)是 Go 标准库 context 包提供的一个接口,用于在 goroutine 之间(尤其是跨 API 边界、跨服务调用)传递取消信号、截止时间、超时控制与请求域数据它本身不做什么事,而是一个「约定」——所有可能阻塞的函数都接收它,并在它被取消时主动退出。

核心类比:把一次请求想象成一棵树。用户发来一个 HTTP 请求,服务端可能要查数据库、调三个微服务、读缓存。这些操作都是这棵树的子节点。如果用户中途关闭了浏览器,或者某个操作超时了,我们希望整棵树上的所有工作都立即停止,而不是让那些 goroutine 傻傻地等到自己完成、白白占用资源。Context 就是用来「广播这个停止信号」的机制。

本质
一棵树
每个 Context 都有一个父节点,取消信号从父往子单向级联传播,子取消不影响父。
核心能力
三件事
① 传取消信号 ② 传截止时间/超时 ③ 传请求域数据(如 traceID、用户身份)。
并发安全
Context 的实现都是并发安全的,可被多个 goroutine 同时使用,无需加锁。
💡 最重要的心智模型

Context 不是用来控制 goroutine 的(Go 没有强制杀死 goroutine 的机制),而是一个协作式的通知机制:Context 只负责「喊一声停」,具体停不停、怎么停,由拿到 Context 的那个函数自己决定、自己实现。这就是为什么所有阻塞操作都要用 select 监听 ctx.Done()

它长什么样:极简接口

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

只有 4 个方法,且没有 Cancel 方法——取消能力由可取消的具体实现(cancelCtx)提供,通过 WithCancel 返回的 cancel 函数来触发。这个设计保证了「只有创建者能取消,传递者只能监听」,避免下游随意取消上游。

02Context 到底解决什么问题

在 Context 出现之前(Go 1.7 之前),这些问题每一个都很棘手

① goroutine 泄漏(最痛)

启动一个 goroutine 去做耗时操作,调用方已经不需要结果了(用户取消、超时、报错提前返回),但这个 goroutine 还在跑,永远不退出,占着内存和栈。服务跑几天后 goroutine 数量爆炸,OOM。

② 取消信号无法传播

一个请求会派生出一棵调用树(DB 查询、RPC 调用、子 goroutine)。没有统一机制时,要停止整棵树得给每个环节单独发信号,代码里到处是 quit chan,极易漏掉某一个。

③ 超时控制各自为战

每个函数自己实现超时逻辑,用 time.Aftertime.Sleep,导致嵌套调用时超时时间叠加(外层 3s、内层各 3s,实际可能等 9s),而且无法做到「总超时」。

④ 请求域数据无处安放

traceID、用户 ID、语言偏好这类「贯穿整条调用链」的数据,如果靠函数参数传递,会污染所有函数签名;如果用全局变量,并发下会串号。

⚠️ 没有 Context 时的经典泄漏代码
// ❌ 泄漏版:调用方超时返回后,这个 goroutine 还在跑,结果永远没人接收
func fetch() Result {
    ch := make(chan Result)
    go func() {
        r := slowOperation()  // 耗时 10s
        ch <- r                        // 没人接收 → 永久阻塞 → goroutine 泄漏
    }()
    select {
    case r := <-ch:
        return r
    case <-time.After(2 * time.Second):
        return Result{}   // 超时返回了,但 goroutine 还在跑
    }
}

每次调用泄漏一个 goroutine。QPS 100 的服务,一小时泄漏 36 万个 goroutine。

✅ 有 Context 后的正确写法
// ✅ 正确版:ctx 被取消时,子 goroutine 能感知并退出
func fetch(ctx context.Context) Result {
    ch := make(chan Result, 1)  // ← 关键:缓冲区 1,即使没人收也不会阻塞
    go func() {
        ch <- slowOperation()
    }()
    select {
    case r := <-ch:
        return r
    case <-ctx.Done():          // ← 监听取消信号
        return Result{Err: ctx.Err()}
    }
}

两个关键点:channel 缓冲为 1(防 goroutine 卡在发送)+ select 监听 ctx.Done()(及时退出)。

Context 出现前后的对比

问题Context 之前Context 之后
goroutine 泄漏靠人工保证,极易遗漏Done() 统一通知,机制化解决
取消传播每个函数加 quit chan 参数,签名混乱一个 ctx 参数贯穿全链路,自动级联
超时控制各自 time.After,超时叠加WithTimeout 统一截止时间,子级自动继承
请求域数据全局变量(并发串号)或污染函数签名WithValue 随 ctx 传递,天然按请求隔离
跨库协作各库各自定义,无法互通标准库统一约定,DB/HTTP/gRPC 全部支持

03Context 接口四个方法详解

搞懂这四个方法,就搞懂了 Context 的全部对外能力

方法作用返回值含义典型用途
Done() 返回一个 channel,当 Context 被取消或超时时,这个 channel 会被关闭 <-chan struct{}关闭而非发送值——这样所有监听者都能同时收到(广播) select 中监听,实现「可被取消的阻塞」
Err() 返回 Context 为什么结束 nil=还没结束;context.Canceled=被主动取消;context.DeadlineExceeded=超时 区分「用户取消」与「超时」,做不同处理
Deadline() 返回该 Context 的截止时间 (time.Time, bool),第二个返回值为 false 表示没设截止时间 设置底层 I/O 的 deadline;计算剩余时间
Value() 取出该 Context 上绑定的请求域数据 interface{},key 不存在返回 nil 取 traceID、用户身份等贯穿链路的数据

逐个拆解

① Done() —— 取消通知的广播器

这是 Context 最核心的方法。它返回一个只读 channel。重点在于:当取消发生时,这个 channel 是被 close 掉的,而不是往里面发一个值。为什么?因为 close 一个 channel 会让所有正在 <-ch 上阻塞的 goroutine 同时被唤醒——这是一次性广播,正是取消语义需要的。如果发值,只有一个接收者能收到。

// 标准用法:select 监听 Done
select {
case result := <-resultCh:
    // 正常拿到结果
    handle(result)
case <-ctx.Done():
    // 被取消或超时,立即退出
    return ctx.Err()
}

// 如果没设取消,Done() 返回 nil,此时这个 case 永远阻塞(相当于禁用该分支)
var bg = context.Background()
bg.Done()  // → nil(nil channel 上的接收操作永远阻塞)
⚠️ 易错点

context.Background()context.TODO()Done() 返回 nil。在 select 里监听 nil channel 会永久阻塞(这是 Go 的特性),所以「永不取消」就自然实现了,不需要特判。

② Err() —— 为什么结束

只有在 Done() 关闭后,Err() 才返回非 nil。两个标准错误值:

  • context.Canceled —— 被主动调用 cancel() 取消
  • context.DeadlineExceeded —— 截止时间到了
// 区分取消原因,做不同处理
if err := ctx.Err(); err != nil {
    switch err {
    case context.Canceled:
        log.Println("用户主动取消,无需告警")       // 客户端断开,正常现象
    case context.DeadlineExceeded:
        log.Println("处理超时,需要告警")             // 系统慢了,要关注
    }
}

③ Deadline() —— 截止时间

返回绝对时间(不是剩余时长)。第二个返回值表示「是否设置了 deadline」。用途:把 Context 的超时传递给底层 I/O(如 net.Conn.SetDeadline),避免上层超时了底层还在傻等。

if dl, ok := ctx.Deadline(); ok {
    // 有截止时间:把它设到底层连接上
    conn.SetDeadline(dl)
    log.Printf("剩余时间: %v", time.Until(dl))
} else {
    // 无截止时间:Background/TODO 就是这种情况
    conn.SetDeadline(time.Time{})
}

④ Value() —— 请求域数据

取数据。只能用于「请求域」数据(随请求生命周期存在、跨函数边界传递的元数据),不能当万能参数袋用。详见第 07 节。

traceID := ctx.Value("traceID")   // ⚠️ 不建议用字符串做 key,会冲突
traceID := ctx.Value(traceKey{})      // ✅ 用自定义类型做 key,类型安全

04四种构造方式与 Context 树

Background/TODO 是根,WithCancel/WithTimeout/WithValue 是派生——它们构成一棵树

Background() / TODO() 根节点 · 永不取消 · Done() 返回 nil WithCancel 手动取消 · 需要 cancel() WithTimeout / Deadline 超时自动取消 · 也需 cancel() WithValue 只挂数据 · 无取消能力 WithTimeout(3s) 子超时 ≤ 父,可更严 WithValue(traceID) 继承父的取消能力 DB 查询 ctx 超时自动回滚连接 RPC 调用 ctx 透传到下游服务 WithValue(userID) ⚠️ 无取消能力 取消信号:父 → 子,单向级联传播 父被取消 ⇒ 所有子孙全部被取消;子被取消 ⇒ 父和兄弟节点不受影响 ⚠️ WithValue 不继承取消能力?—— 错,它继承,但它自己不新增取消能力 WithValue(parent, k, v) 返回的 ctx 仍会随 parent 取消;只是它没有自己的 cancel 函数
Context 树:根节点不可取消,派生节点继承父的取消信号并可追加自己的取消条件
函数用途是否需 cancel典型场景
Background()根 Context,永不取消、无值、无 deadlinemain 函数、初始化、测试代码的起点
TODO()语义同 Background,但表示「还不确定该用哪个」重构过渡期,提醒后续补上(静态检查可告警)
WithCancel(parent)派生一个可手动取消的子 Context手动控制生命周期,如启动/停止后台任务
WithTimeout(parent, d)派生一个 d 时间后自动取消的子 Context网络请求、RPC 调用、任何「最多等 N 秒」
WithDeadline(parent, t)派生一个到 t 时刻自动取消的子 Context有明确绝对截止时间;或承接上游传来的 deadline
WithValue(parent, k, v)派生一个携带 k-v 的子 ContexttraceID、用户身份等请求域元数据
⚠️ 最重要的一条:cancel 必须调用

WithCancel / WithTimeout / WithDeadline 都会返回 cancel 函数。必须调用它,否则子 Context 会一直挂在父节点上,其关联的 goroutine 和定时器直到父 Context 取消时才释放——这就是内存泄漏。标准写法是 defer cancel()

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()   // ← 必须!即使超时已到,调用 cancel 也是无害且推荐的

Background vs TODO

❌ 滥用 TODO
// 已经确定要用根 ctx,却写 TODO
ctx := context.TODO()
// 静态检查工具会告警,且语义不清
✅ 各司其职
// 明确的起点用 Background
ctx := context.Background()
// 不确定/待重构处用 TODO 做标记
ctx := context.TODO()  // TODO: 接入请求 ctx

两者功能完全相同(都是空的 root ctx),区别只在语义和意图TODO 是给人和工具看的「这里还没想好」,便于后续检索补齐。

05取消传播机制

父取消 → 子全部取消;子取消 → 父不受影响。理解这一点就理解了 Context 的一半

调用 cancel() 之后发生的事 parent (cancelCtx) ① 调用 cancel() ② 关闭自己的 done channel close(c.done) —— 广播 所有 select 监听者同时唤醒 ③ 设置 err 字段 c.err = Canceled Err() 从此返回非 nil ④ 遍历 children 对每个子节点递归 调用 child.cancel() ⑤ 从父节点摘除自己 removeChild(parent, c) 断开引用,可被 GC child A 被取消 done 关闭 child B 被取消 done 关闭 child C 被取消 done 关闭 反向不成立:child.cancel() 只影响自己和它的子孙,parent 与兄弟节点完全不受影响 这正是「局部超时不影响整体」得以实现的原因:给某个子调用加 WithTimeout,取消它不会波及整条链路
取消传播:关闭 done channel 广播 → 递归取消所有子孙 → 从父节点摘除

代码验证:取消一个子 Context 不影响父

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    parent, parentCancel := context.WithCancel(context.Background())
    defer parentCancel()

    child, childCancel := context.WithCancel(parent)

    childCancel()  // 只取消子 Context

    fmt.Println("child  err:", child.Err())    // context.Canceled
    fmt.Println("parent err:", parent.Err())   // nil ← 父不受影响!

    // 反过来:取消父,子会跟着取消
    parent2, parent2Cancel := context.WithCancel(context.Background())
    child2, _ := context.WithCancel(parent2)
    parent2Cancel()

    fmt.Println("child2 err:", child2.Err())   // context.Canceled ← 被父级联取消
}
// 输出:
// child  err: context.Canceled
// parent err: <nil>
// child2 err: context.Canceled
💡 实用推论:给子调用加独立超时

因为「子取消不影响父」,我们可以给某个具体的子调用单独加更短的超时,而不用担心它会连带取消整条链路。这是精细化超时控制的基础。

// 整体请求 5s,但查缓存最多只允许 200ms
cacheCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
if v, err := cache.Get(cacheCtx, key); err == nil {
    return v  // 命中缓存
}
// 缓存超时/未命中 → 继续走 DB,此时 ctx(父)依旧有效,没有被取消
return db.Query(ctx, sql)

06WithTimeout 与 WithDeadline

两者本质相同,只是一个给「多久之后」,一个给「什么时候」

WithTimeout(相对时间)

最常用。传一个 time.Duration,表示「从现在起多久后超时」。

ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()

内部实现就是 WithDeadline(parent, time.Now().Add(3s))

WithDeadline(绝对时间)

传一个 time.Time,表示「到这个时刻就超时」。

deadline := time.Now().Add(3 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

适合承接上游传来的 deadline,或跨多次调用共享同一截止时间。

⚠️ 一个重要细节:子 Deadline 晚于父时会被父"截断"

如果父 Context 的 deadline 是 3s 后,而你在子 Context 上设 10s,那么实际生效的是 3s——父先到就会级联取消子。Deadline() 返回的是继承后的最早时间,不是你自己设的那个。

parent, _ := context.WithTimeout(context.Background(), 3*time.Second)
child, _  := context.WithTimeout(parent, 10*time.Second)  // 设了 10s

dl, _ := child.Deadline()
fmt.Println(time.Until(dl))  // 约 3s,不是 10s!被父截断了

这是设计如此而非 bug:保证「总耗时不会超过外层的预算」。子超时只能比父更严格,不能更宽松。

实战:带超时的 HTTP 请求

func fetchWithTimeout(url string) ([]byte, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        // 超时时 err 会包装 context.DeadlineExceeded
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, fmt.Errorf("请求 %s 超时: %w", url, err)
        }
        return nil, err
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}
✅ 推荐:用 errors.Is 判断,不要用 ==

网络库通常会把 context.DeadlineExceeded 包装一层(*url.Error),直接 == 比较会失败。用 errors.Is(err, context.DeadlineExceeded) 才能穿透包装正确识别。

超时不应层层叠加:这是 WithTimeout 的核心价值

❌ 各自超时,时间叠加
// 每个函数自己 sleep/after
func A() { B(); C() }
func B() { wait(3*time.Second) }
func C() { wait(3*time.Second) }
// 实际最坏 6s,且无法整体控制
✅ 统一预算,共享截止
func A(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
    defer cancel()
    B(ctx); C(ctx)
}
// B、C 共享同一个 deadline,总耗时 ≤ 4s
// B 用完 3s,C 就只剩 1s

07WithValue:请求域数据的正确用法

这是最容易被滥用的 API —— 用对了很优雅,用错了是灾难

⚠️ 核心原则

Context 的 Value 只应用于「请求域」的、跨进程/跨 API 边界传递的元数据,且是「可选的」—— 即:即使取不到,程序也应该能正常工作(降级),而不是直接报错。函数的必需参数永远应该显式声明在函数签名里,不要塞进 Context。

❌ 反模式:当可选参数袋用
// 把业务参数塞进 ctx
ctx = context.WithValue(ctx, "userID", 123)
ctx = context.WithValue(ctx, "pageSize", 20)
ctx = context.WithValue(ctx, "sortBy", "time")

func ListOrders(ctx context.Context) {
    // 必须靠猜才知道要传什么,编译期无检查
    size := ctx.Value("pageSize").(int)  // 可能 panic
}
✅ 正确:只放请求域元数据
// 只放贯穿链路的元数据
ctx = context.WithValue(ctx, traceKey{}, "abc-123")
ctx = context.WithValue(ctx, userKey{}, "u-88")

// 业务参数显式声明
func ListOrders(ctx context.Context, pageSize int, sortBy string) {
    // 签名即文档,编译期检查
}

Key 的类型安全:不要用字符串!

用字符串做 key 会导致不同包之间的 key 冲突(都叫 "userID" 但含义不同)。标准做法:定义一个私有的、不可导出的类型作为 key。

// ✅ 推荐:定义不可导出的 key 类型 + 配套读写函数
package mypkg

type traceIDKey struct{}   // 空结构体,零内存分配,且类型唯一
type userKey struct{}

// 写入
func WithTraceID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, traceIDKey{}, id)
}

// 读取(类型安全,带 ok 判断)
func TraceIDFrom(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(traceIDKey{}).(string)
    return id, ok
}

// 使用方
ctx := WithTraceID(parent, "req-abc-123")
if id, ok := TraceIDFrom(ctx); ok {
    log.Println("trace:", id)
}
⚠️ 用类型断言时务必带 ok

ctx.Value(k).(string) 在 key 不存在或类型不符时会 panic。永远用 v, ok := ctx.Value(k).(T) 的双返回值形式。

Value 的查找机制:沿树向上

Value() 会先查自己,查不到就沿父链一路向上找,直到根节点。如果都没有,返回 nil。注意:不会向下查——父 Context 取不到子 Context 上设的值。

root  := context.WithValue(context.Background(), k1, "v1")
child := context.WithValue(root, k2, "v2")

child.Value(k2)  // "v2"  自己就有
child.Value(k1)  // "v1"  向上找到父
root.Value(k2)   // nil   不会向下找!
⚠️ 性能提示

因为要沿链向上查找,Value 的查找是 O(链深) 的,不是 O(1)。虽然 context 链通常很短(几层),但不要在热循环里频繁调用 Value——在循环外取一次存到局部变量。

traceID, _ := TraceIDFrom(ctx)   // ✅ 循环外取一次
for _, item := range items {
    log(traceID, item)
}
适合放 Value 的不适合放 Value 的
traceID / requestID / spanID函数的必需业务参数(userID 若业务必需则应显式传)
用户身份(认证后的 principal)可选的配置项(应走 options 模式或配置结构体)
客户端语言 / 时区 / 地区偏好数据库连接、客户端实例(应走依赖注入)
请求的 Accept-Encoding 等元数据可变状态(Context 应当是只读的)
日志所需的公共字段大对象(会一直挂在链上无法释放)

08源码实现原理

理解四种 Context 的具体实现,很多"为什么"就自然明白了

四种实现类型

类型由谁创建核心字段取消能力
emptyCtxBackground() / TODO()无(只是个 int 占位)永不取消,Done() 返回 nil
cancelCtxWithCancel()done chanchildren maperr可取消,是其他两者的基础
timerCtxWithTimeout/WithDeadline内嵌 cancelCtx + timerdeadline在 cancelCtx 基础上加定时器
valueCtxWithValue()keyvalparent无自己的取消,继承父

cancelCtx:取消机制的核心

// 简化版源码(Go 1.21+ 改用了 canceler 链表,但原理一致)
type cancelCtx struct {
    Context                              // 内嵌父 Context

    mu       sync.Mutex
    done     chan struct{}         // 懒创建,第一次 Done() 时才 make
    children map[canceler]struct{}   // ← 所有可取消的子节点
    err      error                 // 取消原因
}

func (c *cancelCtx) Done() <-chan struct{} {
    c.mu.Lock()
    if c.done == nil {
        c.done = make(chan struct{})
    }
    d := c.done
    c.mu.Unlock()
    return d
}

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    c.mu.Lock()
    if c.err != nil {                  // 幂等:已取消过就直接返回
        c.mu.Unlock()
        return
    }
    c.err = err                       // ③ 设置错误
    if c.done == nil {
        c.done = closedchan           // 复用一个已关闭的 channel
    } else {
        close(c.done)                 // ② 关闭 channel → 广播给所有监听者
    }
    for child := range c.children {   // ④ 递归取消所有子节点
        child.cancel(false, err)
    }
    c.children = nil

    if removeFromParent {             // ⑤ 从父节点摘除自己
        removeChild(c.Context, c)
    }
    c.mu.Unlock()
}
💡 三个设计细节值得注意
  • cancel 是幂等的:靠 c.err != nil 判断,重复调用不会 panic。所以 defer cancel() 即使超时已触发也绝对安全。
  • done 是懒创建的:只有第一次调用 Done() 时才 make(chan),没被监听的 Context 零开销。
  • children 是 map:这就是为什么必须调用 cancel —— 不调用的话,自己会一直留在父节点的 children 里,父节点就没法被 GC。

propagateCancel:把自己挂到父节点上

创建子 Context 时,Go 会调用 propagateCancel 决定「如何让自己能被父级取消」。这是理解 Context 树的关键函数:

func propagateCancel(parent Context, child canceler) {
    done := parent.Done()
    if done == nil {
        return   // ① 父不可取消(Background/TODO),无需挂靠
    }

    select {
    case <-done:
        // ② 父已经被取消了,立即取消自己
        child.cancel(false, parent.Err())
        return
    default:
    }

    // ③ 找到父链上最近的 *cancelCtx,把自己加入它的 children
    if p, ok := parentCancelCtx(parent); ok {
        p.mu.Lock()
        if p.err != nil {
            child.cancel(false, p.err)   // 检查竞态:父可能刚好被取消
        } else {
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}
        }
        p.mu.Unlock()
        return
    }

    // ④ 父是自定义 Context 实现:起一个 goroutine 转发取消信号
    go func() {
        select {
        case <-parent.Done():
            child.cancel(false, parent.Err())
        case <-child.Done():
        }
    }()
}
⚠️ 情况 ④ 会多起一个 goroutine

如果你自定义了 Context 实现(不内嵌 *cancelCtx),Go 无法直接把它挂进 children map,只能额外起一个 goroutine 来转发取消信号。这也是官方不建议自己实现 Context 的原因之一——优先用标准库提供的。

timerCtx:超时就是「定时器 + 取消」

type timerCtx struct {
    *cancelCtx              // 内嵌,复用取消能力
    timer    *time.Timer    // 到点触发的定时器
    deadline time.Time      // 绝对截止时间
}

func (c *timerCtx) cancel(removeFromParent bool, err error) {
    c.cancelCtx.cancel(false, err)   // 复用 cancelCtx 的取消逻辑
    if removeFromParent {
        removeChild(c.cancelCtx.Context, c)
    }
    c.mu.Lock()
    if c.timer != nil {
        c.timer.Stop()                // ← 停掉定时器,防止泄漏
        c.timer = nil
    }
    c.mu.Unlock()
}
⚠️ 这正是"必须 defer cancel()"的底层原因

即使超时已经触发(timer 已经 fire 过),底层的 timer 对象和父节点的 children 引用仍需清理。只有调用 cancel() 才会 timer.Stop()removeChild。不调用的话,在父 Context 生命周期结束前,这些资源都不释放——在长生命周期的父 Context 上反复创建短超时子 Context,就会累积泄漏。

09常用实战代码模式

生产代码里最常见的六种 Context 用法

① HTTP Server:请求自带 Context

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()   // ← 每个请求都有,客户端断开时自动取消

    // 客户端断开连接时,ctx 会被取消
    select {
    case <-time.After(5 * time.Second):
        fmt.Fprintln(w, "处理完成")
    case <-ctx.Done():
        log.Println("客户端断开,停止处理:", ctx.Err())
        return
    }
}

// 服务端也可以统一设置超时(用中间件)
func timeoutMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
        defer cancel()
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

② 数据库查询:超时自动取消

// database/sql 的 *Context 系列方法都会接收 ctx
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

var user User
err := db.QueryRowContext(ctx,
    "SELECT id, name FROM users WHERE id = ?", userID).Scan(&user.ID, &user.Name)

if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        // 查询超时:连接会被自动归还,已执行的语句在服务端被 KILL
        return fmt.Errorf("查询用户超时")
    }
    return err
}
✅ 为什么一定要用 QueryContext 而不是 Query

QueryRowContext 在 ctx 取消时会:① 立即返回,不再等待② 把连接标记为需要清理并归还连接池。用不带 Context 的 QueryRow 则会一直阻塞到数据库返回,请求已经超时了但连接还被占着,高并发下连接池迅速耗尽。

③ 超时重试:每次重试独立的超时预算

func callWithRetry(ctx context.Context, url string, maxRetry int) ([]byte, error) {
    var lastErr error
    for i := 0; i < maxRetry; i++ {
        // 每次重试单独 2s 超时,但都受外层 ctx 总预算约束
        attemptCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
        data, err := doRequest(attemptCtx, url)
        cancel()   // ← 循环内不能用 defer!立即调用

        if err == nil {
            return data, nil
        }
        lastErr = err

        // 外层已取消(总预算耗尽或用户放弃),不再重试
        if ctx.Err() != nil {
            return nil, ctx.Err()
        }

        // 退避等待,且等待过程也可被取消
        select {
        case <-time.After(time.Duration(i+1) * 500 * time.Millisecond):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, lastErr
}
⚠️ 循环里绝对不要用 defer cancel()

defer 要到函数返回时才执行。在循环里 defer cancel() 会导致本轮迭代的 Context 一直不释放,累积泄漏。循环内必须显式立即调用 cancel()

④ 控制后台 goroutine 的生命周期

// 启一个可被停止的后台任务
func startWorker(ctx context.Context) {
    go func() {
        ticker := time.NewTicker(1 * time.Second)
        defer ticker.Stop()

        for {
            select {
            case <-ticker.C:
                doWork()
            case <-ctx.Done():
                log.Println("worker 收到停止信号,退出")
                return   // ← 干净退出,goroutine 结束
            }
        }
    }()
}

// 使用:想停的时候调用 cancel
ctx, cancel := context.WithCancel(context.Background())
startWorker(ctx)
// ... 运行一段时间 ...
cancel()   // worker 优雅退出

⑤ 等待一组 goroutine(配合 errgroup)

import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, ids []string) ([]Item, error) {
    g, ctx := errgroup.WithContext(ctx)   // ← 任一子任务失败,ctx 自动取消
    items := make([]Item, len(ids))

    for i, id := range ids {
        i, id := i, id   // Go 1.22+ 不需要这行
        g.Go(func() error {
            item, err := fetchOne(ctx, id)   // 传入同一个 ctx
            if err != nil {
                return err
            }
            items[i] = item
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err   // 其他 goroutine 已因 ctx 取消而退出
    }
    return items, nil
}

errgroup.WithContext 返回的 ctx 在第一个子任务返回 error 时自动取消,其他并行任务会立即收到信号退出——这就是「快速失败」(fail-fast),避免一个失败还傻等其他的。

⑥ 链路追踪:traceID 贯穿全链路

// 中间件:入口处生成 traceID 注入 ctx
func traceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = generateID()
        }
        ctx := WithTraceID(r.Context(), traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// 任意深度的调用处都能取到,无需层层传参
func repository.Find(ctx context.Context, id int) {
    traceID, _ := TraceIDFrom(ctx)
    log.Printf("[trace=%s] 查询 id=%d", traceID, id)
    // 调用下游 RPC 时也把 ctx 传过去,traceID 就实现了跨服务串联
    rpc.Call(ctx, ...)
}

10最佳实践清单

这 8 条是 Go 官方和社区的共识,背下来能避开 90% 的坑

1️⃣ Context 作为函数的第一个参数

// ✅ 官方约定:ctx 是第一个参数,命名统一为 ctx
func DoSomething(ctx context.Context, arg Arg) error

// ❌ 不要放后面,不要改名
func DoSomething(arg Arg, ctx context.Context) error   // 不推荐

2️⃣ 永远不要传 nil Context

如果确实不确定用哪个,传 context.TODO(),而不是 nil。传 nil 会导致 ctx.Done() 等方法直接 panic。

// ❌ 危险
doWork(nil, arg)      // panic: nil pointer dereference

// ✅ 不确定时用 TODO
doWork(context.TODO(), arg)

3️⃣ 不要将 Context 存在结构体里

Context 应当随调用流动(flow through),而不是被存储。存进结构体等于把一个「请求生命周期」的对象绑定到了一个可能长期存活的对象上,会造成生命周期错乱和数据串号。

❌ 存在结构体
type Service struct {
    ctx context.Context  // 危险!
}
// 这个 Service 常驻内存,但 ctx 早就该结束了
✅ 随参数传递
type Service struct {
    db *sql.DB   // 只存真正的依赖
}
func (s *Service) Get(ctx context.Context, id int) {
    s.db.QueryContext(ctx, ...)
}

4️⃣ 总是 defer cancel()

前面已强调过,这是最重要的一条WithCancel/WithTimeout/WithDeadline 返回的 cancel 必须调用。

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()     // ✅ 函数内:defer

// 循环内:显式立即调用(不能用 defer)
for i := 0; i < n; i++ {
    c, cancel := context.WithTimeout(ctx, time.Second)
    use(c)
    cancel()      // ✅ 立即
}

5️⃣ Context 是只读的,不要往里塞可变状态

Context 设计为不可变(immutable)——每次 WithXXX 都是返回一个新的 Context,而不是修改原来的。往 Value 里放指针并修改其指向的对象,会破坏并发安全性。

6️⃣ 不要把必需参数塞进 Value

见第 07 节。一句话判断标准:「如果取不到这个值,函数应该报错还是能继续?」能继续 → 可以放 Value;必须报错 → 应该显式声明为参数。

7️⃣ 派生 Context 时保持"总预算"意识

子 Context 的超时会被父截断,所以设计超时时要考虑整体预算:入口设一个总超时(如 10s),各子调用在此基础上分配(DB 3s、RPC 2s…),而不是各自独立设 10s。

8️⃣ 传给下游时原样传递,不要自己造一个新的

调用下游服务/库时,把当前 ctx 原样传下去,而不是新建一个 Background()。否则上游的取消信号在这里断裂——用户已经关掉页面了,下游还在傻干。

// ❌ 信号断裂
func handler(w, r) {
    ctx := context.Background()   // 丢弃了 r.Context()
    db.QueryContext(ctx, ...)
}

// ✅ 原样传递
func handler(w, r) {
    db.QueryContext(r.Context(), ...)
}

11常见陷阱与错误

这些错误在生产环境都真实发生过,每一个的代价都不小

陷阱后果正确做法
忘记调用 cancel() 子 Context 挂在父的 children 里不释放,timer 不停止 → 内存与 goroutine 泄漏 defer cancel();循环内显式立即调用
循环内用 defer cancel() defer 到函数返回才执行,循环 N 次就泄漏 N 个 循环体内直接 cancel(),不用 defer
传 nil Context 调用方法时直接 panic context.TODO() 占位
Context 存在 struct 里 生命周期错乱,长生命周期对象持有已结束的 ctx;并发下数据串号 作为方法参数传递
用 Value 传必需参数 编译期无检查,重构时漏改;.(T) 断言 panic;代码可读性极差 必需参数显式声明;Value 只放可选元数据
用字符串做 Value 的 key 不同包 key 冲突,互相覆盖 定义不可导出的 key 类型 + 配套读写函数
Value 类型断言不带 ok key 不存在或类型不符时 panic v, ok := ctx.Value(k).(T)
子 goroutine 用 Background 另起炉灶 取消信号断裂,父取消时子 goroutine 还在跑 → 泄漏 把父 ctx 传进去
把 ctx 存起来异步使用 请求已结束、ctx 已取消,异步任务一起被取消,或读到过期数据 异步任务用新的 context.Background() + 自己的超时
用 == 比较超时错误 错误被包装(*url.Error)后判断失败,超时处理逻辑失效 errors.Is(err, context.DeadlineExceeded)
热循环里反复 Value() Value 沿链向上查找是 O(链深),高频调用有性能损耗 循环外取一次存局部变量
以为 cancel 能"杀掉"goroutine Context 是协作式的,不响应 Done() 的 goroutine 照样泄漏 所有阻塞点都要 select 监听 ctx.Done()
⚠️ 特别提醒:异步任务不能用请求 ctx

这是最常见也最隐蔽的坑。HTTP handler 里起了个 goroutine 做异步任务(如发通知),并且直接用了 r.Context()。结果请求一返回,ctx 就被取消,异步任务跟着挂掉(或者更糟:任务执行一半被中断)。

// ❌ 错误:用了请求 ctx,请求结束任务就被取消
go sendNotification(r.Context(), msg)

// ✅ 正确:异步任务用新 ctx,自带超时,脱离请求生命周期
go func() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    sendNotification(ctx, msg)
}()

12常见问题

点击展开

Q1: Context 能"杀死"一个 goroutine 吗?
不能。Go 没有提供从外部强制终止 goroutine 的机制(这是刻意的设计)。Context 是协作式的:它只负责发出「你应该停了」的信号,goroutine 必须自己检查 ctx.Done() 并主动 return。如果一个 goroutine 在跑纯计算、从不检查 Done,那它永远不会因 ctx 取消而停止。所以写循环/阻塞代码时,务必在阻塞点加上 select { case <-ctx.Done(): return }
Q2: 为什么 Done() 返回的是 channel 关闭,而不是发送一个值?
为了实现广播。关闭一个 channel 会让所有在其上阻塞接收的 goroutine 同时被唤醒;而发送一个值只能被一个接收者拿到。取消语义需要的是「通知所有人在同一时刻停止」,所以必须用 close。这也是为什么 Done() 的类型是 <-chan struct{}——空结构体零内存开销,我们只关心「是否关闭」这个事件,不关心传什么值。
Q3: 超时已经触发了,还需要调用 cancel 吗?
需要,而且必须。超时触发只是「这个 Context 被取消了」,但底层的 time.Timer 对象和它在父节点 children map 里的引用仍在。只有调用 cancel() 才会执行 timer.Stop()removeChild()。另外 cancel 是幂等的(内部靠 err != nil 判断),重复调用完全安全。所以 defer cancel() 永远是正确写法。
Q4: 子 Context 设了 10s,父是 3s,实际超时是多少?
3s。子 Context 的 deadline 会被父截断——Deadline() 返回的是继承链上最早的那个时间。这是设计如此:保证「整体耗时不会超出外层预算」。推论:子超时只能比父更严格,无法更宽松。如果你想让某个子任务跑得比父更久,那它就不应该用父的 ctx 派生,而应该另起一个独立的 Context(但通常这说明设计有问题)。
Q5: 取消子 Context 会影响父吗?
不会。取消是单向向下传播的:父取消 → 所有子孙全部取消;子取消 → 仅自己和自己的子孙被取消,父和兄弟节点完全不受影响。这个特性正是「给单个子调用加独立超时」得以实现的基础——比如整体 5s 预算下,给缓存查询单独设 200ms,缓存超时不会连带取消整个请求。
Q6: 什么时候该用 WithValue?
判断标准:「如果取不到这个值,函数应该报错还是能继续?」能继续(降级)→ 适合放 Value,如 traceID(取不到就不打日志)、用户语言偏好(取不到用默认)。必须报错 → 应该显式声明为函数参数,如订单 ID、分页大小。另外 Value 只适合放请求域的、贯穿调用链的元数据,不要放数据库连接、配置对象或大对象(会一直挂在链上无法释放)。
Q7: 为什么官方建议不要自己实现 Context 接口?
两个原因:① 自定义实现无法直接挂进 *cancelCtxchildren map,propagateCancel 会退化到「起一个额外 goroutine 转发取消信号」,有额外开销;② 自定义实现容易违反「并发安全」「不可变」等隐含契约,埋下隐患。除非非常清楚自己在做什么,否则一律用标准库提供的 WithCancel/WithTimeout/WithValue 组合。
Q8: Background 和 TODO 到底有什么区别?
功能上完全一样——都是不可取消、无值、无 deadline 的空根节点,底层都是 emptyCtx。区别只在语义Background 表示「我明确这里就是起点」;TODO 表示「我还不确定该用哪个 Context,或者这里还没改造完」。用 TODO 便于后续检索补齐,也被静态检查工具(如 go vet 的部分 linter)用来提示。生产代码里如果长期留着 TODO,说明该重构了。
Q9: 忘记 cancel 到底泄漏了什么?
泄漏两样东西:① timerCtx 的 time.Timer——不调用 cancel 就不会 timer.Stop(),定时器会一直存在到触发;② 父节点 children map 里的引用——子 Context 不从 map 里摘除,父节点就一直持有它,整条链都无法被 GC。如果父是 Background()(永不取消),那么这些资源永远不释放——在长跑服务里每秒泄漏一个,几小时就能耗尽内存。
Q10: 怎么检测项目里的 Context 泄漏?
三种手段:① 静态检查:用 go vet -lostcancel(官方自带!)能直接查出「创建了 cancel 但可能没被调用」的代码;② 运行时监控:暴露 runtime.NumGoroutine() 到监控,goroutine 数持续增长就是信号;③ pprof:抓 /debug/pprof/goroutine 看堆积在哪个调用栈上,泄漏的 goroutine 通常卡在同一个 select 或 channel 操作上。