GO [ context 原理解析 ]

context

在 Go 的并发程序里,context 经常和 goroutine、channel、HTTP 请求、数据库查询一起出现。很多代码把它简单理解成"超时参数",但这只覆盖了它很小的一部分能力。

Go 官方对 context 的定义是:它在 API 边界和进程之间传递截止时间、取消信号以及请求范围的数据。一个请求从入口进入服务以后,通常会经过 handler、service、repository、HTTP client 和数据库驱动。Context 就沿着这条调用链向下传递,把"这个请求什么时候结束""调用方是否已经放弃""这个请求携带哪些元数据"告诉后面的函数。

本文的核心判断是:context.Context 是一棵可取消的调用树,不是参数字典,也不是强制终止 goroutine 的杀进程按钮。

这句话包含三个重点:

  • Context 的主要职责是传播取消、截止时间和少量请求范围数据;
  • 取消是合作式的,函数必须主动检查 Done 或 Err;
  • Context 的父子关系决定了取消传播方向,子 Context 被取消不会反向取消父 Context。

本文会按照"官方定义 → 接口方法 → 取消传播 → 超时实现 → valueCtx → cause → 手写简化实现 → 可取消插件执行器 → HTTP 请求 → 常见错误"的顺序展开。

本文代码按照 Go 1.27 标准库整理。Context 的内部类型属于实现细节,版本升级后字段和优化策略可能变化;文章中的源码结构用于理解原理,不应该在业务代码中直接依赖未导出的类型。

Context 接口

context.Context 是一个接口,定义了四个方法:

复制代码
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

四个方法分别表达不同的信息:

方法 作用
Deadline 返回工作应该结束的时间
Done 返回一个在取消时被关闭的 channel
Err 返回取消原因,通常是 context.Canceled 或 context.DeadlineExceeded
Value 读取请求范围的元数据

Context 的方法可以被多个 goroutine 同时调用。它本身不负责保存业务结果,也不应该被当成一个并发 map 使用。

Deadline

复制代码
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

deadline, ok := ctx.Deadline()
if ok {
	fmt.Println("deadline:", deadline)
}

ok == false 表示这个 Context 没有截止时间。context.Background() 和 context.TODO() 都没有 deadline。

截止时间是绝对时间,而 WithTimeout 接收的是持续时间:

复制代码
context.WithTimeout(parent, 2*time.Second)

官方实现可以理解成:

复制代码
context.WithDeadline(parent, time.Now().Add(2*time.Second))

如果父 Context 已经有更早的 deadline,子 Context 不会把它延长。

Done

Done 返回一个只读 channel。取消发生时,context 包关闭这个 channel:

复制代码
select {
case <-ctx.Done():
	return ctx.Err()
case value := <-result:
	return value
}

这里使用的是"关闭 channel 广播"的语义。一个 channel 被关闭以后,所有接收者都能立即收到通知,不需要给每个 goroutine 单独发送一条消息。

有些 Context 永远不会被取消,因此 Done() 可以返回 nil。对 nil channel 的接收会永久阻塞,而在 select 中 nil channel 分支会被禁用。这就是 Background 可以作为根 Context 使用的原因。

Err

复制代码
if err := ctx.Err(); err != nil {
	return err
}

如果 Done 尚未关闭,Err 返回 nil;如果已经取消,则返回:

  • context.Canceled:主动取消或父 Context 取消;
  • context.DeadlineExceeded:截止时间到达。

不要用错误字符串判断类型:

复制代码
if errors.Is(err, context.Canceled) {
	// 请求被取消
}

errors.Is 可以处理包装后的错误,调用方不必依赖具体错误文本。

Value

Value 用于传递请求范围数据:

复制代码
requestID := ctx.Value(requestIDKey{})

官方建议 Value 只保存跨 API 边界传递的请求元数据,例如 request ID、trace ID、认证用户或租户信息。函数的业务参数仍然应该显式写在函数参数里:

复制代码
func LoadUser(ctx context.Context, userID int64) (*User, error)

不要写成:

