Skip to content

Go Context ​

context.Context 是 Go 服务端开发里非常核心的能力,用来在一条调用链中传递取消信号、超时时间、截止时间和少量请求级数据。Web 服务、数据库访问、HTTP/RPC 调用、后台 goroutine 都应该正确传递 Context。

很多初学者把 Context 当成“万能参数包”,这是错误的。Context 的核心不是传业务参数,而是控制生命周期:请求取消了,下游也要尽快停;接口超时了,数据库查询和外部调用不能继续占着资源。

学习目标 ​

学完本页你应该能回答:

  1. Context 解决什么问题。
  2. Background、TODO、WithCancel、WithTimeout、WithDeadline、WithValue 分别怎么用。
  3. Context 的取消信号如何向下游传播。
  4. 为什么创建带取消的 Context 后必须调用 cancel()。
  5. Context 和 goroutine 泄漏有什么关系。
  6. Web API、数据库、HTTP 客户端中如何正确传 Context。
  7. WithValue 能放什么,不能放什么。
  8. 线上超时、请求取消、goroutine 堆积怎么排查。

为什么需要 Context ​

先看一个没有 Context 的问题。

用户请求接口 /report,后端开始生成报告。生成过程中调用数据库、调用外部系统、启动 goroutine。如果用户关闭浏览器或网关已经超时,后端还继续执行,结果会是:

  1. 数据库查询继续占用连接。
  2. 外部接口继续占用网络资源。
  3. goroutine 继续运行,可能堆积。
  4. 结果没人接收,资源白白消耗。
  5. 高并发下服务越来越慢,甚至崩溃。

Context 的价值就是把“请求已经结束”这件事告诉整条调用链。

mermaid
flowchart TD
    A["HTTP 请求进入"] --> B["生成请求 Context"]
    B --> C["Handler"]
    C --> D["Service"]
    D --> E["Repository"]
    D --> F["外部 HTTP/RPC"]
    E --> G["数据库查询"]
    B --> H{"客户端取消或超时"}
    H -- "是" --> I["Context Done 关闭"]
    I --> D
    I --> E
    I --> F
    I --> G

Context 接口长什么样 ​

Go 标准库中 Context 的核心接口:

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

每个方法的含义:

方法含义
Deadline()返回截止时间,如果没有截止时间,ok=false
Done()返回一个 channel,Context 被取消或超时时会关闭
Err()返回取消原因,例如 context.Canceled 或 context.DeadlineExceeded
Value()获取请求级数据

最重要的是 Done()。它不是传数据用的,而是用“channel 关闭”表达取消信号。

根 Context:Background 和 TODO ​

go
ctx := context.Background()

Background() 通常用于:

  1. main 函数。
  2. 初始化逻辑。
  3. 测试。
  4. 没有上游 Context 的根节点。
go
ctx := context.TODO()

TODO() 表示“这里暂时不知道应该用哪个 Context”。它通常是迁移代码时的过渡方案,不建议长期大量存在。

选择建议:

方法使用场景
context.Background()明确这是根 Context
context.TODO()暂时还没想好,后续要替换

WithCancel:手动取消 ​

WithCancel 创建一个可以手动取消的子 Context。

go
package main

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

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

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go worker(ctx)

    time.Sleep(time.Second)
    cancel()
    time.Sleep(500 * time.Millisecond)
}

执行过程:

mermaid
flowchart TD
    A["创建 ctx 和 cancel"] --> B["启动 worker goroutine"]
    B --> C["worker 循环 select"]
    C --> D{"ctx.Done 是否关闭"}
    D -- "否" --> E["继续工作"]
    E --> C
    D -- "是" --> F["读取 ctx.Err 并退出"]
    A --> G["main 调用 cancel"]
    G --> D

如果不调用 cancel(),worker 不知道应该停,可能造成 goroutine 泄漏。

WithTimeout:超时取消 ​

WithTimeout 是最常用的 Context 派生方法之一。

go
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

含义:这个 Context 最多存活 2 秒,时间到了会自动取消。

示例:

