Skip to content

context:让一条调用链共用退出信号和时间预算 ​

一个 HTTP 请求进来,服务要查缓存、访问数据库,还可能调用下游。代码分散在几个函数里,工作却属于同一次请求。

如果用户已经离开,或者这次请求允许等待的时间已经用完,怎样把“可以停了”告诉这些函数?

context 就负责传递这个信号,以及这次工作最晚能进行到什么时候。但收到信号不等于已经停下:函数要主动检查它,或把它继续交给支持取消的下游 API。context 不会强制杀掉 goroutine。[1]

先不用记住所有构造函数。拿到一个 ctx 时,可以通过下面四个方法了解这次请求的状态:

方法它告诉调用方什么没有对应状态时
Deadline()这项工作的最晚截止时刻第二个返回值为 false
Done()关闭时表示该工作应停止可能返回 nil,表示永不取消
Err()为什么停止:取消或超时未取消时为 nil
Value(key)本请求范围内绑定的一个值找不到返回 nil

多个 goroutine 可以同时调用这些方法,但这项保证只针对 context 本身。比如,Value 中存放了一个可变 map,读写这个 map 仍要遵守它自己的并发访问规则;把对象放进 context 不会自动为它加锁。[2]

先画出取消会走到哪里 ​

接着看这些函数怎样连起来。数据库查询属于当前请求,就从请求已有的 context 派生一个子 context;下游 HTTP 调用也可以派生自己的子 context。派生时传入的那个 context 就叫 parent,也就是父级。

子级沿用父级的取消约束、截止时间(deadline)和值,也能给自己设置更紧的时间预算。这样,请求取消时,它下面的工作都会收到退出信号;数据库分支先取消时,影响则留在数据库这一支,不会反过来取消父级和兄弟。[1:1]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart TD
    R[请求 context] --> C[缓存查询 context]
    R --> D[数据库 context:最多 200 ms]
    R --> H[下游 HTTP context:最多 500 ms]
    D --> Q[数据库内部等待]
    X[请求结束:父级取消] -. 传播到所有后代 .-> C
    X -. 传播 .-> D
    X -. 传播 .-> H
    D -. 本分支先超时,只影响自己和后代 .-> Q
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart TD
    R[请求 context] --> C[缓存查询 context]
    R --> D[数据库 context:最多 200 ms]
    R --> H[下游 HTTP context:最多 500 ms]
    D --> Q[数据库内部等待]
    X[请求结束:父级取消] -. 传播到所有后代 .-> C
    X -. 传播 .-> D
    X -. 传播 .-> H
    D -. 本分支先超时,只影响自己和后代 .-> Q

图中,实线连接 parent 与 child,虚线标出取消事件影响的范围。数据库分支先超时,不会要求缓存和下游 HTTP 分支一起退出;请求 context 取消,则会影响所有后代。这张图描述的是取消关系,每个节点并不对应一个单独启动的 goroutine,因此也不能据此估计线程数。

要建立的约束入口返回的清理动作
只有手动取消WithCancel(parent)CancelFunc
最多运行一段时长WithTimeout(parent, d)CancelFunc
到指定时刻为止WithDeadline(parent, t)CancelFunc
取消时携带业务原因WithCancelCause(parent)CancelCauseFunc,可传一个 error
到期时携带预设原因WithTimeoutCause / WithDeadlineCause普通 CancelFunc
绑定请求元数据WithValue(parent, key, value)没有新的取消函数

选择构造函数前,要先确认手中的 parent。Background() 没有取消信号、deadline 和值,常用作 main、初始化或测试的根;TODO() 表示这里还没确定该传哪个 context。处理请求时若已经拿到 ctx,却又从 Background() 开始派生,新分支就接不到原调用链的退出约束。[3]

每次派生都接住 cancel,即使预计很快会成功 ​

函数通常把 context 放在第一个参数,命名为 ctx。需要进一步限制工作时长时,就从它派生,并保留返回的取消函数。下面用根 context 和零时长演示到期与清理的写法:

go
expired, stop := context.WithTimeout(context.Background(), 0)
defer stop()
fmt.Println(WaitForStop(expired))

WaitForStop 的全部工作是 <-ctx.Done(); return ctx.Err():等待退出信号,然后返回取消类别。本章程序的实际输出为:

text
context deadline exceeded

例子把时长设为零,只是为了立即看到到期结果,不必真的等一个慢服务。业务代码会把派生出来的 ctx 传给实际执行查询或网络请求的函数。