复制代码
func LoadUser(ctx context.Context) (*User, error) {
	userID := ctx.Value("user-id")
	// 调用者看不出这个函数依赖 user-id
	return nil, nil
}

显式参数让函数契约清楚,也让编译器和静态分析工具更容易检查代码。

Background 和 TODO

context.Background

Background 返回一个非 nil、永远不会取消、没有 deadline、没有 value 的根 Context:

复制代码
ctx := context.Background()

它通常用于:

  • main 函数创建最顶层 Context;
  • 初始化代码;
  • 测试中的根 Context;
  • 没有上游请求的后台任务。

context.TODO

TODO 的行为和 Background 类似,区别主要是表达意图:当前代码还没有确定应该使用哪个 Context。

复制代码
func migrate() error {
	ctx := context.TODO()
	return runMigration(ctx)
}

TODO 不是"比 Background 更能取消"的 Context。它只是一个提醒:这段代码未来可能需要接收真正的 Context。

不要传 nil Context

错误写法:

复制代码
runQuery(nil)

即使某个函数暂时允许 nil,后续调用 ctx.Done() 或 ctx.Value() 也可能 panic。没有合适的 Context 时使用 context.TODO(),不要传 nil。

WithCancel:主动取消

WithCancel 从父 Context 派生一个子 Context,并返回一个 CancelFunc:

复制代码
ctx, cancel := context.WithCancel(parent)
defer cancel()

调用 cancel 会取消当前 Context 和它派生出来的所有子 Context。

复制代码
package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

