🔖 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(),才能在取消和超时时及时释放资源。
✏️ 十、练习题
- 🖥️ 动手实践:编写一个可取消的 worker pool,取消后所有 worker 都能退出。
- 🔧 HTTP 超时 :使用
http.NewRequestWithContext实现一个 1 秒超时的请求。 - 🤔 思考题:为什么不应该把 Context 存进结构体或用它传递普通业务参数?
- 📚 元数据实践:定义自有 Context key,传递 trace ID,并在日志函数中读取它。
💡 练习题答案会在下一篇中公布,欢迎在评论区分享你的答案~
关注我,一起学编程
如果本文对你有帮助,欢迎 点赞、在看、转发、关注。