即使查询提前成功,返回的 stop() 也要调用。它负责释放这个派生 context 关联的资源;工作已经做完,就没必要继续等到 deadline。把清理安排在创建附近,后面新增提前返回的分支时,也不容易漏掉。包文档要求不要丢弃取消函数,go vet 的 lostcancel 检查能发现其中一部分遗漏。[1:2][3:1]

现在可以发出取消请求了。还差一个问题:调用 cancel 返回以后,子任务是不是已经退出?

不是。CancelFunc 不等待子任务结束,Done 甚至可能在 cancel 返回后才异步关闭。它可以重复调用,也允许多个 goroutine 同时调用;第一次之后的调用不再产生进一步效果。[4]

所以,想知道退出信号是否发出,就观察 Done;想确认后台循环真的结束、连接清理完毕或结果已经收齐,还要另外等待 WaitGroup、errgroup.Wait 或完成 channel。

发出退出信号和等到工作结束,是程序里需要分别完成的两步。

defer cancel() 的位置也决定了资源何时释放。在长期运行的循环里不断登记 defer,清理会一直等到外层函数返回。若希望每轮结束就清理,可以把一轮工作提取为小函数,在函数内 defer;也可以在本轮结束时显式 cancel。这个建议来自 defer 的执行时机和取消函数的资源语义。[5][4:1]

子级只能收紧有效 deadline ​

取消关系还决定了时间预算的上限。假设父级还有一小时,子级即使请求两小时,也只能沿用父级更早的截止时刻。下面的程序和配套测试直接比较这两个 deadline:

go
p, pc := context.WithDeadline(context.Background(), time.Now().Add(time.Hour))
defer pc()
c, cc := context.WithTimeout(p, 2*time.Hour)
pd, _ := p.Deadline()
cd, _ := c.Deadline()
fmt.Println("earlier deadline:", pd.Equal(cd))
cc()
fmt.Println("child stopped; parent active:", c.Err() != nil, p.Err() == nil)

实际输出:

text
earlier deadline: true
child stopped; parent active: true true

先看第一行:子级申请了两小时,但它的实际截止时间仍与父级相同。再看第二行:取消子级以后,父级没有跟着取消。[6]

这就允许我们同时表达两层约束:整个请求最多两秒,数据库这一步最多两百毫秒。每一步可以有自己的预算,但不能把整个请求的截止时间往后推。反过来,若每层都从 Background 单独创建超时,它们就接不到原请求的共同约束。

交互实验:context 取消传播与有效期限 ​

GO 实验室 · CONTEXT

取消一个分支,谁会收到信号?

点一下数据库的“取消”,看看内部等待会不会一起变化,再对比父级和 HTTP。

这是虚拟演示,没有运行 Go。它只展示退出信号,不判断任务是否真的结束。

01 输入 · 设置同一条请求的预算

切换案例或修改预算会从头开始。所有节点都在 t=0 创建,单位为 ms,0 表示立即到期。

02 点一下,看信号传到哪里

时刻 0 ms未关闭 4已关闭 0
请求父级

○ Done 未关闭截止 400 ms

├ 数据库

○ Done 未关闭截止 150 ms

↳ 内部等待随数据库

○ Done 未关闭截止 150 ms

├ HTTP

○ Done 未关闭截止 300 ms

先试着取消数据库,看看哪些节点会跟着变。

取消只向下传,不影响父级或兄弟。已关闭表示收到退出信号,不是任务完成。

时间怎样推进?

每步前进 50 ms;途中到期的节点按各自 deadline 结算。取消不推进时间,也会暂停自动演练。内部等待由数据库用 WithCancel 派生,继承它的 deadline。

03 想看具体数值?展开核对

上面看变化,下面查每个节点的 deadline、Err 和信号时刻。

请求父级 · Err = nil

派生自:Background(根,不参与计数)

请求父级
○ 未收到退出信号
申请的截止时间
t=400 ms
有效 deadline
t=400 ms
更早约束来自
请求父级
Done()
未关闭
Err()
nil
信号发生时刻
尚未发生

未取消,也不代表任务仍在运行。

数据库分支 · Err = nil

派生自:请求父级

数据库分支
○ 未收到退出信号
申请的截止时间
t=150 ms
有效 deadline
t=150 ms
更早约束来自
数据库分支
Done()
未关闭
Err()
nil
信号发生时刻
尚未发生

