Context:并发控制的指挥棒

深入讲解Go语言context包的原理与实践,涵盖超时控制、取消传播、请求值传递等核心机制,帮助开发者掌握跨goroutine控制流管理的高级技巧

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("X %s: %v\n", r.Service, r.Error)
		} else {
			fmt.Printf("OK %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 中文件操作的方方面面。

进阶概念:深入 Context 内部原理

Context 树结构

Context 包的设计基于树形结构。每一个通过 WithCancel、WithTimeout、WithDeadline 或 WithValue 创建的子 Context 都会成为 Context 树中的一个节点。当父节点被取消时,树会沿着所有子节点递归传播取消信号。

// 树形结构示意
type cancelCtx struct {
    Context
    mu       sync.Mutex
    done     chan struct{}
    children map[canceler]struct{} // 子节点集合
    err      error
}

// 取消时会遍历所有子节点并逐个取消
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    c.mu.Lock()
    // ... 设置 done,设置 err
    for child := range c.children {
        child.cancel(false, err) // 递归取消所有子节点
    }
    c.children = nil
    c.mu.Unlock()
    // ...
}

这个树形结构的设计非常巧妙:它可以高效地一次性取消整个调用链,而无需手动维护关系。每条函数调用链上的 Context 形成了一个自然的树形结构,取消信号直接沿着这个结构传播。

取消传播机制

取消传播本质上是多播模式的应用。当调用 cancel() 时,底层会关闭一个无缓冲 channel,而所有监听 <-ctx.Done() 的 goroutine 同时收到通知。

package main

import (
	"context"
	"fmt"
	"time"
)

func propagateDemo() {
	root := context.Background()

	// 创建多层嵌套
	level1, cancel1 := context.WithCancel(root)
	level2, cancel2 := context.WithTimeout(level1, 5*time.Second)
	level3, _ := context.WithCancel(level2)

	// 监听各层级
	for name, ctx := range map[string]context.Context{
		"level1": level1,
		"level2": level2,
		"level3": level3,
	} {
		go func(n string, c context.Context) {
			<-c.Done()
			fmt.Printf("%s 收到取消: %v\n", n, c.Err())
		}(name, ctx)
	}

	// 取消 level1,所有子层都会收到信号
	cancel1()
	time.Sleep(100 * time.Millisecond)

	// 即使 level2 有超时,也不会独立触发
	cancel2() // 释放 level2 的 timer 资源
}

Deadline vs Timeout 区别

虽然两者都限制时间,但实现方式不同:

特性WithTimeoutWithDeadline
参数相对时长 duration绝对时间点 time.Time
底层内部调用 WithDeadline直接计算截止时间
使用场景从现在开始算 X 秒后指定一个具体时刻
等价关系WithTimeout(parent, d) == WithDeadline(parent, time.Now().Add(d))WithDeadline(parent, time.Now().Add(d))
// WithDeadline 适合有明确截止时间的场景
func processOrderDeadline(ctx context.Context, orderDeadline time.Time) error {
	// 订单必须在截止时间前完成处理
	ctx, cancel := context.WithDeadline(ctx, orderDeadline)
	defer cancel()

	return processOrder(ctx)
}

Value 的链式查找原理

ctx.Value(key) 的查找过程是递归向上的——从当前节点开始,如果没找到,就向父节点继续查找,直到根节点。

func valueDemo() {
	root := context.Background()

	ctxA := context.WithValue(root, "key", "root-value")
	ctxB := context.WithValue(ctxA, "key", "b-value")
	ctxC := context.WithValue(ctxB, "other-key", "c-value")

	// 查找 key 时会从 ctxC 向上递归
	// ctxC 没有 key,到 ctxB 找到 b-value
	fmt.Println(ctxC.Value("key")) // b-value

	// 查找 other-key 也在 ctxC 找到
	fmt.Println(ctxC.Value("other-key")) // c-value
}

这种设计意味着:子 Context 中的同名键会覆盖父 Context 中的值。这正是使用自定义类型作为键的原因——如果用基础类型如 string,不同包可能用相同的键导致意外覆盖。

实战案例

HTTP 请求链路超时

在真实微服务中,一条请求可能经过网关、服务 A、服务 B、数据库等多个环节。每个环节都应该设置合理的超时:

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

func gatewayHandler(w http.ResponseWriter, r *http.Request) {
	// 1. 总体超时 10 秒
	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
	defer cancel()

	// 注入追踪信息
	ctx = context.WithValue(ctx, requestIDKey, r.Header.Get("X-Request-ID"))

	// 2. 调用用户服务(上限 3 秒)
	userCtx, userCancel := context.WithTimeout(ctx, 3*time.Second)
	defer userCancel()
	user, err := callUserService(userCtx, 42)

	// 3. 调用订单服务(上限 5 秒)
	orderCtx, orderCancel := context.WithTimeout(ctx, 5*time.Second)
	defer orderCancel()
	orders, err := callOrderService(orderCtx, user.ID)

	// 4. 返回结果
	fmt.Fprintf(w, "User: %v, Orders: %v", user, orders)
}

func callUserService(ctx context.Context, userID int) (*User, error) {
	req, _ := http.NewRequestWithContext(ctx, "GET", "http://user-service/user?id=", nil)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	// ... 解析 resp
	return &User{}, nil
}

type User struct{ ID int }

关键点在于 r.Context() 已经包含了客户端断开连接的取消信号。在此基础上叠加超时,实现了两层保护。

数据库查询取消

数据库驱动(如 lib/pq、go-sql-driver/mysql)通常原生支持 context。查询超时时,驱动会主动向数据库服务器发送取消命令:

package main

import (
	"context"
	"database/sql"
	"fmt"
	"time"
)

func queryWithTimeout(db *sql.DB) error {
	// 设置 2 秒查询超时
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	var name string
	err := db.QueryRowContext(ctx,
		"SELECT name FROM users WHERE id = ?", 42).Scan(&name)
	if err != nil {
		if err == context.DeadlineExceeded {
			return fmt.Errorf("查询超时: %w", err)
		}
		return err
	}
	fmt.Println(name)
	return nil
}

func transactionWithCancel(db *sql.DB) error {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// 事务内所有操作都受 ctx 约束
	_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - 100 WHERE id = 1")
	if err != nil {
		return err
	}

	_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + 100 WHERE id = 2")
	if err != nil {
		return err
	}

	return tx.Commit()
}

当 context 取消时,数据库连接会收到上下文已取消的信号,驱动可发送 KILL QUERY 命令终止正在执行的长查询。

微服务调用链 context 传递

在 gRPC 和 HTTP 框架中,context 需要在服务间传递:

// gRPC 自动从 metadata 中恢复和传递 context
type server struct{}

func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
	// ctx 已经包含了上游的 deadline 和 metadata
	deadline, ok := ctx.Deadline()
	if ok {
		fmt.Printf("剩余时间: %v\n", time.Until(deadline))
	}

	// 继续调用下游服务,传递同一个 ctx
	orders, err := orderClient.ListOrders(ctx, &pb.ListOrdersRequest{UserId: req.Id})
	// ...
}