func worker(ctx context.Context, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 1; ; i++ {
		select {
		case <-ctx.Done():
			fmt.Println("worker stopped:", ctx.Err())
			return
		default:
			fmt.Println("working", i)
			time.Sleep(20 * time.Millisecond)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	var wg sync.WaitGroup
	wg.Add(1)
	go worker(ctx, &wg)

	time.Sleep(60 * time.Millisecond)
	cancel()
	wg.Wait()
}

cancel() 只是发出取消信号,不负责等待 worker 退出。这里用 WaitGroup 确认 goroutine 已经结束。

为什么要 defer cancel

复制代码
func load(ctx context.Context) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	return doWork(ctx)
}

cancel 的作用不只是改变 Err() 的返回值,它还会释放 Context 关联的资源、从父节点移除子节点,并停止可能存在的计时器。即使任务提前成功返回,也应该调用 cancel。

go vet 能够检查很多控制流路径上是否正确使用了 CancelFunc。把 defer cancel() 放在创建 Context 之后,是最简单可靠的习惯。

取消是一棵树

Context 的父子关系可以画成一棵树:

复制代码
context.Background()
        │
        ├── request context
        │       │
        │       ├── service timeout
        │       │       └── database query
        │       │
        │       └── downstream HTTP call
        │
        └── background task

取消传播只有一个方向:从父节点流向子节点。

复制代码
parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithCancel(parent)

grandchild, cancelGrandchild := context.WithCancel(child)

defer cancelGrandchild()
defer cancelChild()
defer cancelParent()

如果调用 cancelChild:

  • child 被取消;
  • grandchild 被取消;
  • parent 仍然可以继续使用。

如果调用 cancelParent:

  • parent 被取消;
  • child 被取消;
  • grandchild 被取消。

这正适合请求调用链:请求入口取消时,数据库、缓存和下游 HTTP 请求一起停止;某个子操作失败时,只取消它自己的子树,不影响其他独立请求。

父节点已经取消

如果创建子 Context 时父 Context 已经取消,子 Context 会立即进入取消状态:

复制代码
parent, cancel := context.WithCancel(context.Background())
cancel()

child, childCancel := context.WithCancel(parent)
defer childCancel()

fmt.Println(child.Err() == context.Canceled) // true

子 Context 不需要等下一次事件循环才发现父节点的状态。

子节点先取消

复制代码
parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithCancel(parent)

cancelChild()
fmt.Println(child.Err())  // context canceled
fmt.Println(parent.Err()) // <nil>

cancelParent()

子节点的取消不会反向传播。这个规则避免一个局部操作失败后误杀整个请求树。

cancelCtx:取消传播的实现

Go 标准库的 WithCancel 返回一个内部的 cancelCtx。它不是公开类型,但可以从源码中抽象出几个关键字段:

复制代码
type cancelCtx struct {
	Context

	mu       sync.Mutex
	done     atomic.Value
	children map[canceler]struct{}
	err      atomic.Value
	cause    error
}

这些字段的职责是:

  • 嵌入父 Context,让子 Context 继续获得父节点的 deadline 和 value;
  • done 保存取消通知 channel,并且按需创建;
  • children 保存当前节点的可取消子节点;
  • err 保存第一次取消的错误;
  • cause 保存更具体的取消原因。

Done 为什么延迟创建

cancelCtx.Done() 不一定在创建 Context 时立刻创建 channel。标准库采用懒创建:只有代码真正调用 Done() 时,才分配 channel。

这很有意义:

复制代码
ctx, cancel := context.WithCancel(parent)
defer cancel()

如果调用链只需要 Value,从来不监听 Done,就没有必要为每个节点分配一个 channel。

标准库还会复用一个已经关闭的 channel,减少已经取消 Context 的重复分配。这些是实现优化,业务代码只需要依赖接口行为。

cancel 的核心动作

取消一个 cancelCtx 大致包含以下步骤:

  1. 加锁,判断是否已经取消;
  2. 记录 err 和 cause;
  3. 关闭 done channel;
  4. 取出子节点集合;
  5. 解锁;
  6. 递归取消每个子节点;
  7. 从父节点的 children 集合中移除自己。

同一个 CancelFunc 被多个 goroutine 同时调用也是安全的。第一次调用真正改变状态,后续调用直接返回。

WithDeadline 和 WithTimeout

WithDeadline

WithDeadline 接收一个绝对时间:

复制代码
deadline := time.Now().Add(200 * time.Millisecond)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

WithTimeout

WithTimeout 接收一个持续时间:

复制代码
ctx, cancel := context.WithTimeout(parent, 200*time.Millisecond)
defer cancel()

两者都返回一个带截止时间的 Context。截止时间到达后,Done 被关闭,Err 返回 context.DeadlineExceeded。

复制代码
func slowOperation(ctx context.Context) error {
	timer := time.NewTimer(200 * time.Millisecond)
	defer timer.Stop()

	select {
	case <-timer.C:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()
	fmt.Println(slowOperation(ctx))
}

运行结果:

复制代码
context deadline exceeded

timerCtx 的结构

标准库中的 timerCtx 可以概括成:

复制代码
type timerCtx struct {
	cancelCtx
	timer    *time.Timer
	deadline time.Time
}

它复用 cancelCtx 的取消树能力,再增加一个 time.Timer。计时器到期时调用内部 cancel,取消逻辑仍然是关闭 Done、设置 Err、传播给子节点。

如果父节点 deadline 更早,标准库会直接使用父节点的截止时间,不会为了一个更晚的子 deadline 再创建多余的 timer:

复制代码
parent, cancelParent := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancelParent()

child, cancelChild := context.WithTimeout(parent, time.Second)
defer cancelChild()

parentDeadline, _ := parent.Deadline()
childDeadline, _ := child.Deadline()
fmt.Println(childDeadline.Equal(parentDeadline)) // true

超时不是强制杀死

下面这个函数即使传入超时 Context,也不会在循环内部自动停止:

复制代码
func badLoop(ctx context.Context) {
	for i := 0; i < 1_000_000_000; i++ {
		// 没有读取 ctx
	}
}

Context 只能发出信号,函数必须主动合作:

复制代码
func goodLoop(ctx context.Context) error {
	for i := 0; i < 1_000_000_000; i++ {
		if i%1000 == 0 {
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
			}
		}
	}
	return nil
}

如果代码阻塞在 channel、网络或数据库调用中,底层 API 也必须支持 Context;否则上层虽然已经取消,底层调用仍可能继续阻塞。

WithValue:请求范围数据

WithValue 返回一个新的 Context,新的 Context 保存一组 key 和 value:

复制代码
ctx := context.WithValue(parent, key, value)

它不是修改原来的 Context,而是沿父链增加一个节点。查找时从当前节点开始,找不到再向父节点查找。

key 必须避免碰撞

错误写法:

复制代码
ctx = context.WithValue(ctx, "request-id", "req-1")

不同包都可能使用相同的字符串,导致值相互覆盖。推荐使用包内私有类型:

复制代码
type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

func RequestID(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok
}

空结构体 key 通常不需要额外分配,同时只能在声明它的包中使用。

Value 的查找顺序

复制代码
type key string

ctx := context.WithValue(context.Background(), key("mode"), "outer")
ctx = context.WithValue(ctx, key("mode"), "inner")

fmt.Println(ctx.Value(key("mode"))) // inner

最近的一层优先。这个行为适合请求链上的局部覆盖,例如测试中替换 trace ID,但也意味着过度使用 Value 会让数据来源难以追踪。

不要把业务配置塞进 Context

错误写法:

复制代码
ctx = context.WithValue(ctx, "page-size", 100)
ctx = context.WithValue(ctx, "sort-order", "desc")

正确写法是显式传入配置:

复制代码
func ListUsers(ctx context.Context, pageSize int, order string) ([]User, error)

Context 值应该是请求元数据,而不是隐藏参数。

WithCancelCause 和 Cause

普通 WithCancel 只能得到一个统一的 context.Canceled。如果需要知道"为什么取消",可以使用 WithCancelCause:

复制代码
ctx, cancel := context.WithCancelCause(context.Background())

reason := errors.New("upstream deployment stopped")
cancel(reason)

fmt.Println(ctx.Err())          // context canceled
fmt.Println(context.Cause(ctx)) // upstream deployment stopped

Err 表示标准化的取消类别,Cause 表示更具体的业务原因。这样既保留了 errors.Is(err, context.Canceled) 的通用判断,又能在日志和监控中记录真正原因。

父子 Context 的 cause

复制代码
parent, cancelParent := context.WithCancelCause(context.Background())
child, cancelChild := context.WithCancelCause(parent)

cancelChild(errors.New("child failed"))
fmt.Println(context.Cause(child))  // child failed
fmt.Println(parent.Err())          // <nil>

cancelParent(errors.New("parent failed"))
fmt.Println(context.Cause(parent)) // parent failed

如果父节点先取消,子节点会继承父节点的取消原因;如果子节点先取消,它保留自己的原因,父节点仍然可以继续运行。

WithTimeoutCause

如果希望 deadline 到期时带上自定义原因,可以使用 WithTimeoutCause:

复制代码
ctx, cancel := context.WithTimeoutCause(
	context.Background(),
	50*time.Millisecond,
	errors.New("database budget exhausted"),
)
defer cancel()

<-ctx.Done()
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))