未取消,也不代表任务仍在运行。

数据库内部等待 · Err = nil

派生自:数据库分支

数据库内部等待
○ 未收到退出信号
申请的截止时间
不另设,继承父级
有效 deadline
t=150 ms
更早约束来自
数据库分支
Done()
未关闭
Err()
nil
信号发生时刻
尚未发生

未取消,也不代表任务仍在运行。

HTTP 分支 · Err = nil

派生自:请求父级

HTTP 分支
○ 未收到退出信号
申请的截止时间
t=300 ms
有效 deadline
t=300 ms
更早约束来自
HTTP 分支
Done()
未关闭
Err()
nil
信号发生时刻
尚未发生

未取消,也不代表任务仍在运行。

查看本轮操作记录(1 条)
  1. 操作 0 · 所有节点在 t=0 创建。先预测:数据库先到期时,父级和 HTTP 的 Done 会关闭吗?

04 接下来可以这样试

先取消数据库

选“先取消一个分支”,点击“取消数据库分支”。内部等待收到信号,父级与 HTTP 不变;再取消父级,比较其余节点。

给子级更多时间

选“父级预算更早”。数据库申请 400 ms,实际 deadline 仍是 150 ms。把父预算改为 175,再逐步看精确的信号时刻。

比较取消与到期

同一节点先手动取消,Err 是 Canceled;先到期则是 DeadlineExceeded。已经确定的 Err 不会被后续取消改写。

取消不等于等待 goroutine 结束(join)。CancelFunc 不等待任务退出;代码仍需响应 Done,并用 WaitGroup、完成 channel 等确认结束。树中的四个 context 也不等于四个 goroutine。

这个模型简化了什么?

所有预算都从同一个虚拟 t=0 起算。现实中 WithTimeout 从各自创建时刻起算;这里为了直接比较,更像给每个节点设置相对同一起点的 WithDeadline。更改输入会重建整棵树,不会修改已创建 context 的 deadline。

每一步展示取消传播完成后的状态。真实 Done 的关闭可能在 cancel 返回后异步发生,计时器与 goroutine 也有调度延迟;这里的精确虚拟时刻不是实时性保证。相同 deadline 的节点按父级在前显示,不代表运行顺序。

手动取消不删除既有 deadline,Err 一旦非 nil 就保持不变。本实验不运行任务,不判断 I/O 是否停止、是否有副作用,也不统计资源是否清理完成。真实代码仍应接住并调用每个派生 context 的 cancel。

机制依据:Go 官方文档

文档核对:2026-10-14。页面用 JavaScript 演示有限规则;Node 测试验证教学模型,不等于真实 Go 执行。

Err 回答取消类别,Cause 保留具体原因 ​

工作收到退出信号以后,调用方还要处理错误。只看 Err(),能知道的是取消类别:尚未取消时为 nil,手动取消后为 context.Canceled,到期后为 context.DeadlineExceeded。

但排查问题时,你可能还想知道是谁决定取消的。是用户离开了页面,还是某个并行分支先失败了?这类更具体的原因可以交给 context.Cause 保留。[7]

go
parent, cancel := context.WithCancelCause(context.Background())
child, childCancel := context.WithCancel(parent)
defer childCancel()
cancel(errors.New("client left"))
fmt.Println(child.Err())
fmt.Println(context.Cause(child))

这段代码在本章程序中的输出为:

text
context canceled
client left

第一行仍是统一的取消类别,第二行才是这次传入的具体原因。调用方因此既能判断“取消了”,也能保留排查所需的线索。

先发生的取消还有一个重要影响:如果子级已经带着自己的原因取消,父级随后取消,不会把子级原来的原因覆盖掉。也不要把 cancel(nil) 理解为撤销取消。它照样会取消,只是原因会落到 context.Canceled。[7:1]

这些入口的名字很相近,使用时可以先问自己:原因是现在传入,还是等到期时使用?

手动取消时传入原因,用 WithCancelCause。WithTimeoutCause 和 WithDeadlineCause 则预先保存一个到期原因;提前调用它们返回的普通 cancel,并不会设置这个预设原因。版本也要对应:CancelCause 与 Cause 在 Go 1.20 加入,TimeoutCause、DeadlineCause 在 Go 1.21 加入。[7:2]

退出原因也不能代替所有业务错误。网络操作可能已经失败,context 随后才被取消;这时若只检查 ctx.Err() != nil,就可能把具体错误覆盖成笼统的 canceled。错误和取消在并发中可能接近发生,函数应明确约定优先报告哪个结果,让调用者保留足够的排查线索。

