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 设计规则

  1. Context 放在函数第一个参数,并命名为 ctx。
  2. 不要把 Context 存入结构体长期持有。
  3. 不要传递 nil Context;不确定时使用 context.Background()。
  4. 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 传进去还不够,内部必须真正监听它。

实践检查清单

  1. Context 是否作为第一个参数沿请求链向下传递。
  2. 每个 WithCancel、WithTimeout 和 WithDeadline 是否调用取消函数。
  3. 数据库、HTTP、RPC 和缓存客户端是否使用支持 Context 的方法。
  4. 阻塞通道操作和长循环是否监听 ctx.Done()。
  5. Context Value 是否只承载少量请求元数据。
  6. 超时是否按总体预算分配,而不是每层任意设置。
  7. 后台 Goroutine 是否能在服务关闭时退出并被等待。
  8. 测试是否覆盖超时、主动取消和资源清理。

Context 的价值在于统一生命周期。只有取消信号真正传播到每一个阻塞点,服务才能在客户端离开、依赖超时或进程退出时及时停止无效工作。