ctx.Err() 仍然是 context deadline exceeded,context.Cause(ctx) 是自定义错误。

AfterFunc

context.AfterFunc 在 Context 被取消后启动一个新的 goroutine 执行函数:

复制代码
ctx, cancel := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx, func() {
	fmt.Println("cleanup started")
})

defer stop()
cancel()

返回的 stop 函数用于解除关联:

复制代码
stopped := stop()
fmt.Println(stopped)

stop 返回 true,表示函数尚未启动并且关联已经解除;返回 false,表示函数已经开始执行或之前已经停止。stop 不会等待清理函数结束,如果调用方需要等待,必须额外使用 channel 或 WaitGroup。

AfterFunc 适合把取消转换成一次性的清理动作,但不要在清理函数中做不可控的长时间阻塞。它本身会启动 goroutine,使用时仍然要考虑生命周期。

手写一个简化版 Context

为了理解 Context 的核心机制,先实现一个只支持取消的简化版本。它不实现 deadline 和 value,也不追求标准库的性能优化。

复制代码
package main

import (
	"context"
	"sync"
)

type simpleContext struct {
	parent context.Context
	done   chan struct{}
	once   sync.Once
	mu     sync.Mutex
	err    error
}

func (c *simpleContext) Deadline() (time.Time, bool) {
	return time.Time{}, false
}