有 Done 并不代表代码就会停 ​

把 context 传进函数,只完成了信号传递。函数还要在可能长时间等待的位置响应它。例如,向 channel 发送结果时,可以同时等待发送成功和 context 取消:

go
select {
case result <- value:
case <-ctx.Done():
    return ctx.Err()
}

若把 result <- value 写在 select 外,发送一旦阻塞,ctx 不会自动中断它。第三方网络函数若忽略 context,也有同样的问题。http.NewRequestWithContext、数据库的 QueryContext 等入口能把信号继续传向实际等待的位置,但最终能否及时取消,仍取决于底层实现。[2:1][8][9]

不过,写进 select 也不等于“取消之后绝不再做事”。如果发送结果和 Done 同时就绪,select 仍可能选中发送分支。[10]

还有一种情况发生在网络另一头:请求已经发给远程服务,你在本地取消,不会让对方已经完成的操作自动回滚。遇到付款、写库这类动作,还需要靠事务、幂等键或状态查询约定结果怎样确认。context 传递退出意图,不负责撤销已经发生的业务动作。

WithValue 只放请求范围内需要跨层流转的元数据 ​

前面一直在讨论工作什么时候停。另一个常见需求是:这条调用链上的函数,怎样知道自己处理的是哪一次请求?

请求 ID、追踪标识这类请求范围内的数据,可以通过 Value 向下传。它们适合跨越多层 API,而不必在每一层单独列一个参数。不过,值不会自己出现在网络请求里;跨进程时,仍需要协议或追踪库显式传递。[11]

这也给出了使用范围。数据库句柄、查询必需的用户 ID、重试配置都有各自明确的接口,不应因为 ctx 随手可用,就全部塞进 Value。

为了让调用方不必直接操作键,本文为 request ID 提供两个存取入口:

go
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
}

程序先在父链绑定 "req-7",再从派生的 child 读取。实际输出为:

text
req-7

为什么不直接用字符串 "request_id" 作键?因为另一个包也可能选中同一个字符串。这里定义包内未导出的键类型,把键的使用范围留在当前包,再通过两个函数对外提供存取。

key 必须可比较,slice、map 和函数不能用;文档也不建议直接使用 string 等内建类型。读取时会沿父链查找。如果后代为同一个键绑定新值,后代会读到新值,父 context 中的值仍然不变。[11:1]

要传递的内容是否适合放 Value建议的写法
查询的用户 ID、订单 ID调用签名看不出必需依赖,缺失到运行时才发现命名参数或请求结构体
重试次数、分页大小用元数据通道代替配置系统options 或配置结构体
共享客户端、仓库对象生命周期和依赖关系被隐藏构造器注入到对象
请求追踪 ID确实要跨多个 API 层透传私有 key 加类型化存取函数

接口怎样接收 context,也会影响调用者是否容易用对。包文档建议通常把 ctx 放在第一个参数,不传 nil;还没确定应传什么时,用 TODO。通常也应在每次操作时传入 context,而非把它存进结构体,以免不同调用意外共用旧 deadline。若 API 确实需要例外,应能说明相应的生命周期理由。[1:3]

两个扩展入口,用前先看清谁负责善后 ​

基本的取消链讲清楚后,还有两个入口值得认识。它们解决的是特殊需要,不必为了“用全 API”而加入普通代码。

第一个是 Go 1.21 加入的 AfterFunc(ctx, f):ctx 取消后,它另启一个 goroutine 运行 f,适合需要由取消事件触发后续动作的场景。返回的 stop 函数可以尝试阻止 f 启动;返回 true 表示阻止成功,返回 false 却不能证明 f 已经执行完了。若其他代码需要等回调收尾,仍要自行安排等待。[12]

另一个是同版加入的 WithoutCancel(parent)。它保留 Value 的查找关系,但切断父级的取消和 deadline;返回 context 的 Done、Err,以及对它调用 Cause 的结果都为 nil。[13]

这给了某些工作脱离当前请求的机会,也把责任交回给了调用方:新的时间预算由谁设定,完成时由谁等待,失败以后由谁处理?这些问题要先安排好。遇到超时就直接套一层 WithoutCancel,只会让原来的约束消失。

依据与本次验证 ​

