Context:并发控制的指挥棒

深入理解 Go 语言的 Context 机制,掌握超时控制、取消传播和值传递

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)
}

⚠️ 注意事项

  1. 不要用 Context 传递函数参数。Context 只应该存储请求范围的数据(比如请求 ID、追踪 ID),不要用它来传递普通的函数参数。

  2. 使用自定义的键类型,避免不同包之间的键冲突:

// ✅ 好:自定义类型
type myKey string
const key myKey = "myKey"

// ❌ 不好:用 string 作为键,可能和其他包冲突
ctx := context.WithValue(ctx, "key", "value")
  1. 获取值时要做类型断言,并检查是否为 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 机制:

  1. Context 是什么:携带截止时间、取消信号和请求值的对象
  2. 创建方式:Background、TODO、WithCancel、WithTimeout、WithDeadline、WithValue
  3. 取消信号:通过 select 监听 ctx.Done()
  4. 超时控制:用 WithTimeout 限制操作时间
  5. 层级传播:父取消,子全部取消
  6. 传值:存储请求范围的元数据
  7. 最佳实践:第一个参数、不存储、总是 cancel、不传 nil

Context 是 Go 并发编程中不可或缺的工具。它让你能优雅地处理取消、超时和跨层级的信息传递。

练习时间

  1. 超时下载器:实现一个带超时和重试机制的下载函数
  2. 级联取消:实现一个场景,父任务取消时,所有子任务也要取消
  3. 请求追踪:用 Context 传递请求 ID,在多个函数中打印追踪信息
  4. 并发竞争:同时请求多个数据源,返回第一个成功的结果(竞速模式)
  5. 中间件模式:实现一个 HTTP 中间件,用 Context 注入用户认证信息

下一篇预告

下一篇文章,我们将学习 Go 的文件 I/O 操作。从读写文件到处理目录,从缓冲区到临时文件,让你掌握 Go 中文件操作的方方面面。

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页