// HTTP 框架中手动传递
func callDownstream(ctx context.Context, url string) error {
	req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)

	// 把追踪 ID 放到 header 中传递
	if reqID, ok := ctx.Value(requestIDKey).(string); ok {
		req.Header.Set("X-Request-ID", reqID)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return nil
}

性能对比:不同 Context 创建方式的内存开销

Context 本身很轻量,但频繁的创建和取消仍有开销:

创建方式内存分配额外开销适用场景
context.Background()0(单例)无根节点
context.TODO()0(单例)无占位符
WithCancel~112 byteschannel + mutex需要手动取消
WithValue~40 bytes键值对存储传少量元数据
WithTimeout~208 byteschannel + timer + mutex超时控制

注:以上数值在 Go 1.22/amd64 上通过 unsafe.Sizeof() 估算,实际值可能因版本和平台不同。

package main

import (
	"context"
	"fmt"
	"runtime"
	"time"
	"unsafe"
)

func checkMemory() {
	fmt.Println("Context 类型大小:")
	fmt.Printf("Background/TODO: 单例\n")

	ctx, cancel := context.WithCancel(context.Background())
	fmt.Printf("WithCancel ctx: ~%d bytes\n", unsafe.Sizeof(ctx))
	cancel()

	ctx2, cancel2 := context.WithTimeout(context.Background(), time.Hour)
	fmt.Printf("WithTimeout ctx: ~%d bytes\n", unsafe.Sizeof(ctx2))
	cancel2()

	ctx3 := context.WithValue(context.Background(), "key", "value")
	fmt.Printf("WithValue ctx: ~%d bytes\n", unsafe.Sizeof(ctx3))

	// GC 测试:大量创建 context 是否导致泄漏
	fmt.Println("\nGC 压测:")
	var m1, m2 runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&m1)

	for i := 0; i < 100000; i++ {
		c, can := context.WithTimeout(context.Background(), time.Hour)
		can() // 立即取消,timer 会被清理
		_ = c
	}

	runtime.GC()
	runtime.ReadMemStats(&m2)
	fmt.Printf("分配内存差: %d KB\n", (m2.TotalAlloc-m1.TotalAlloc)/1024)
}