func (c *simpleContext) Done() <-chan struct{} {
	return c.done
}

func (c *simpleContext) Err() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.err
}

func (c *simpleContext) Value(key any) any {
	return c.parent.Value(key)
}

func (c *simpleContext) cancel(err error) {
	c.once.Do(func() {
		c.mu.Lock()
		c.err = err
		c.mu.Unlock()
		close(c.done)
	})
}

这个代码还需要导入 time:

复制代码
import "time"

为了让父 Context 取消时能传播到 simpleContext,可以写一个构造函数:

复制代码
func withSimpleCancel(parent context.Context) (context.Context, context.CancelFunc) {
	if parent == nil {
		panic("nil parent")
	}

	child := &simpleContext{
		parent: parent,
		done:   make(chan struct{}),
	}

	go func() {
		select {
		case <-parent.Done():
			child.cancel(parent.Err())
		case <-child.Done():
		}
	}()

	return child, func() {
		child.cancel(context.Canceled)
	}
}

完整可运行版本:

复制代码
package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

type simpleContext struct {
	parent context.Context
	done   chan struct{}
	once   sync.Once
	mu     sync.Mutex
	err    error
}

func (c *simpleContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (c *simpleContext) Done() <-chan struct{}       { return c.done }
func (c *simpleContext) Value(key any) any            { return c.parent.Value(key) }
func (c *simpleContext) Err() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.err
}
func (c *simpleContext) cancel(err error) {
	c.once.Do(func() {
		c.mu.Lock()
		c.err = err
		c.mu.Unlock()
		close(c.done)
	})
}
func withSimpleCancel(parent context.Context) (context.Context, context.CancelFunc) {
	if parent == nil { panic("nil parent") }
	child := &simpleContext{parent: parent, done: make(chan struct{})}
	go func() {
		select {
		case <-parent.Done(): child.cancel(parent.Err())
		case <-child.Done():
		}
	}()
	return child, func() { child.cancel(context.Canceled) }
}
func main() {
	parent, cancelParent := context.WithCancel(context.Background())
	defer cancelParent()
	child, cancelChild := withSimpleCancel(parent)
	defer cancelChild()
	fmt.Println("before:", child.Err())
	cancelParent()
	<-child.Done()
	fmt.Println("after:", child.Err())
}

运行结果:

复制代码
before: <nil>
after: context canceled

这个实现和标准库有什么区别

这个简化版本为每个子 Context 启动一个 goroutine 监听父节点:

复制代码
go func() {
	select {
	case <-parent.Done():
	case <-child.Done():
	}
}()

标准库不会简单地为每个节点都启动一个监听 goroutine。它会识别父节点是不是内部的 cancelCtx,如果是,就把子节点放进父节点的 children 集合;父节点取消时直接遍历集合传播取消。

只有当父 Context 是自定义实现、无法直接加入标准取消树时,标准库才可能使用额外的监听机制。这个优化让大量 Context 派生不会产生同等数量的常驻 goroutine。

手写版本的意义是理解四个关键动作:

  1. 用 channel 表示取消通知;
  2. 用 sync.Once 保证只关闭一次;
  3. 用互斥锁保存取消错误;
  4. 用 goroutine 连接父节点和子节点。

它不应该替代标准库。生产代码使用 context.WithCancel,这样可以获得标准库已经处理好的竞态、资源释放和性能优化。

实战:可取消的插件执行器

现在实现一个小型插件执行器。每个插件执行一段任务,任意插件失败时,执行器用取消原因停止其他插件;所有插件都能收到同一个 Context,并在阻塞或循环处主动退出。

定义插件接口

复制代码
type Plugin interface {
	Name() string
	Run(context.Context) error
}

一个插件不应该创建自己的根 Context。宿主程序负责创建父 Context,并把它传给所有插件:

复制代码
type SleepPlugin struct {
	name  string
	delay time.Duration
}

func (p SleepPlugin) Name() string { return p.name }

