Go context 实战:超时、取消与请求链路
context.Context 用于在 API 边界之间传递截止时间、取消信号和少量请求级元数据。它解决的不是普通参数传递,而是让一条请求链上的 Goroutine、数据库查询和网络调用能够一起停止。
Context 的核心能力
Context 提供四类信息:
Deadline():任务必须结束的时间。Done():取消或超时后关闭的通道。Err():结束原因,通常是context.Canceled或context.DeadlineExceeded。Value():请求链路中的少量元数据。
根 Context 通常来自以下位置:
ctx := context.Background()
ctx := context.TODO()
ctx := r.Context()
应用启动和测试可使用 Background。HTTP Handler 应使用请求自带的 r.Context(),客户端断开时下游工作才能及时取消。TODO 只适合作为尚未完成 Context 设计时的临时标记。
四条 API 设计规则
- Context 放在函数第一个参数,并命名为
ctx。 - 不要把 Context 存入结构体长期持有。
- 不要传递 nil Context;不确定时使用
context.Background()。 - Context Value 只保存请求级元数据,不保存可选业务参数或服务依赖。
推荐形式:
func (s *Service) CreateOrder(ctx context.Context, input CreateOrderInput) (Order, error)
不推荐形式:
type Service struct {
ctx context.Context
}
结构体中的 Context 容易跨请求复用,导致生命周期和取消关系混乱。
主动取消任务
WithCancel 返回子 Context 和取消函数。调用方必须在不再需要任务时执行取消函数。
func run() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() {
result <- doWork(ctx)
}()
select {
case err := <-result:
return err
case <-ctx.Done():
return ctx.Err()
}
}
即使预计任务会正常完成,也应调用 cancel,这样运行时可以及时释放定时器和父子关系资源。
设置超时与截止时间
func FetchProfile(ctx context.Context, client *http.Client, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch profile: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return body, nil
}
超时应分层设置:
- 入口请求拥有总体预算。
- 数据库、缓存和下游 HTTP 调用使用更短的子预算。
- 子调用不能把父 Context 的截止时间延长。
固定使用 context.Background() 创建下游超时会切断父请求的取消传播,应基于传入的 ctx 派生。
响应取消信号
长循环和阻塞通道操作需要主动监听 Done:
func Process(ctx context.Context, jobs <-chan Job) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case job, ok := <-jobs:
if !ok {
return nil
}
if err := handle(ctx, job); err != nil {
return err
}
}
}
}
如果任务执行单步就需要很长时间,handle 内部也必须接受 Context 并传给阻塞操作。只在外层循环检查一次无法及时中断内部调用。
在数据库调用中传播 Context
database/sql 为查询和事务提供了 Context 版本 API:
func FindUser(ctx context.Context, db *sql.DB, id int64) (User, error) {
const query = `SELECT id, name, email FROM users WHERE id = ?`
var user User
err := db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Name,
&user.Email,
)
if err != nil {
return User{}, fmt.Errorf("query user %d: %w", id, err)
}
return user, nil
}
事务也应使用 BeginTx,并把同一个 Context 传给事务内调用:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, statement, args...); err != nil {
return err
}
return tx.Commit()
驱动必须支持相应的取消能力。即使数据库端不能立即终止查询,应用层仍应停止后续处理。
携带请求级元数据
Context Value 适合请求 ID、Trace ID 或认证后得到的主体标识。键应使用包内私有类型,避免不同包发生冲突。
type contextKey string
const requestIDKey contextKey = "request-id"
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey, requestID)
}
func RequestID(ctx context.Context) (string, bool) {
requestID, ok := ctx.Value(requestIDKey).(string)
return requestID, ok
}
不要使用 Value 传递数据库连接、Logger、配置或普通函数参数。显式依赖更容易测试和理解。
记录取消原因
Go 1.20 起可以使用 WithCancelCause 保留业务取消原因:
ctx, cancel := context.WithCancelCause(parent)
go func() {
if err := monitorDependency(ctx); err != nil {
cancel(fmt.Errorf("dependency unhealthy: %w", err))
}
}()
<-ctx.Done()
log.Printf("task stopped: %v", context.Cause(ctx))
ctx.Err() 仍返回通用的取消类别,context.Cause(ctx) 返回具体原因,适合日志和故障分析。
防止 Goroutine 泄漏
以下函数在没有接收者时可能永久阻塞:
func send(result chan<- Item, item Item) {
result <- item
}
加入取消分支:
func send(ctx context.Context, result chan<- Item, item Item) error {
select {
case result <- item:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
任何可能阻塞的发送、接收、锁等待或外部调用,都应有明确的终止条件。
服务优雅退出
signal.NotifyContext 可以把操作系统信号转换为 Context:
func main() {
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
server := &http.Server{
Addr: ":8080",
Handler: routes(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
收到终止信号后停止接收新请求,并给正在处理的请求有限时间完成。关闭过程使用新的超时 Context,因为信号 Context 此时已经取消。
测试超时和取消
测试不应依赖长时间 sleep。使用短超时和通道确认任务确实退出:
func TestProcess_Canceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
jobs := make(chan Job)
done := make(chan error, 1)
go func() {
done <- Process(ctx, jobs)
}()
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled, got %v", err)
}
case <-time.After(time.Second):
t.Fatal("Process did not stop")
}
}
常见错误
忘记调用 cancel
这会让定时器和父子关系存活到超时结束。创建取消函数后立即写 defer cancel()。
用 Context Value 隐藏依赖
把数据库或配置塞入 Context 会让函数签名失真。服务依赖应通过结构体字段或参数显式注入。
吞掉 context 错误
取消通常是正常控制流,但上层仍需知道任务没有完成。返回或包装 ctx.Err(),再由边界决定日志级别。
启动无法停止的 Goroutine
每个后台 Goroutine 都应有所有者、退出信号和等待机制。仅仅把 Context 传进去还不够,内部必须真正监听它。
实践检查清单
- Context 是否作为第一个参数沿请求链向下传递。
- 每个
WithCancel、WithTimeout和WithDeadline是否调用取消函数。 - 数据库、HTTP、RPC 和缓存客户端是否使用支持 Context 的方法。
- 阻塞通道操作和长循环是否监听
ctx.Done()。 - Context Value 是否只承载少量请求元数据。
- 超时是否按总体预算分配,而不是每层任意设置。
- 后台 Goroutine 是否能在服务关闭时退出并被等待。
- 测试是否覆盖超时、主动取消和资源清理。
Context 的价值在于统一生命周期。只有取消信号真正传播到每一个阻塞点,服务才能在客户端离开、依赖超时或进程退出时及时停止无效工作。