先取消数据库
选“先取消一个分支”,点击“取消数据库分支”。内部等待收到信号,父级与 HTTP 不变;再取消父级,比较其余节点。
一个 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
图中,实线连接 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]
函数通常把 context 放在第一个参数,命名为 ctx。需要进一步限制工作时长时,就从它派生,并保留返回的取消函数。下面用根 context 和零时长演示到期与清理的写法:
expired, stop := context.WithTimeout(context.Background(), 0)
defer stop()
fmt.Println(WaitForStop(expired))WaitForStop 的全部工作是 <-ctx.Done(); return ctx.Err():等待退出信号,然后返回取消类别。本章程序的实际输出为:
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:
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)实际输出:
earlier deadline: true
child stopped; parent active: true true先看第一行:子级申请了两小时,但它的实际截止时间仍与父级相同。再看第二行:取消子级以后,父级没有跟着取消。[6]
这就允许我们同时表达两层约束:整个请求最多两秒,数据库这一步最多两百毫秒。每一步可以有自己的预算,但不能把整个请求的截止时间往后推。反过来,若每层都从 Background 单独创建超时,它们就接不到原请求的共同约束。
GO 实验室 · CONTEXT
点一下数据库的“取消”,看看内部等待会不会一起变化,再对比父级和 HTTP。
这是虚拟演示,没有运行 Go。它只展示退出信号,不判断任务是否真的结束。
切换案例或修改预算会从头开始。所有节点都在 t=0 创建,单位为 ms,0 表示立即到期。
○ Done 未关闭截止 400 ms
○ Done 未关闭截止 150 ms
○ Done 未关闭截止 150 ms
○ Done 未关闭截止 300 ms
先试着取消数据库,看看哪些节点会跟着变。
取消只向下传,不影响父级或兄弟。已关闭表示收到退出信号,不是任务完成。
每步前进 50 ms;途中到期的节点按各自 deadline 结算。取消不推进时间,也会暂停自动演练。内部等待由数据库用 WithCancel 派生,继承它的 deadline。
上面看变化,下面查每个节点的 deadline、Err 和信号时刻。
派生自:Background(根,不参与计数)
nil未取消,也不代表任务仍在运行。
派生自:请求父级
nil未取消,也不代表任务仍在运行。
派生自:数据库分支
nil未取消,也不代表任务仍在运行。
派生自:请求父级
nil未取消,也不代表任务仍在运行。
选“先取消一个分支”,点击“取消数据库分支”。内部等待收到信号,父级与 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。
文档核对:2026-10-14。页面用 JavaScript 演示有限规则;Node 测试验证教学模型,不等于真实 Go 执行。
工作收到退出信号以后,调用方还要处理错误。只看 Err(),能知道的是取消类别:尚未取消时为 nil,手动取消后为 context.Canceled,到期后为 context.DeadlineExceeded。
但排查问题时,你可能还想知道是谁决定取消的。是用户离开了页面,还是某个并行分支先失败了?这类更具体的原因可以交给 context.Cause 保留。[7]
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))这段代码在本章程序中的输出为:
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。错误和取消在并发中可能接近发生,函数应明确约定优先报告哪个结果,让调用者保留足够的排查线索。
把 context 传进函数,只完成了信号传递。函数还要在可能长时间等待的位置响应它。例如,向 channel 发送结果时,可以同时等待发送成功和 context 取消:
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 传递退出意图,不负责撤销已经发生的业务动作。
前面一直在讨论工作什么时候停。另一个常见需求是:这条调用链上的函数,怎样知道自己处理的是哪一次请求?
请求 ID、追踪标识这类请求范围内的数据,可以通过 Value 向下传。它们适合跨越多层 API,而不必在每一层单独列一个参数。不过,值不会自己出现在网络请求里;跨进程时,仍需要协议或追踪库显式传递。[11]
这也给出了使用范围。数据库句柄、查询必需的用户 ID、重试配置都有各自明确的接口,不应因为 ctx 随手可用,就全部塞进 Value。
为了让调用方不必直接操作键,本文为 request ID 提供两个存取入口:
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 读取。实际输出为:
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 运行这些命令:
go run .
go test -race -count=1 ./...
go vet ./...module example.com/go-articles/03_context
go 1.25.0package 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)
}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)
}
}CancelFunc与 Context.Done 约定。 ↩︎ ↩︎
WithDeadline,更早 deadline 的规则。 ↩︎
Cause、CancelCauseFunc、WithTimeoutCause。API 页面标注引入版本。 ↩︎ ↩︎ ↩︎
database/sql,包说明对驱动不支持 context 取消的限定。 ↩︎