注意:如果不调用 cancel(),定时器会等到时间到达后才被 GC 回收,这会造成短期的资源滞留。

FAQ

Q1: 为什么 context 是接口而不是具体结构体?

A: 接口设计使得用户可以创建自己的 context 实现,同时也让标准库能够隐藏内部细节。空接口可以通过组合模式构建层级结构,灵活性很高。当你调用 WithCancel 时,返回的具体类型是未导出的,用户只关心接口约定。

Q2: 不调用 cancel() 会有什么后果?

A: 如果只是 WithCancel 且不调用 cancel,会导致 goroutine 可能永久阻塞在 <-ctx.Done() 上(如果没有其他退出条件)。对于 WithTimeout/WithDeadline,不调用 cancel 会导致底层 timer 在超时前一直存在,虽然最终会被 GC,但会占用少量内存和调度资源。

Q3: 为什么不能用 string 或 int 作为 WithValue 的键?

A: 使用基础类型作为键存在命名冲突风险。如果包 A 用 ctx.Value("user") 存用户信息,包 B 也用 "user" 存其他数据,就会产生意外覆盖。自定义私有类型可以彻底避免冲突。

Q4: context deadline exceeded 和 context canceled 的区别是什么?

A: context deadline exceeded 表示操作到达了设定的截止时间(WithTimeout 或 WithDeadline 触发)。context canceled 表示有人手动调用了 cancel() 函数。通过判断 err 可以区分超时和主动取消。

Q5: 多层级的 timeout 怎么共存?

A: 多个 timeout 是取交集:只要任意一个条件满足就会触发取消。例如父 context 有 10 秒超时,子 context 有 3 秒超时,那么实际上最多等 3 秒就会触发(以先到达的为准)。

Q6: 如何在 channel 操作中结合 context?

A: 可以将 context 的取消信号集成进 select 语句,实现 channel 操作的可取消化:

select {
case <-ctx.Done():
    return ctx.Err()
case msg := <-ch:
    // 处理消息
}

Q7: 为什么 context 不适合在结构体中存储?

A: Context 代表的是单次请求的控制流,而结构体通常有较长的生命周期。如果把 context 存到结构体中,可能泄漏(结构体还存在时请求早已结束),也可能在不同方法调用中被错误重用。正确做法是作为函数参数传递。

Q8: 如何优雅地在 gin/echo 等框架中使用 context?

A: Web 框架通常会自动把 context 注入到 *http.Request 中。以 gin 为例:

func handler(c *gin.Context) {
    ctx := c.Request.Context()
    // 叠加业务超时
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    // 把业务需要的值注入
    ctx = context.WithValue(ctx, "user", c.GetString("user"))

    // 传递给 service 层
    data, err := service.GetData(ctx)
    // ...
}

最佳实践汇总

不在 struct 中存储 context

始终将 context 作为函数的第一个参数传递,不存储到任何 struct 中。这有助于保持 context 的生命周期与请求的生命周期一致,避免 goroutine 泄漏。

context key 用自定义类型

// 正确定义
type contextKey int
const (
    requestIDKey contextKey = iota
    userIDKey
)

这样每个包的 key 相互隔离,即使名字相同也不会冲突。

超时处理的三层结构

建议在生产代码中采用三层超时结构:

  • 网关层:设置最大请求超时(通常 30-60 秒)
  • 服务层:根据业务设置合理超时(通常 3-10 秒)
  • 数据库/OS 层:更短的单次操作超时(通常 1-5 秒)

每层都从上层 context 派生,这样既有个性化控制,又受全局约束。

延伸阅读

我们下篇见!


参考资料:

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练