需要预先了解:


1. context

一个请求往往要派生多个 goroutine:查数据库、调下游服务、写日志。如果其中一步超时或客户端断开,其他步骤需要尽快停下。context 就是用来统一传递取消信号、截止时间和请求范围内的元数据的标准机制。


1.1 需求引入

假设一个函数要并发做三件事,任何一件失败或超时就整体放弃。如果每个 goroutine 各自用 done channel,代码会变成一堆手写的通知逻辑:

done1 := make(chan struct{})
done2 := make(chan struct{})
// 谁来关闭?超时怎么办?取消要不要级联?

context 把这些问题标准化:它是一棵可级联取消的树,父节点取消时所有子节点都会收到通知。


1.2 Context 接口

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

四个方法的职责:

方法 作用
Deadline 返回截止时间,第二个返回值表示是否设置了截止时间
Done 返回一个 channel,context 被取消时它被关闭
Err context 结束后返回原因:context.Canceled 或 context.DeadlineExceeded
Value 取出与该 context 关联的值

Done 是唯一必须用的方法——它让你能 select 等待取消。Err 通常在收到取消后调用,用来区分"被主动取消"和"超时"。


1.3 根 context

所有 context 树都从两个根之一开始:

  • context.Background():主函数、初始化、测试中作为根使用;
  • context.TODO():暂时不知道该传什么时的占位。

两者都是空的、不可取消的 context,行为完全一样,区别只在语义:TODO 表示"这里以后要换成真正的 context"。


1.4 派生 context

WithCancel

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 一定要调用,否则会泄漏

cancel() 关闭 ctx.Done(),并把取消信号传播给所有派生子节点。

WithTimeout / WithDeadline

ctx, cancel := context.WithTimeout(parent, 50*time.Millisecond)
defer cancel()

ctx2, cancel2 := context.WithDeadline(parent, time.Now().Add(30*time.Millisecond))
defer cancel2()

WithTimeout 就是 WithDeadline(parent, time.Now().Add(d)) 的简写。超时后 ctx.Err() 返回 context.DeadlineExceeded。

即使设置了超时,也要 defer cancel():提前完成时取消可以释放定时器资源,避免等到超时才被 GC。

WithValue

type key string

ctx := context.WithValue(context.Background(), key("traceID"), "abc-123")

if v, ok := ctx.Value(key("traceID")).(string); ok {
	fmt.Println("traceID =", v) // abc-123
}

key 建议用自定义类型(比如 type key string)而不是裸字符串,避免不同包之间撞 key。Value 里只放请求范围的元数据(trace ID、用户身份、认证信息),不要放业务参数或可选参数。

树形结构与传播

     context.Background()
             |
       WithCancel(请求级)
        /          \
WithTimeout        WithValue
    |                  |
worker A           worker B

传播规则:

  • 取消父节点 → 所有子孙节点都被取消;
  • 取消子节点 → 父节点和其他兄弟节点不受影响。
parent, cancelParent := context.WithCancel(context.Background())
child1, cancel1 := context.WithCancel(parent)
child2, cancel2 := context.WithCancel(parent)
defer cancel1()
defer cancel2()

cancelParent()
time.Sleep(50 * time.Millisecond)

fmt.Println("child1.Err() =", child1.Err()) // context canceled
fmt.Println("child2.Err() =", child2.Err()) // context canceled

反向取消:

child3, cancel3 := context.WithCancel(parent2)
cancel3()
fmt.Println("child3.Err() =", child3.Err(), "parent2.Err() =", parent2.Err())
child3.Err() = context canceled parent2.Err() = <nil>

1.5 使用惯例

  1. ctx 作为函数的第一个参数,命名为 ctx:
    func Fetch(ctx context.Context, id int) (*User, error)
  2. 不要把 ctx 存进结构体。它是请求级对象,存进结构体会让生命周期难以追踪。例外是 http.Request 这类本身携带请求上下文的结构体。
  3. ctx 可以为 nil 吗? 不可以,绝大多数函数会直接调用 ctx.Done(),传 nil 会 panic。不确定时传 context.TODO()。
  4. 只在真正需要取消的层级派生。不要每个函数都 WithCancel,否则取消链很长,cancel 容易忘记调用。
  5. Value 不要用来传业务参数。业务参数应该显式出现在函数签名里,否则调用方无法从签名知道依赖了什么。

