Go context 实战:超时、取消与请求链路

context.Context 用于在 API 边界之间传递截止时间、取消信号和少量请求级元数据。它解决的不是普通参数传递,而是让一条请求链上的 Goroutine、数据库查询和网络调用能够一起停止。

Context 的核心能力

Context 提供四类信息:

  • Deadline():任务必须结束的时间。
  • Done():取消或超时后关闭的通道。
  • Err():结束原因,通常是 context.Canceledcontext.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. 每个 WithCancelWithTimeoutWithDeadline 是否调用取消函数。
  3. 数据库、HTTP、RPC 和缓存客户端是否使用支持 Context 的方法。
  4. 阻塞通道操作和长循环是否监听 ctx.Done()
  5. Context Value 是否只承载少量请求元数据。
  6. 超时是否按总体预算分配,而不是每层任意设置。
  7. 后台 Goroutine 是否能在服务关闭时退出并被等待。
  8. 测试是否覆盖超时、主动取消和资源清理。

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