本章验证记录来自 Go 1.27.2、Linux/amd64:程序实际运行,四项测试分别检查子取消不影响父级、更早的 deadline 生效、Cause 与 Value 传递,以及阻塞发送响应取消;go test -race -count=1 ./... 和 go vet ./... 均通过。这些结果只覆盖所列示例,没有连接真实数据库,也不能证明所有第三方驱动都能即时取消。AfterFunc、WithoutCancel 的说明依据固定版本官方 API 文档,未另做时序实验。

完整代码与复现方法 ​

若要复现上述结果,把下面的文件按标出的相对路径保存到同一目录,再使用 Go 1.27.2 运行这些命令:

sh
go run .
go test -race -count=1 ./...
go vet ./...

go.mod ​

go.mod
module example.com/go-articles/03_context

go 1.25.0

main.go ​

代码块 · 53 行 · 点击收起 / 展开
go
package main

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

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
}
func WaitForStop(ctx context.Context) error {
	<-ctx.Done()
	return ctx.Err()
}
func Send(ctx context.Context, result chan<- int, value int) error {
	select {
	case result <- value:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}
func main() {
	parent, cancel := context.WithCancelCause(context.Background())
	child, childCancel := context.WithCancel(WithRequestID(parent, "req-7"))
	defer childCancel()
	result := make(chan error, 1)
	go func() { result <- WaitForStop(child) }()
	cancel(errors.New("client left"))
	fmt.Println(<-result)
	fmt.Println(context.Cause(child))
	id, _ := RequestID(child)
	fmt.Println(id)
	expired, stop := context.WithTimeout(context.Background(), 0)
	defer stop()
	fmt.Println(WaitForStop(expired))
	p, pc := context.WithDeadline(context.Background(), time.Now().Add(time.Hour))
	defer pc()
	c, cc := context.WithTimeout(p, 2*time.Hour)
	pd, _ := p.Deadline()
	cd, _ := c.Deadline()
	fmt.Println("earlier deadline:", pd.Equal(cd))
	cc()
	fmt.Println("child stopped; parent active:", c.Err() != nil, p.Err() == nil)
}

main_test.go ​

代码块 · 51 行 · 点击收起 / 展开
go
package main

import (
	"context"
	"errors"
	"testing"
	"time"
)

func TestCancelDoesNotReachParent(t *testing.T) {
	parent, cancel := context.WithCancel(context.Background())
	defer cancel()
	child, stop := context.WithCancel(parent)
	stop()
	if !errors.Is(child.Err(), context.Canceled) {
		t.Fatal(child.Err())
	}
	if parent.Err() != nil {
		t.Fatal("child canceled parent")
	}
}
func TestEarlierDeadlineWins(t *testing.T) {
	parent, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Hour))
	defer cancel()
	child, stop := context.WithTimeout(parent, 2*time.Hour)
	defer stop()
	a, _ := parent.Deadline()
	b, _ := child.Deadline()
	if !a.Equal(b) {
		t.Fatalf("%s != %s", a, b)
	}
}
func TestCauseAndValue(t *testing.T) {
	want := errors.New("owner stopped")
	ctx, cancel := context.WithCancelCause(WithRequestID(context.Background(), "r1"))
	cancel(want)
	if !errors.Is(context.Cause(ctx), want) {
		t.Fatal(context.Cause(ctx))
	}
	if id, ok := RequestID(ctx); !ok || id != "r1" {
		t.Fatal(id, ok)
	}
}

func TestSendCanceled(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	if err := Send(ctx, make(chan int), 7); !errors.Is(err, context.Canceled) {
		t.Fatal(err)
	}
}

  1. context,Go 1.27.2 包说明。 ↩︎ ↩︎ ↩︎ ↩︎

  2. Context 接口。 ↩︎ ↩︎

  3. WithCancel、WithTimeout、Background、TODO。 ↩︎ ↩︎

  4. CancelFunc与 Context.Done 约定。 ↩︎ ↩︎

  5. 规范:Defer statements。 ↩︎

  6. WithDeadline,更早 deadline 的规则。 ↩︎

  7. Cause、CancelCauseFunc、WithTimeoutCause。API 页面标注引入版本。 ↩︎ ↩︎ ↩︎

  8. http.NewRequestWithContext。 ↩︎

  9. database/sql,包说明对驱动不支持 context 取消的限定。 ↩︎

  10. 规范:Select statements。 ↩︎

  11. WithValue。 ↩︎ ↩︎

  12. AfterFunc。 ↩︎

  13. WithoutCancel。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准