1.6 新增 API

WithCancelCause 与 Cause(Go 1.20+)

默认的取消原因是 context.Canceled,无法区分"为什么取消"。带 cause 的版本可以携带具体错误:

ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("数据库连接失败"))
<-ctx.Done()

fmt.Println("ctx.Err() =", ctx.Err())            // context canceled
fmt.Println("context.Cause =", context.Cause(ctx)) // 数据库连接失败

ctx.Err() 仍然是 context.Canceled,具体原因通过 context.Cause(ctx) 获取。

AfterFunc(Go 1.21+)

在 context 被取消时执行一个函数,不需要自己起 goroutine 等待 Done:

ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
context.AfterFunc(ctx, func() { close(done) })
cancel()
<-done
fmt.Println("AfterFunc 的回调被触发")

返回值 stop 可以取消这个注册:

stop := context.AfterFunc(ctx2, func() { fmt.Println("这行不会打印") })
fmt.Println("stop() 返回值:", stop()) // true

WithoutCancel(Go 1.21+)

派生一个不随父节点取消的 context,但保留其中的值:

parent, cancelParent := context.WithCancel(context.Background())
ctx := context.WithValue(parent, "k", "v")
detached := context.WithoutCancel(ctx)

cancelParent()
fmt.Println("parent.Err() =", parent.Err())       // context canceled
fmt.Println("detached.Err() =", detached.Err())   // <nil>
fmt.Println("值仍然保留:", detached.Value("k"))    // v

典型用途:请求结束后仍要写审计日志、上报指标——这些操作不应该因为客户端断开就被取消。


1.7 可取消的 worker

把 context 和 select 组合起来,就是最常见的可取消任务模式:

func worker(ctx context.Context, id int) {
	for i := 1; ; i++ {
		select {
		case <-ctx.Done():
			fmt.Printf("worker %d 收到取消信号: %v\n", id, ctx.Err())
			return
		default:
			time.Sleep(30 * time.Millisecond)
			fmt.Printf("worker %d 完成第 %d 轮\n", id, i)
		}
	}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go worker(ctx, 1)
go worker(ctx, 2)

time.Sleep(80 * time.Millisecond)
cancel()
time.Sleep(50 * time.Millisecond)

输出(顺序不固定):

worker 1 完成第 1 轮
worker 2 完成第 1 轮
worker 2 完成第 2 轮
worker 1 完成第 2 轮
取消 parent
worker 2 完成第 3 轮
worker 2 收到取消信号: context canceled
worker 1 完成第 3 轮
worker 1 收到取消信号: context canceled

关键点:

  • select 同时等待"取消信号"和"继续工作";
  • 收到取消后立即 return,不要再启动新的工作;
  • 如果 worker 阻塞在 IO 上,要把 ctx 传给 IO 函数(标准库的 http.NewRequestWithContext、db.QueryContext 都接收 ctx)。

1.8 标准库中的 ctx

context 是标准库的一等公民,很多 API 都带 ctx 参数:

// net/http:带超时的请求
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)

// database/sql:带取消的查询
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", id)
// 服务端从请求里取 context
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	// ...
}

HTTP 服务的优雅关闭、请求超时、下游调用取消,都建立在这套机制上,详见 标准库(二):JSON 与 HTTP 服务。

注:context 只负责"通知取消",不负责"强制终止"。收到取消信号后是否立刻停止,由你的代码决定。定时器、channel、IO 都要显式处理取消,否则 goroutine 依然会泄漏。


1.9 练习

  1. 实现一个函数,用 context.WithTimeout 限制总耗时,内部并发请求三个"慢服务"(用 time.Sleep 模拟),任何一个超时就返回错误。
  2. 用 WithValue 传递一个 trace ID,在多个函数中通过 ctx.Value 取出并打印,验证它跨函数、跨 goroutine 都能访问到。
  3. 用 WithCancelCause 实现一个"失败即取消全部"的批量任务,用 context.Cause 打印出第一个失败原因。