func (p SleepPlugin) Run(ctx context.Context) error {
	timer := time.NewTimer(p.delay)
	defer timer.Stop()

	select {
	case <-timer.C:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

失败插件:

复制代码
type FailPlugin struct{}

func (FailPlugin) Name() string { return "fail" }

func (FailPlugin) Run(ctx context.Context) error {
	select {
	case <-time.After(80 * time.Millisecond):
		return errors.New("plugin failed")
	case <-ctx.Done():
		return ctx.Err()
	}
}

执行器数据结构

复制代码
type RunResult struct {
	Plugin string
	Err    error
}

type Executor struct {
	plugins []Plugin
}

执行器不保存请求 Context,因为 Context 应该是每次执行的参数,而不是对象的长期状态:

复制代码
func (e Executor) Run(ctx context.Context) []RunResult

并行执行并传播失败原因

复制代码
func (e Executor) Run(ctx context.Context) []RunResult {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	results := make(chan RunResult, len(e.plugins))
	var wg sync.WaitGroup
	wg.Add(len(e.plugins))

	for _, plugin := range e.plugins {
		plugin := plugin
		go func() {
			defer wg.Done()
			err := plugin.Run(ctx)
			results <- RunResult{Plugin: plugin.Name(), Err: err}
			if err != nil && !errors.Is(err, context.Canceled) {
				cancel(fmt.Errorf("%s: %w", plugin.Name(), err))
			}
		}()
	}

	go func() {
		wg.Wait()
		close(results)
	}()

	var output []RunResult
	for result := range results {
		output = append(output, result)
	}
	return output
}

这里使用带缓冲的 results channel,容量等于插件数量。这样即使主 goroutine正在等待其他结果,已经结束的插件也不会因为发送结果而永久阻塞。

cancel 的调用顺序很重要:插件先把自己的结果发送出去,再通知其他插件取消。如果先取消,当前插件可能在结果发送阶段也观察到 ctx.Done(),从而让调用方丢失失败信息。

完整项目:

复制代码
package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

type Plugin interface {
	Name() string
	Run(context.Context) error
}

type SleepPlugin struct {
	name  string
	delay time.Duration
}
func (p SleepPlugin) Name() string { return p.name }
func (p SleepPlugin) Run(ctx context.Context) error {
	timer := time.NewTimer(p.delay)
	defer timer.Stop()
	select {
	case <-timer.C: return nil
	case <-ctx.Done(): return ctx.Err()
	}
}

type FailPlugin struct{}
func (FailPlugin) Name() string { return "fail" }
func (FailPlugin) Run(ctx context.Context) error {
	timer := time.NewTimer(80 * time.Millisecond)
	defer timer.Stop()
	select {
	case <-timer.C: return errors.New("plugin failed")
	case <-ctx.Done(): return ctx.Err()
	}
}

type RunResult struct { Plugin string; Err error }
type Executor struct { plugins []Plugin }
func (e Executor) Run(parent context.Context) []RunResult {
	ctx, cancel := context.WithCancelCause(parent)
	defer cancel(nil)
	results := make(chan RunResult, len(e.plugins))
	var wg sync.WaitGroup
	wg.Add(len(e.plugins))
	for _, plugin := range e.plugins {
		plugin := plugin
		go func() {
			defer wg.Done()
			err := plugin.Run(ctx)
			results <- RunResult{Plugin: plugin.Name(), Err: err}
			if err != nil && !errors.Is(err, context.Canceled) {
				cancel(fmt.Errorf("%s: %w", plugin.Name(), err))
			}
		}()
	}
	go func() { wg.Wait(); close(results) }()
	var output []RunResult
	for result := range results { output = append(output, result) }
	return output
}
func main() {
	results := (Executor{plugins: []Plugin{
		SleepPlugin{name: "cache", delay: 200 * time.Millisecond},
		FailPlugin{},
		SleepPlugin{name: "search", delay: 300 * time.Millisecond},
	}}).Run(context.Background())
	for _, result := range results { fmt.Printf("plugin=%s err=%v\n", result.Plugin, result.Err) }
}

一种可能的输出:

复制代码
plugin=fail err=fail: plugin failed
plugin=cache err=context canceled
plugin=search err=context canceled

结果顺序不固定,因为插件是并行运行的。这个例子展示了一个常见的"首个失败取消剩余任务"模式,但它不是 errgroup 的完整替代品。生产项目还需要决定:是否返回所有错误、是否等待已经启动的外部请求、是否允许部分成功,以及取消原因是否需要上报监控。

执行器中的三个生命周期

这个项目里有三个不同的生命周期,不能混为一谈:

  • 插件任务生命周期:由 Plugin.Run 和传入的 Context 决定;
  • goroutine 生命周期:由 Run 中的 WaitGroup 等待;
  • 结果 channel 生命周期:由协调 goroutine 在所有插件退出后关闭。

Context 只负责发出取消信号。它不会替代 WaitGroup,也不会自动关闭业务 channel。

HTTP 请求中的 Context

net/http 会为每个入站请求提供 r.Context():

复制代码
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	result, err := service(ctx)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			return
		}
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	fmt.Fprintln(w, result)
}

