19-context 包详解:让并发任务可取消、可超时

🔖 Go 语言学习教程 · 第 19 篇

📌 本系列基于 Go 1.23 编写,代码兼容 Go 1.21+

全文约 4300 字 | 阅读约 14 分钟


欢迎来到第三部分的第一篇 👋

前面的并发章节已经学会启动 goroutine、使用 channel 和控制共享状态。但一个完整的服务还需要回答几个问题:请求取消后,后台任务如何停止?数据库查询超时怎么办?一个请求携带的 trace ID 如何传给下游?

context 包正是为这些问题设计的。它可以在调用链中传播取消信号、截止时间和少量请求范围的数据。本篇会用 worker、HTTP 请求和服务调用三个场景理解它的正确用法。


✅ 上一篇练习题答案

以下是上一篇练习题的参考答案,欢迎在评论区分享你的解法~

1. worker pool

生产者创建任务 channel,多个 worker 使用 for range 消费;生产者发送完毕后关闭 channel,主 goroutine 用 WaitGroup 等待所有 worker 退出。

2. 两秒超时

可以创建定时器并在 select 中同时等待任务结果和 timer.C。更推荐把超时封装成 context.WithTimeout,让任务内部也能观察取消信号。

3. 多发送者 channel 的关闭责任

接收方无法确认其他发送者是否已经结束。由接收方关闭可能导致仍在发送的 goroutine 触发 panic,因此一般由生产者或统一的协调者负责关闭。

4. 竞态检测

执行 go test -race ./... 可以检测同一内存位置的并发读写。给共享计数器加互斥锁或使用原子操作即可修复,但仍要确认业务上的读写顺序是否正确。


🧐 一、context.Context 是什么?

Context 是一个用于在 API 边界之间传递取消信号、截止时间和请求范围数据的接口:

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

平时最常用的是:

  • Done():任务被取消时关闭的 channel;
  • Err():返回 context.Canceled 或 context.DeadlineExceeded;
  • Deadline():查询截止时间;
  • Value():读取请求范围数据。

Context 应该作为函数的第一个参数传入:

go 复制代码
func QueryUser(ctx context.Context, id int) (User, error) {
	// ...
}

不要把 context 存进结构体,也不要用它传递普通业务参数。


📖 二、根 Context 与派生 Context

程序入口通常从根 Context 开始:

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

HTTP 服务、测试和命令行程序也经常使用:

go 复制代码
ctx := context.TODO()

TODO 表示调用方还没有决定合适的父 Context,适合临时占位;明确知道没有取消需求时使用 Background。

派生 Context 会继承父 Context 的取消状态:

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

调用 cancel() 后,ctx.Done() 会被关闭,所有监听这个 Context 的任务都能收到信号。

取消函数应该尽快调用,即使任务已经成功完成。这样可以释放定时器和父 Context 持有的资源。


💡 三、WithCancel:主动取消任务

下面是一个可取消的 worker:

go 复制代码
func worker(ctx context.Context, jobs <-chan int) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker 退出:", ctx.Err())
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			fmt.Println("处理任务:", job)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	jobs := make(chan int)
	go worker(ctx, jobs)

	jobs <- 1
	cancel()
}

任务内部必须主动选择 ctx.Done()。如果它只阻塞在一个不会结束的 channel 上,外部取消也无法让它退出。

对于多级调用链,子函数应继续接收并传递同一个 Context:

go 复制代码
func Handle(ctx context.Context) error {
	return service(ctx)
}

func service(ctx context.Context) error {
	return repository(ctx)
}

这样请求入口的取消信号才能一路传到最底层。


⏱️ 四、WithTimeout 和 WithDeadline

WithTimeout

相对当前时间设置超时:

go 复制代码
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

result, err := callRemote(ctx)
if errors.Is(err, context.DeadlineExceeded) {
	fmt.Println("远程调用超时")
}

WithDeadline

指定绝对截止时间:

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

如果父 Context 的截止时间更早,子 Context 不会把它延长。超时只是发出取消信号,真正的底层操作必须支持 Context 才能及时停止。


🧩 五、正确处理取消和错误

任务通常需要同时处理业务错误和 Context 错误:

go 复制代码
func process(ctx context.Context) error {
	select {
	case <-time.After(3 * time.Second):
		return nil
	case <-ctx.Done():
		return fmt.Errorf("处理任务: %w", ctx.Err())
	}
}

调用方可以使用 errors.Is:

go 复制代码
if err := process(ctx); err != nil {
	switch {
	case errors.Is(err, context.Canceled):
		fmt.Println("调用方主动取消")
	case errors.Is(err, context.DeadlineExceeded):
		fmt.Println("超过截止时间")
	default:
		fmt.Println("业务失败:", err)
	}
}

不要把所有取消都记录成系统异常。用户主动断开连接和服务内部故障是不同的事件,日志级别和告警策略也应不同。


🌐 六、Context 与 HTTP 请求

HTTP 请求自带 Context:

go 复制代码
type User struct {
	Name string
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	result, err := QueryUser(ctx, 1)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	fmt.Fprintln(w, result.Name)
}

当客户端断开连接,请求 Context 通常会被取消。数据库查询、下游 HTTP 调用和后台计算都应该使用这个 Context:

go 复制代码
req, err := http.NewRequestWithContext(
	ctx,
	http.MethodGet,
	"https://example.com/user",
	nil,
)
if err != nil {
	return err
}

resp, err := http.DefaultClient.Do(req)

如果忽略请求 Context,客户端已经离开后,服务端仍可能继续消耗连接、CPU 和数据库资源。


🏷️ 七、WithValue 的正确用途

WithValue 适合传递请求范围的元数据,例如 trace ID、租户 ID 或认证后的主体信息:

go 复制代码
type contextKey string

const traceIDKey contextKey = "traceID"

func withTraceID(ctx context.Context, traceID string) context.Context {
	return context.WithValue(ctx, traceIDKey, traceID)
}

func traceID(ctx context.Context) string {
	value, _ := ctx.Value(traceIDKey).(string)
	return value
}

Key 不要使用裸字符串或导出的基础类型,避免不同包之间发生冲突。也不要把数据库连接、可选参数、业务对象和大量配置塞进 Context;这些内容应该通过显式参数传递。

WithValue 传递的是请求范围数据,不是全局变量的替代品。


🛠️ 八、一个可取消的并发流水线

go 复制代码
func generate(ctx context.Context, output chan<- int) {
	defer close(output)
	for i := 1; i <= 10; i++ {
		select {
		case output <- i * i:
		case <-ctx.Done():
			return
		}
	}
}

func consume(ctx context.Context, input <-chan int) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case value, ok := <-input:
			if !ok {
				return nil
			}
			fmt.Println("收到:", value)
		}
	}
}

流水线的每一个发送、接收和阻塞调用都要能够响应取消。只在最外层检查一次是不够的,因为内部可能已经卡在 channel 或 IO 操作上。


📝 九、本篇小结

要点 总结
Context 传播取消、截止时间和请求范围数据
WithCancel 主动取消任务
WithTimeout 相对当前时间设置超时
WithDeadline 设置绝对截止时间
HTTP Context 客户端断开时可通知下游
WithValue 只传递少量请求元数据
使用原则 第一个参数传入,不存进结构体,及时调用 cancel

💬 一句话总结:

Context 是并发调用链的控制信号;让每个阻塞操作监听 Done(),才能在取消和超时时及时释放资源。


✏️ 十、练习题

  1. 🖥️ 动手实践:编写一个可取消的 worker pool,取消后所有 worker 都能退出。
  2. 🔧 HTTP 超时 :使用 http.NewRequestWithContext 实现一个 1 秒超时的请求。
  3. 🤔 思考题:为什么不应该把 Context 存进结构体或用它传递普通业务参数?
  4. 📚 元数据实践:定义自有 Context key,传递 trace ID,并在日志函数中读取它。

💡 练习题答案会在下一篇中公布,欢迎在评论区分享你的答案~


关注我,一起学编程

如果本文对你有帮助,欢迎 点赞、在看、转发、关注。

相关推荐
人人都是软件工程师1 小时前
17-反射:在运行时观察和操作类型
go
miofly1 小时前
GitHub 今日推荐|open_scion:为AI代理设计的并发版本控制
开源·go·github
敲代码的玉米C20 小时前
编辑器开始懂 Git:改动、提交图和 worktree
go
敲代码的玉米C20 小时前
我想用 Go 写个编辑器,先把「轻量」这件事想明白
go
敲代码的玉米C20 小时前
让工作流接起来:文件拖放与 Git 冲突处理
go
敲代码的玉米C20 小时前
中文输入不是「能打出汉字」就算完成
go
敲代码的玉米C20 小时前
把熟悉的操作带回来:快捷键和 Markdown 预览
go
敲代码的玉米C20 小时前
从打开一个文件,到打开一个项目
go
敲代码的玉米C20 小时前
终端里的小细节:选中复制、截图粘贴和项目提醒
go