go
func slowCall(ctx context.Context) error {
    select {
    case <-time.After(3 * time.Second):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    err := slowCall(ctx)
    fmt.Println(err) // context deadline exceeded
}

为什么还要 defer cancel()?
即使超时时间最终会到,提前调用 cancel() 也能释放定时器等资源。养成习惯:只要拿到了 cancel,就在合适位置调用。

WithDeadline:截止时间 ​

WithDeadline 和 WithTimeout 很像,只是它指定的是具体时间点。

go
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

关系:

text
WithTimeout(parent, 5*time.Second)
约等于
WithDeadline(parent, time.Now().Add(5*time.Second))

如果你有明确的业务截止时间,例如“必须在订单支付倒计时结束前完成”,可以使用 Deadline。

取消如何向下传播 ​

Context 是树状结构。父 Context 被取消,所有子 Context 都会被取消。

mermaid
flowchart TD
    A["root ctx"] --> B["request ctx"]
    B --> C["db ctx"]
    B --> D["rpc ctx"]
    D --> E["retry ctx"]
    B --> F["log ctx"]
    A -- "取消" --> B
    B -- "级联取消" --> C
    B -- "级联取消" --> D
    D -- "级联取消" --> E

示例:

go
root, cancel := context.WithCancel(context.Background())
child, childCancel := context.WithTimeout(root, 10*time.Second)
defer childCancel()

cancel()
fmt.Println(child.Err()) // context canceled

这里父 Context 被取消后,子 Context 也会取消。反过来,子 Context 取消不会取消父 Context。

函数签名规范 ​

Go 约定:Context 通常作为函数第一个参数。

go
func GetAsset(ctx context.Context, assetID int64) (*Asset, error) {
    // ...
}

不要这样:

go
func GetAsset(assetID int64, ctx context.Context) (*Asset, error) {
    // 不符合 Go 常见约定
}

也不要把 Context 存进结构体作为长期字段:

go
type Service struct {
    ctx context.Context // 通常不推荐
}

Context 是请求级生命周期,不是服务级依赖。每次请求都应该传自己的 Context。

select 监听取消 ​

长时间运行的 goroutine 必须监听 ctx.Done()。

go
func consume(ctx context.Context, jobs <-chan int) {
    for {
        select {
        case <-ctx.Done():
            return
        case job, ok := <-jobs:
            if !ok {
                return
            }
            fmt.Println("handle job", job)
        }
    }
}

如果只写:

go
for job := range jobs {
    handle(job)
}

当 jobs 没关闭时,goroutine 会一直阻塞,无法响应请求取消。

Web API 中使用 Context ​

标准库 net/http 的每个请求都有 Context。

go
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    asset, err := service.GetAsset(ctx, 1001)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(asset)
}

如果客户端断开连接,r.Context() 会被取消。下游 Service、Repository、HTTP Client 如果都传这个 Context,就可以尽快停止工作。

数据库中使用 Context ​

Go database/sql 提供了带 Context 的方法:

go
func FindAsset(ctx context.Context, db *sql.DB, id int64) (*Asset, error) {
    row := db.QueryRowContext(
        ctx,
        "select id, name from assets where id = ?",
        id,
    )

    var asset Asset
    if err := row.Scan(&asset.ID, &asset.Name); err != nil {
        return nil, err
    }
    return &asset, nil
}

不要使用不带 Context 的 QueryRow、Query、Exec,否则请求超时后数据库查询可能还在继续。

事务也有 Context:

go
tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

if _, err := tx.ExecContext(ctx, "insert into assets(name) values(?)", name); err != nil {
    return err
}

return tx.Commit()

注意:Context 取消不等于所有数据库都能立刻中断 SQL,具体行为依赖驱动和数据库。但应用层必须正确传递 Context。

HTTP 客户端中使用 Context ​

调用外部服务时也要传 Context。

go
func CallRemote(ctx context.Context, url string) error {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return err
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    return nil
}

如果上游请求取消,外部 HTTP 调用也能收到取消信号。

生产中还要配置 http.Client 的超时和连接池,不要只依赖 Context:

go
client := &http.Client{
    Timeout: 5 * time.Second,
}

Context 控制单次调用生命周期,Client Timeout 是客户端整体保护,二者不是互相替代。

WithValue 怎么用 ​

WithValue 用于传递请求级数据,例如 request_id、trace_id、当前用户 ID。不要用它传普通业务参数。

推荐定义私有 key 类型,避免 key 冲突:

go
type contextKey string

const requestIDKey contextKey = "requestID"

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

func RequestIDFrom(ctx context.Context) string {
    value, _ := ctx.Value(requestIDKey).(string)
    return value
}

不要这样:

go
ctx = context.WithValue(ctx, "user", user)

问题:

  1. 字符串 key 可能和其他包冲突。
  2. 大对象放 Context 会增加内存占用。
  3. 业务参数隐藏在 Context 里,可读性差。

WithValue 适合“穿透式元数据”,不适合“函数真正需要的业务入参”。

超时分层设计 ​