当客户端连接断开、HTTP/2 请求被取消,或者 handler 返回时,服务器请求 Context 会被取消。下游调用必须接收并继续传递这个 Context:

复制代码
func service(ctx context.Context) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()
	return callDownstream(ctx)
}

调用 HTTP client:

复制代码
func callDownstream(ctx context.Context) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
	if err != nil {
		return "", err
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return "", fmt.Errorf("downstream status: %s", resp.Status)
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	return string(body), err
}

这里有两层 timeout:HTTP 请求 Context 可能因为客户端断开而取消,service 又可以设置比整个请求更短的业务预算。子 Context 只能缩短时间,不能延长父 Context 的截止时间。

HTTP 中间件注入 request ID

复制代码
type requestIDKey struct{}

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = strconv.FormatInt(time.Now().UnixNano(), 10)
		}
		ctx := context.WithValue(r.Context(), requestIDKey{}, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

下游函数通过类型安全的访问器读取:

复制代码
func requestID(ctx context.Context) string {
	id, _ := ctx.Value(requestIDKey{}).(string)
	return id
}

不要把请求 ID 直接放进全局变量。全局变量会在并发请求之间互相覆盖,Context 的请求范围正好解决了这个问题。

Context 和结构体

官方文档明确建议不要把 Context 存进结构体:

复制代码
type Service struct {
	ctx context.Context // 不推荐
}

推荐把它作为每个需要取消或 deadline 的函数的第一个参数:

复制代码
type Service struct {
	client *http.Client
}

func (s *Service) Fetch(ctx context.Context, id string) error {
	// 使用 ctx 控制本次 Fetch
	return nil
}

原因不是"结构体一定不能有 Context",而是结构体字段无法清楚表达它属于哪个请求、何时失效、是否会被多个请求复用。一个长期存在的 service 如果保存了某次请求的 Context,就可能把已经取消的请求错误地带到下一次调用。

如果一个对象本身确实有明确生命周期,例如一个 worker 管理器,可以保存它自己的 cancel 和内部 context;但对外部业务方法,仍然建议显式接收调用方 Context。

Context 的常见错误

把 Context 当作 map

Context Value 只保存少量请求元数据,不适合放几十个配置项、业务对象和可变状态。参数隐式化以后,函数很难独立测试,也很难知道依赖从哪里来。

只创建 timeout,不向下游传递

错误代码:

复制代码
func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), time.Second)
	defer cancel()
	_ = ctx
	callDatabase(context.Background()) // 丢掉了 timeout
}

正确代码:

复制代码
func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), time.Second)
	defer cancel()
	_ = callDatabase(ctx)
}

创建 Context 没有意义,除非所有可能阻塞的下游操作都真正使用它。

用 time.Sleep 模拟可取消等待

错误:

复制代码
time.Sleep(5 * time.Second)

正确:

复制代码
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
	return ctx.Err()
}

取消后继续发送 channel

错误:

