Context:并发控制的指挥棒
在构建真实的 Go 应用时,你经常会遇到这样的场景:
- 一个 HTTP 请求处理到一半,客户端断开了连接——你应该停止后续的工作
- 一个数据库查询跑了太久——你应该在超时后取消它
- 一个微服务调用了另一个微服务——你应该传递请求 ID 方便追踪
这些场景都涉及到跨 goroutine 的控制流管理——怎么告诉一组相关的 goroutine “该停了"或"带上这个信息”。
Go 1.7 引入了 context 包来解决这个问题。Context 已经成为 Go 并发编程中不可或缺的一部分,几乎所有标准库和第三方库都支持它。
今天我们就来搞懂 Context 的前世今生和正确用法。
什么是 Context?
Context(上下文)是一个对象,它携带了截止时间(deadline)、取消信号(cancellation)和请求范围的值(request-scoped values)。
一个 Context 可以在多个 goroutine 之间共享,当 Context 被取消时,所有持有这个 Context 的 goroutine 都能收到通知。
你可以把 Context 想象成一个指挥棒——指挥者(通常是发起请求的 goroutine)挥动指挥棒,所有的乐手(goroutine)都跟着节奏行事。当指挥者放下指挥棒(取消 context),所有乐手都停下来。
Context 的基本结构
context.Context 是一个接口:
type Context interface {
// Deadline 返回 context 的截止时间
Deadline() (deadline time.Time, ok bool)
// Done 返回一个 channel,在 context 被取消时关闭
Done() <-chan struct{}
// Err 在 Done channel 关闭后返回取消原因
Err() error
// Value 获取 context 中存储的键值对
Value(key interface{}) interface{}
}
四个方法,各有分工:
Deadline():查询什么时候过期Done():获取取消信号的通道Err():查询取消原因Value():获取存储的值
创建 Context
context.Background()
context.Background() 返回一个空的根 Context,通常用在 main 函数、初始化代码和测试中:
ctx := context.Background()
它是 Context 树的根节点,永远不会被取消,没有截止时间,也没有值。
context.TODO()
context.TODO() 和 Background() 类似,但它表示"我还不知道该用什么 Context,先占个位":
ctx := context.TODO()
在实际开发中,当你还没有确定要传什么 Context 时,可以用 TODO() 占位。
派生子 Context
从现有的 Context 派生子 Context,是使用 Context 的核心操作。context 包提供了四个函数:
1. WithCancel:可手动取消
ctx, cancel := context.WithCancel(parent)
// ... 使用 ctx ...
cancel() // 手动取消
2. WithTimeout:带超时
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel() // 即使超时触发,也记得调用 cancel 释放资源
3. WithDeadline:指定截止时间点
deadline := time.Date(2021, 12, 31, 23, 59, 59, 0, time.UTC)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
4. WithValue:携带键值对
ctx := context.WithValue(parent, key, value)
取消信号的使用
这是 Context 最核心的用法。让我们看看如何正确使用取消信号:
基本用法
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: 收到取消信号: %v\n", id, ctx.Err())
return
default:
fmt.Printf("Worker %d: 工作中...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
// 启动多个 worker
for i := 1; i <= 3; i++ {
go worker(ctx, i)
}
// 主 goroutine 等 2 秒后取消
time.Sleep(2 * time.Second)
cancel()
// 等一下让 worker 有时间退出
time.Sleep(500 * time.Millisecond)
fmt.Println("主函数退出")
}
输出:
Worker 1: 工作中...
Worker 2: 工作中...
Worker 3: 工作中...
Worker 1: 工作中...
Worker 2: 工作中...
Worker 3: 工作中...
...
Worker 1: 收到取消信号: context canceled
Worker 2: 收到取消信号: context canceled
Worker 3: 收到取消信号: context canceled
主函数退出
关键点
注意 select 语句中 case <-ctx.Done(): 这个分支。这是检查取消信号的标准方式。每个长期运行的 goroutine 都应该在适当的位置检查 ctx.Done()。
超时控制
超时控制是 Context 最常见的实际用途。让我们看一个 HTTP 请求的例子:
package main
import (
"context"
"fmt"
"time"
)
// simulateAPICall 模拟一个耗时的 API 调用
func simulateAPICall(ctx context.Context) (string, error) {
// 模拟处理时间
select {
case <-time.After(3 * time.Second):
return "API 响应数据", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
// 设置 2 秒超时
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
result, err := simulateAPICall(ctx)
if err != nil {
fmt.Println("调用失败:", err)
} else {
fmt.Println("结果:", result)
}
}
// 输出:调用失败: context deadline exceeded
API 调用需要 3 秒,但我们只给了 2 秒的超时时间,所以返回了超时错误。
HTTP 服务器中的超时
在实际的 HTTP 服务器中,context 通常由框架提供:
package main
import (
"fmt"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
// r.Context() 是框架自动创建的,当客户端断开连接时会自动取消
ctx := r.Context()
select {
case <-time.After(10 * time.Second):
fmt.Fprintln(w, "处理完成")
case <-ctx.Done():
fmt.Println("客户端断开了连接:", ctx.Err())
return
}
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
Context 的层级结构
Context 形成了一个树状结构。父 Context 被取消时,所有子 Context 也会被取消。但子 Context 的取消不会影响父 Context。
package main
import (
"context"
"fmt"
"time"
)
func main() {
// 创建父 context
parent, parentCancel := context.WithCancel(context.Background())
defer parentCancel()
// 创建子 context A
childA, cancelA := context.WithCancel(parent)
defer cancelA()
// 创建子 context B(带超时)
childB, cancelB := context.WithTimeout(parent, 1*time.Second)
defer cancelB()
// 取消子 context A
cancelA()
// 检查各个 context 的状态
time.Sleep(100 * time.Millisecond)
fmt.Println("parent err:", parent.Err()) // <nil>(未取消)
fmt.Println("childA err:", childA.Err()) // context canceled
fmt.Println("childB err:", childB.Err()) // <nil>(还没超时)
// 等待子 context B 超时
time.Sleep(1 * time.Second)
fmt.Println("childB err:", childB.Err()) // context deadline exceeded
}
层级传播
这种层级结构在微服务架构中非常有用:
请求入口 (Background)
├── HTTP Handler (WithTimeout: 5s)
│ ├── 调用服务 A (继承父 context)
│ │ └── 查询数据库 (继承父 context)
│ └── 调用服务 B (WithTimeout: 2s) ← 缩短超时
│ └── 调用缓存 (继承父 context)
如果 HTTP Handler 超时,所有子操作都会收到取消信号。服务 B 有自己的更短的超时,如果服务 B 超时,它的子操作会被取消,但服务 A 不受影响。
Context 传值
context.WithValue 可以在 Context 中存储键值对。这通常用于传递请求范围的信息,比如请求 ID、用户信息等。
package main
import (
"context"
"fmt"
)
// 定义自定义的键类型(避免冲突)
type contextKey string
const (
requestIDKey contextKey = "requestID"
userIDKey contextKey = "userID"
)
func main() {
ctx := context.Background()
// 设置值
ctx = context.WithValue(ctx, requestIDKey, "req-12345")
ctx = context.WithValue(ctx, userIDKey, "user-42")
// 在深层函数中获取值
processRequest(ctx)
}
func processRequest(ctx context.Context) {
requestID := ctx.Value(requestIDKey).(string)
userID := ctx.Value(userIDKey).(string)
fmt.Printf("处理请求: requestID=%s, userID=%s\n", requestID, userID)
// 传递给更深层的函数
handleSubTask(ctx)
}
func handleSubTask(ctx context.Context) {
requestID := ctx.Value(requestIDKey).(string)
fmt.Printf("子任务: requestID=%s\n", requestID)
}
⚠️ 注意事项
不要用 Context 传递函数参数。Context 只应该存储请求范围的数据(比如请求 ID、追踪 ID),不要用它来传递普通的函数参数。
使用自定义的键类型,避免不同包之间的键冲突:
// ✅ 好:自定义类型
type myKey string
const key myKey = "myKey"
// ❌ 不好:用 string 作为键,可能和其他包冲突
ctx := context.WithValue(ctx, "key", "value")
- 获取值时要做类型断言,并检查是否为 nil:
if requestID, ok := ctx.Value(requestIDKey).(string); ok {
// 使用 requestID
} else {
// 处理不存在的情况
}
实战:带超时的并发请求
让我们写一个实际的例子——并发请求多个服务,带超时控制:
package main
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
)
type ServiceResponse struct {
Service string
Data string
Error error
}
// callService 模拟调用一个服务
func callService(ctx context.Context, name string) ServiceResponse {
// 模拟随机延迟
delay := time.Duration(rand.Intn(3000)) * time.Millisecond
select {
case <-time.After(delay):
return ServiceResponse{
Service: name,
Data: fmt.Sprintf("来自 %s 的数据", name),
}
case <-ctx.Done():
return ServiceResponse{
Service: name,
Error: fmt.Errorf("%s: %w", name, ctx.Err()),
}
}
}
// fetchAll 并发请求所有服务
func fetchAll(ctx context.Context, services []string) []ServiceResponse {
results := make([]ServiceResponse, len(services))
var wg sync.WaitGroup
for i, service := range services {
wg.Add(1)
go func(idx int, svc string) {
defer wg.Done()
results[idx] = callService(ctx, svc)
}(i, service)
}
wg.Wait()
return results
}
func main() {
services := []string{"user-service", "order-service", "payment-service", "inventory-service"}
// 设置 2 秒超时
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
start := time.Now()
results := fetchAll(ctx, services)
elapsed := time.Since(start)
fmt.Printf("总耗时: %v\n\n", elapsed)
successCount := 0
for _, r := range results {
if r.Error != nil {
fmt.Printf("❌ %s: %v\n", r.Service, r.Error)
} else {
fmt.Printf("✅ %s: %s\n", r.Service, r.Data)
successCount++
}
}
fmt.Printf("\n成功: %d/%d\n", successCount, len(services))
}
Context 最佳实践
1. Context 应该作为函数的第一个参数
// ✅ 好
func fetchData(ctx context.Context, id string) (*Data, error) { ... }
// ❌ 不好
func fetchData(id string, ctx context.Context) (*Data, error) { ... }
这是 Go 社区的强烈约定。
2. 不要把 Context 存储在结构体中
// ❌ 不好
type Handler struct {
ctx context.Context // 不要存储
}
// ✅ 好
type Handler struct {
// 不存储 context
}
func (h *Handler) Handle(ctx context.Context) error {
// 使用传入的 context
}
3. 总是传递 cancel 函数对应的 cancel
每次创建带取消功能的 context 时,都要确保 cancel 会被调用:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel() // 必须调用!即使超时已经触发
不调用 cancel 会导致资源泄漏。
4. 不要传递 nil Context
// ❌ 不好
func doSomething(ctx context.Context) {
if ctx == nil {
// ...
}
}
// ✅ 好
func doSomething(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
}
5. 不要过度使用 WithValue
Context 的 Value 只应该存储请求范围的数据,不要把它当成通用的键值存储:
// ✅ 好:请求 ID、用户认证信息
ctx = context.WithValue(ctx, requestIDKey, "req-123")
// ❌ 不好:函数依赖、数据库连接
ctx = context.WithValue(ctx, dbKey, dbConnection)
小结
今天我们全面学习了 Go 的 Context 机制:
- Context 是什么:携带截止时间、取消信号和请求值的对象
- 创建方式:Background、TODO、WithCancel、WithTimeout、WithDeadline、WithValue
- 取消信号:通过
select监听ctx.Done() - 超时控制:用
WithTimeout限制操作时间 - 层级传播:父取消,子全部取消
- 传值:存储请求范围的元数据
- 最佳实践:第一个参数、不存储、总是 cancel、不传 nil
Context 是 Go 并发编程中不可或缺的工具。它让你能优雅地处理取消、超时和跨层级的信息传递。
练习时间
- 超时下载器:实现一个带超时和重试机制的下载函数
- 级联取消:实现一个场景,父任务取消时,所有子任务也要取消
- 请求追踪:用 Context 传递请求 ID,在多个函数中打印追踪信息
- 并发竞争:同时请求多个数据源,返回第一个成功的结果(竞速模式)
- 中间件模式:实现一个 HTTP 中间件,用 Context 注入用户认证信息
下一篇预告
下一篇文章,我们将学习 Go 的文件 I/O 操作。从读写文件到处理目录,从缓冲区到临时文件,让你掌握 Go 中文件操作的方方面面。
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。