一个 Web 请求可能有整体超时,也可能有下游局部超时。

mermaid
flowchart TD
    A["HTTP 请求整体 3 秒"] --> B["数据库查询 800ms"]
    A --> C["外部 HTTP 1 秒"]
    A --> D["缓存查询 100ms"]
    C --> E["重试一次仍受整体超时限制"]

示例:

go
func Handle(ctx context.Context) error {
    dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer dbCancel()
    if err := queryDB(dbCtx); err != nil {
        return err
    }

    remoteCtx, remoteCancel := context.WithTimeout(ctx, time.Second)
    defer remoteCancel()
    return callRemote(remoteCtx)
}

原则:

  1. 子超时不能比父超时更有意义。父 Context 取消时,子 Context 一定取消。
  2. 每个慢依赖都要有自己的超时预算。
  3. 超时要按业务 SLA 设计,不要随便写很大的数。

常见错误 ​

错误后果正确做法
不传 Context请求取消后下游还在跑函数第一个参数传 ctx
创建 cancel 不调用定时器和资源释放不及时defer cancel()
把业务参数塞 Context代码可读性差、依赖隐式业务参数显式传参
Context 存结构体长期复用不同请求生命周期混乱每次请求传自己的 ctx
goroutine 不监听 Done请求结束后还在运行select 监听 ctx.Done()
只依赖 Context 不配 Client Timeout部分网络场景保护不足Context + client 超时一起用

商业场景:采集 Agent 停止任务 ​

采集 Agent 从医院系统批量拉取数据。如果平台下发“停止任务”,Agent 必须让正在执行的 goroutine 尽快停下来。

go
func RunCollectJob(ctx context.Context, ids []int64, repo Repository) error {
    for _, id := range ids {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }

        if err := repo.CollectOne(ctx, id); err != nil {
            return err
        }
    }
    return nil
}

如果不传 Context,停止任务只能等所有数据采完。数据量大时,这会导致任务无法及时停止、下游接口持续被打、数据库连接长时间占用。

线上排查 ​

请求超时 ​

mermaid
flowchart TD
    A["请求超时"] --> B["查看 access log cost_ms"]
    B --> C["按 request_id 查链路"]
    C --> D{"ctx.Err 是什么"}
    D -- "DeadlineExceeded" --> E["超时预算不足或下游慢"]
    D -- "Canceled" --> F["客户端取消或上游取消"]
    E --> G["拆数据库 HTTP RPC 耗时"]
    F --> H["确认网关 客户端 负载均衡超时"]

goroutine 泄漏 ​

排查步骤:

  1. 看 goroutine 数是否持续增长。
  2. 用 pprof 查看 goroutine 堆栈。
  3. 看是否卡在 channel send/receive、锁、网络 IO、数据库等待。
  4. 检查 goroutine 是否监听 ctx.Done()。
  5. 检查 channel 是否关闭、cancel 是否调用。

典型 pprof 堆栈可能显示大量 goroutine 卡在:

text
select
chan receive
net/http.(*persistConn).roundTrip
database/sql.(*DB).conn

这时要回到代码确认超时、取消、连接池和下游状态。

面试标准回答 ​

Context 是什么?
Context 用于在 Go 调用链中传递取消信号、超时时间、截止时间和少量请求级值。它的核心是控制请求生命周期,让下游数据库、RPC、HTTP 调用和 goroutine 在上游取消或超时时尽快停止工作。

为什么创建 Context 后要调用 cancel?
WithCancel、WithTimeout、WithDeadline 会返回 cancel 函数。调用 cancel 可以关闭 Done channel,通知下游停止,并释放定时器等资源。即使超时时间最终会到,也应该 defer cancel()。

Context 可以传业务参数吗?
不建议。Context 适合传 request_id、trace_id、用户 ID 这类请求级元数据,不适合传真正的业务参数。业务参数应该显式写在函数参数里,否则依赖关系隐藏,代码可读性和可测试性会变差。

Context 如何避免 goroutine 泄漏?
长时间运行的 goroutine 应该在 select 中监听 ctx.Done()。当请求取消、超时或任务停止时,goroutine 收到取消信号并退出。如果不监听 Done,goroutine 可能一直阻塞在 channel、IO 或循环中。

关联知识点 ​

小结 ​

Context 是 Go 生产项目里的“生命周期控制线”。它不是万能参数容器,而是让请求取消、超时和链路元数据能被下游感知。写 Go Web、数据库、RPC、后台任务时,必须习惯把 Context 作为第一个参数传递,并让慢操作和 goroutine 正确响应取消。