Skip to content

Go Context

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

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

学习目标

学完本页你应该能回答:

  1. Context 解决什么问题。
  2. BackgroundTODOWithCancelWithTimeoutWithDeadlineWithValue 分别怎么用。
  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.Canceledcontext.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:截止时间

WithDeadlineWithTimeout 很像,只是它指定的是具体时间点。

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 的 QueryRowQueryExec,否则请求超时后数据库查询可能还在继续。

事务也有 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?
WithCancelWithTimeoutWithDeadline 会返回 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 正确响应取消。