复制代码
for value := range values {
	output <- value
}

如果下游已经退出,发送方可能永久阻塞。应该把发送和取消放在同一个 select 中:

复制代码
for value := range values {
	select {
	case output <- value:
	case <-ctx.Done():
		return ctx.Err()
	}
}

忘记关闭或等待

Context 取消后,goroutine 不会自动消失。需要由任务函数返回,并由 WaitGroup、完成 channel 或结果 channel 观察退出。

使用字符串 key

字符串 key 容易和其他包冲突。使用私有类型和类型安全访问函数。

传 nil

Context 应该是非 nil 的。暂时没有上游时使用 context.TODO(),根程序使用 context.Background()。

调试 Context

可以在日志中记录 deadline 和取消原因:

复制代码
func logContext(logger *log.Logger, ctx context.Context) {
	deadline, ok := ctx.Deadline()
	if ok {
		logger.Printf("deadline=%s remaining=%s", deadline, time.Until(deadline))
	}
	if err := ctx.Err(); err != nil {
		logger.Printf("err=%v cause=%v", err, context.Cause(ctx))
	}
}

不要把 Context 的 String() 当作业务信息。标准库内部实现可能通过 String 展示 context.Background.WithCancel 等调试描述,但这不是稳定协议。

测试取消行为时,应该明确等待完成信号,而不是固定 time.Sleep:

复制代码
func TestCancelStopsWorker(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() {
		defer close(done)
		<-ctx.Done()
	}()

	cancel()
	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("worker did not stop")
	}
}

总结

context.Context 的公开 API 很小,但它解决的是大型并发调用链中的生命周期问题:

  • Deadline 表示最晚完成时间;
  • Done 用关闭 channel 广播取消;
  • Err 返回标准化取消错误;
  • Value 传递少量请求范围元数据;
  • WithCancel 创建主动取消的子树;
  • WithTimeout 和 WithDeadline 把时间事件转换成取消;
  • WithCancelCause 和 Cause 保存更具体的失败原因;
  • AfterFunc 把取消转换成一次性清理动作。

真正使用 Context 时,可以遵循下面这组规则:

  1. Context 作为函数第一个参数,命名为 ctx;
  2. 不传 nil,根 Context 使用 Background 或 TODO;
  3. 创建 WithCancel、WithTimeout 或 WithDeadline 后及时调用 cancel;
  4. 让取消沿调用链传到数据库、HTTP client、channel 和 goroutine;
  5. 在循环、阻塞发送和阻塞接收处主动监听 Done;
  6. 不把 Context 存进普通业务结构体;
  7. Value 只保存请求范围元数据,不保存可选参数和可变状态;
  8. Context 只负责发出取消信号,WaitGroup 和 channel 仍然负责等待与数据传递;
  9. 需要诊断首个失败原因时,使用 WithCancelCause;
  10. 依赖 Context 的代码使用测试完成信号验证退出,而不是依赖 sleep。

Context 不会替你设计并发程序,但它会把并发程序最容易遗漏的"什么时候停止"变成一个可以沿调用链传递的接口契约。

官方资料

相关推荐
普通人61 小时前
iPhone Duo 介绍
ios·iphone
派小心.1 小时前
网站统计多域名数据隔离配置
大数据·数据库·人工智能·小程序·数据分析
DongQiShanRen1 小时前
六根硬件映射(中):双站校验、生克闭环与驱动接口
数据库·人工智能·算法·rust·哈希算法·集成学习
wespaul2 小时前
VeapAI 自然语言操作实战|第 09 篇:收尾——六条原则与三条演进线
大数据·数据库·人工智能
tryxr2 小时前
事务的特性
数据库·事务
广州灵眸科技有限公司2 小时前
瑞芯微(EASY EAI)RV1126B DeepSeek R1部署教程
linux·数据库·yolo
2501_915921432 小时前
iOS应用从开发到上架App Store的完整步骤详解
android·ios·小程序·https·uni-app·iphone·webview
枫叶丹42 小时前
异构数据库结构如何同步:理解 KFS 的 DDL 转换机制
网络·数据库