取消不只是停止,还要知道为什么停止
Go 的 context.Context 常用于请求范围控制:客户端断开,请求超时,后台任务停止,外部调用取消。早期我们通常只能通过 ctx.Err() 知道是 context.Canceled 还是 context.DeadlineExceeded。这已经有用,但在复杂服务里,有时还想知道更具体的原因:是用户主动取消,还是权限检查失败后取消后续任务,还是上游熔断导致停止。
现代 Go 提供了带取消原因的 context API,可以在取消时附带一个错误原因,再由下游读取。它不应该被滥用成业务数据传递通道,但对排查并发任务为什么停止很有帮助。
Go 1.20 引入的 WithCancelCause、1.21 引入的 AfterFunc,以及后续版本的改进,让 context 的能力更加完善。本文将系统讲解这些特性的原理和实战用法。
Context 基础回顾
在开始深入之前,先快速回顾 context 的基础用法。
package main
import (
"context"
"fmt"
"time"
)
func main() {
// WithCancel:可手动取消
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
<-ctx.Done()
fmt.Println("cancelled:", ctx.Err())
}()
cancel()
time.Sleep(100 * time.Millisecond)
}
超时控制:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
case <-time.After(3 * time.Second):
fmt.Println("completed normally")
case <-ctx.Done():
fmt.Println("timeout:", ctx.Err())
}
}
ctx.Err() 常见返回:
context canceled
context deadline exceeded
服务端 handler 中应该把请求 context 传下去:
func handler(w http.ResponseWriter, r *http.Request) {
user, err := service.GetUser(r.Context(), id)
// ...
}
这样客户端断开或请求超时时,数据库和 HTTP 客户端也有机会停止。
WithCancelCause 源码原理
Go 1.20 引入了 WithCancelCause,允许在取消 context 时附带一个原因错误。
package main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
// 取消时附带原因
cancel(fmt.Errorf("quota exceeded for user 12345"))
<-ctx.Done()
fmt.Println("general error:", ctx.Err())
fmt.Println("cause:", context.Cause(ctx))
}
运行结果:
general error: context canceled
cause: quota exceeded for user 12345
关键区别:
ctx.Err()仍然返回通用的context.Canceled,保持兼容性context.Cause(ctx)返回你传入的具体原因
CancelCauseFunc 的签名:
type CancelCauseFunc func(cause error)
当你调用 cancel(cause) 时,内部的实现会存储这个 cause 错误,后续调用 context.Cause(ctx) 就能获取到它。如果多次调用 cancel,只有第一次的原因会被保留。
CancelCauseFunc 实战用法
一个后台任务示例,展示如何在任务被取消时获取具体原因:
package main
import (
"context"
"fmt"
"time"
)
func runJob(ctx context.Context) error {
select {
case <-time.After(5 * time.Second):
fmt.Println("job completed")
return nil
case <-ctx.Done():
cause := context.Cause(ctx)
return fmt.Errorf("job stopped: %w", cause)
}
}
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
go func() {
err := runJob(ctx)
if err != nil {
fmt.Println("job error:", err)
}
}()
time.Sleep(1 * time.Second)
cancel(fmt.Errorf("service shutting down for maintenance"))
time.Sleep(100 * time.Millisecond)
}
输出:
job error: job stopped: service shutting down for maintenance
这种方式让错误信息远比单纯的 context canceled 更有信息量。
超时也可以带原因
Go 1.20 同时引入了 WithTimeoutCause:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeoutCause(
context.Background(),
2*time.Second,
fmt.Errorf("call payment provider timeout after 2s"),
)
defer cancel()
select {
case <-time.After(3 * time.Second):
fmt.Println("done")
case <-ctx.Done():
fmt.Println("general:", ctx.Err())
fmt.Println("cause:", context.Cause(ctx))
}
}
这在多个外部调用可能超时的场景中特别有用,你能精确知道是哪个环节超时了:
type Service struct{}
func (s *Service) CallPayment(ctx context.Context) error {
ctx, cancel := context.WithTimeoutCause(ctx,
3*time.Second,
fmt.Errorf("payment gateway timeout"),
)
defer cancel()
// 调用支付网关...
select {
case <-ctx.Done():
return context.Cause(ctx)
case result := <-paymentResult:
return result.Err
}
}
func (s *Service) CallInventory(ctx context.Context) error {
ctx, cancel := context.WithTimeoutCause(ctx,
1*time.Second,
fmt.Errorf("inventory service timeout"),
)
defer cancel()
// 调用库存服务...
select {
case <-ctx.Done():
return context.Cause(ctx)
case result := <-inventoryResult:
return result.Err
}
}
但要注意,原因应该是错误上下文,不要把大量业务数据塞进去。context 的职责仍然是取消、超时和请求范围值,不是通用参数包。
context.WithoutCancel(Go 1.20+)
Go 1.20 还引入了 WithoutCancel,它创建一个不受父 context 取消影响的新 context:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
// 派生一个不受取消影响的子 context
bgCtx := context.WithoutCancel(ctx)
go func() {
<-bgCtx.Done()
fmt.Println("bgCtx done - should never print")
}()
// 取消父 context
cancel()
time.Sleep(100 * time.Millisecond)
// bgCtx 仍然有效
fmt.Println("bgCtx error:", bgCtx.Err())
}
这个特性在以下场景特别有用:
- 收到取消通知但需要在后台优雅完成清理工作
- 将某个独立的长时间运行操作与请求上下文解耦
- 创建不随请求结束而终止的后台任务
func handleRequest(ctx context.Context) {
// 创建一个不受请求取消影响的 cleanup context
cleanupCtx := context.WithoutCancel(ctx)
// 即使请求被取消,cleanup 也会完成
defer func() {
go performCleanup(cleanupCtx)
}()
// 处理请求...
}
context.AfterFunc(Go 1.21+)
Go 1.21 引入了 context.AfterFunc,它为 context 的取消事件注册回调:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx, func() {
fmt.Println("context was cancelled - performing cleanup")
})
// ... 一些操作 ...
cancel()
// 如果不再需要回调,可以停止它
// stop()
time.Sleep(100 * time.Millisecond)
}
AfterFunc 与直接在 goroutine 中监听 ctx.Done() 的区别:
AfterFunc的回调由 Go 运行时直接调用,可能比新建 goroutine 更高效- 返回的
stop函数可以在回调执行前取消注册 - 更适合轻量级的清理工作
在并发任务中传播取消原因
假设多个 worker 处理任务,只要一个发现严重错误,就取消其他 worker:
package main
import (
"context"
"fmt"
"sync"
)
type Job struct {
ID int
}
func processJob(ctx context.Context, job Job) error {
// 模拟任务处理
if job.ID%3 == 0 {
return fmt.Errorf("job %d failed", job.ID)
}
return nil
}
func runWorkers(ctx context.Context, jobs []Job) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
var wg sync.WaitGroup
for _, job := range jobs {
job := job
wg.Add(1)
go func() {
defer wg.Done()
if err := processJob(ctx, job); err != nil {
cancel(fmt.Errorf("job %d failed: %w", job.ID, err))
}
}()
}
wg.Wait()
// 检查是否因为某个任务失败而取消
if cause := context.Cause(ctx); cause != nil {
return fmt.Errorf("workers failed: %w", cause)
}
return nil
}
func main() {
jobs := []Job{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}, {ID: 5}}
err := runWorkers(context.Background(), jobs)
if err != nil {
fmt.Println("error:", err)
}
}
这个示例展示思路:第一个失败的任务可以取消整个任务组,并带上失败原因。真实项目里还要注意并发错误收集、重复 cancel 和返回 nil 的情况。
改进版,避免重复 cancel 的竞态:
func runWorkersV2(ctx context.Context, jobs []Job) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
var (
wg sync.WaitGroup
once sync.Once
)
for _, job := range jobs {
job := job
wg.Add(1)
go func() {
defer wg.Done()
if err := processJob(ctx, job); err != nil {
once.Do(func() {
cancel(fmt.Errorf("first failure: job %d: %w", job.ID, err))
})
}
}()
}
wg.Wait()
if cause := context.Cause(ctx); cause != nil {
return fmt.Errorf("workers failed: %w", cause)
}
return nil
}
梯度超时设计
在级联调用的系统中,建议采用梯度超时设计,让下游的超时长于上游:
package main
import (
"context"
"fmt"
"time"
)
// Gateway 层:总超时 10s
func gatewayHandler(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return callServiceA(ctx)
}
// Service A:预留 8s
func callServiceA(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if err := callDatabase(ctx); err != nil {
return fmt.Errorf("db failed: %w", err)
}
return callServiceB(ctx)
}
// Service B:预留 5s
func callServiceB(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 调用下游...
return nil
}
// Database:预留 2s
func callDatabase(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// 查询数据库...
return nil
}
梯度超时的设计原则:
- 每一级的超时都短于父级,确保父级有时间处理超时
- 离开当前层时 context 仍然有效,可以返回错误信息
- 底层超时越短,越能快速失败,避免资源浪费
数据库查询中的 context 传递
数据库查询应该始终传入 context,这样当请求被取消或超时时,数据库操作也能及时停止:
package main
import (
"context"
"database/sql"
"fmt"
"time"
)
func getUserByID(ctx context.Context, db *sql.DB, userID int) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
row := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1",
userID,
)
var user User
err := row.Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
return &user, nil
}
type User struct {
ID int
Name string
Email string
}
使用 QueryRowContext、ExecContext 等方法替代不带 context 的版本是现代 Go 开发的基本要求。当客户端断开连接时,数据库查询会自动被取消,释放连接资源。
gRPC metadata 中的 deadline 传递
在 gRPC 服务中,context 的 deadline 会自动通过 metadata 传递。你也可以手动传递取消原因:
package main
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// UnaryInterceptor 传递取消原因
func UnaryInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
resp, err := handler(ctx, req)
if err != nil {
// 如果是 context 相关错误,包装成 gRPC status
if ctx.Err() != nil {
cause := context.Cause(ctx)
if cause != nil {
return nil, status.Error(codes.Canceled, cause.Error())
}
}
}
return resp, err
}
}
调试 context 泄漏方法
context 泄漏是常见的 Go 程序问题,表现为 goroutine 数量持续增长但不减少。
检查 context 泄漏的方法:
- 使用 pprof goroutine profile:
curl http://localhost:6060/debug/pprof/goroutine?debug=1
- 检查是否有未关闭的 context 派生者:
func potentialLeak() {
ctx, cancel := context.WithCancel(context.Background())
_ = ctx // 使用了 context
// 忘记调用 cancel()
}
改进版确保 cancel 被调用:
func fixed() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_ = ctx
}
- 使用 go vet 检查常见的 context 问题
- 在 HTTP handler 中确保不使用 detached goroutine 持有 request context
常见陷阱与最佳实践
陷阱一:在 goroutine 中修改父 context
不要试图修改已经传递出去的 context。context 是不可变的,任何改动都应该创建新的派生 context。
陷阱二:过度使用 context 传递业务数据
context 适合传递请求范围的元数据(trace ID、user ID),不适合传递业务参数。业务数据应该通过函数参数传递。
陷阱三:忽略 cancel 的调用
每个 WithCancel、WithTimeout、WithDeadline 创建的 cancel 函数都应该被调用。使用 defer cancel() 是最安全的方式。
陷阱四:嵌套超时没有留余量
每层的 timeout 应该比父层短,否则父层已经超时,子层的 cancel 可能无法正确执行。
陷阱五:Cause 返回 nil 时的误判
cause := context.Cause(ctx)
if cause != nil {
// 确实被取消了且有原因
}
当 cause 返回 nil 时,可能 context 并未被取消。要结合 ctx.Err() 一起判断。
小结
context 的核心仍然是取消和超时传播。Go 1.20+ 引入的带原因取消让排查更有上下文,Go 1.21 的 AfterFunc 提供了更灵活的回调机制。
关键要点总结:
WithCancelCause让你知道具体的取消原因,而不仅是context.CanceledWithTimeoutCause可以在超时时提供自定义错误信息WithoutCancel适合创建不随请求结束而终止的后台任务- 梯度超时设计能确保资源快速释放
- 永远记得调用 cancel 函数,使用 defer 是最安全的方式
不要把 context 当成业务参数容器。取消原因应该短小、明确、面向排查。普通请求数据仍然应该通过函数参数传递。用好 context 的这些新特性,你的并发程序会更加健壮、可排查。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。