Go Context
context.Context 是 Go 服务端开发里非常核心的能力,用来在一条调用链中传递取消信号、超时时间、截止时间和少量请求级数据。Web 服务、数据库访问、HTTP/RPC 调用、后台 goroutine 都应该正确传递 Context。
很多初学者把 Context 当成“万能参数包”,这是错误的。Context 的核心不是传业务参数,而是控制生命周期:请求取消了,下游也要尽快停;接口超时了,数据库查询和外部调用不能继续占着资源。
学习目标
学完本页你应该能回答:
- Context 解决什么问题。
Background、TODO、WithCancel、WithTimeout、WithDeadline、WithValue分别怎么用。- Context 的取消信号如何向下游传播。
- 为什么创建带取消的 Context 后必须调用
cancel()。 - Context 和 goroutine 泄漏有什么关系。
- Web API、数据库、HTTP 客户端中如何正确传 Context。
WithValue能放什么,不能放什么。- 线上超时、请求取消、goroutine 堆积怎么排查。
为什么需要 Context
先看一个没有 Context 的问题。
用户请求接口 /report,后端开始生成报告。生成过程中调用数据库、调用外部系统、启动 goroutine。如果用户关闭浏览器或网关已经超时,后端还继续执行,结果会是:
- 数据库查询继续占用连接。
- 外部接口继续占用网络资源。
- goroutine 继续运行,可能堆积。
- 结果没人接收,资源白白消耗。
- 高并发下服务越来越慢,甚至崩溃。
Context 的价值就是把“请求已经结束”这件事告诉整条调用链。
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 --> GContext 接口长什么样
Go 标准库中 Context 的核心接口:
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
ctx := context.Background()Background() 通常用于:
main函数。- 初始化逻辑。
- 测试。
- 没有上游 Context 的根节点。
ctx := context.TODO()TODO() 表示“这里暂时不知道应该用哪个 Context”。它通常是迁移代码时的过渡方案,不建议长期大量存在。
选择建议:
| 方法 | 使用场景 |
|---|---|
context.Background() | 明确这是根 Context |
context.TODO() | 暂时还没想好,后续要替换 |
WithCancel:手动取消
WithCancel 创建一个可以手动取消的子 Context。
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)
}执行过程:
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 派生方法之一。
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()含义:这个 Context 最多存活 2 秒,时间到了会自动取消。
示例:
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 很像,只是它指定的是具体时间点。
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()关系:
WithTimeout(parent, 5*time.Second)
约等于
WithDeadline(parent, time.Now().Add(5*time.Second))如果你有明确的业务截止时间,例如“必须在订单支付倒计时结束前完成”,可以使用 Deadline。
取消如何向下传播
Context 是树状结构。父 Context 被取消,所有子 Context 都会被取消。
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示例:
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 通常作为函数第一个参数。
func GetAsset(ctx context.Context, assetID int64) (*Asset, error) {
// ...
}不要这样:
func GetAsset(assetID int64, ctx context.Context) (*Asset, error) {
// 不符合 Go 常见约定
}也不要把 Context 存进结构体作为长期字段:
type Service struct {
ctx context.Context // 通常不推荐
}Context 是请求级生命周期,不是服务级依赖。每次请求都应该传自己的 Context。
select 监听取消
长时间运行的 goroutine 必须监听 ctx.Done()。
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)
}
}
}如果只写:
for job := range jobs {
handle(job)
}当 jobs 没关闭时,goroutine 会一直阻塞,无法响应请求取消。
Web API 中使用 Context
标准库 net/http 的每个请求都有 Context。
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 的方法:
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:
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。
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:
client := &http.Client{
Timeout: 5 * time.Second,
}Context 控制单次调用生命周期,Client Timeout 是客户端整体保护,二者不是互相替代。
WithValue 怎么用
WithValue 用于传递请求级数据,例如 request_id、trace_id、当前用户 ID。不要用它传普通业务参数。
推荐定义私有 key 类型,避免 key 冲突:
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
}不要这样:
ctx = context.WithValue(ctx, "user", user)问题:
- 字符串 key 可能和其他包冲突。
- 大对象放 Context 会增加内存占用。
- 业务参数隐藏在 Context 里,可读性差。
WithValue 适合“穿透式元数据”,不适合“函数真正需要的业务入参”。
超时分层设计
一个 Web 请求可能有整体超时,也可能有下游局部超时。
flowchart TD
A["HTTP 请求整体 3 秒"] --> B["数据库查询 800ms"]
A --> C["外部 HTTP 1 秒"]
A --> D["缓存查询 100ms"]
C --> E["重试一次仍受整体超时限制"]示例:
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)
}原则:
- 子超时不能比父超时更有意义。父 Context 取消时,子 Context 一定取消。
- 每个慢依赖都要有自己的超时预算。
- 超时要按业务 SLA 设计,不要随便写很大的数。
常见错误
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 不传 Context | 请求取消后下游还在跑 | 函数第一个参数传 ctx |
| 创建 cancel 不调用 | 定时器和资源释放不及时 | defer cancel() |
| 把业务参数塞 Context | 代码可读性差、依赖隐式 | 业务参数显式传参 |
| Context 存结构体长期复用 | 不同请求生命周期混乱 | 每次请求传自己的 ctx |
| goroutine 不监听 Done | 请求结束后还在运行 | select 监听 ctx.Done() |
| 只依赖 Context 不配 Client Timeout | 部分网络场景保护不足 | Context + client 超时一起用 |
商业场景:采集 Agent 停止任务
采集 Agent 从医院系统批量拉取数据。如果平台下发“停止任务”,Agent 必须让正在执行的 goroutine 尽快停下来。
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,停止任务只能等所有数据采完。数据量大时,这会导致任务无法及时停止、下游接口持续被打、数据库连接长时间占用。
线上排查
请求超时
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 泄漏
排查步骤:
- 看 goroutine 数是否持续增长。
- 用 pprof 查看 goroutine 堆栈。
- 看是否卡在 channel send/receive、锁、网络 IO、数据库等待。
- 检查 goroutine 是否监听
ctx.Done()。 - 检查 channel 是否关闭、cancel 是否调用。
典型 pprof 堆栈可能显示大量 goroutine 卡在:
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 正确响应取消。
