Skip to content

context 包:取消、超时与传值

上一篇我们用 done channel 实现了取消模式,但手写取消逻辑容易出错,超时也需要自己管理 timer。Go 标准库的 context 包提供了统一的解决方案:它能在 goroutine 树中传播取消信号、超时控制,还能携带请求级别的数据。context 是 Go 并发编程的「神经中枢」,几乎所有涉及 I/O 的库(database、http、rpc)都要求传入 context。本篇我们将系统掌握 context 的用法。

一、context 包的设计哲学

context 包解决的核心问题是:在 goroutine 树中传播取消信号和超时

考虑这样一个场景:一个 HTTP 请求处理过程中,会启动多个 goroutine 去查数据库、调下游服务、读缓存。如果客户端断开连接,我们希望这些正在进行的操作都能及时取消,避免浪费资源。手动用 channel 协调很繁琐,而 context 提供了标准化的方式:

  • 取消传播:父 context 取消,所有子 context 自动取消。
  • 超时传播:设置超时后,到点自动取消,子 context 也跟着取消。
  • 传值:可以在 context 中携带请求级别的数据(如 trace ID、用户身份),沿调用链传递。

context 的核心接口:

go
type Context interface {
    Deadline() (deadline time.Time, ok bool)  // 截止时间
    Done() <-chan struct{}                     // 取消信号 channel
    Err() error                                // 取消原因
    Value(key any) any                         // 携带的值
}

Done() 返回一个 channel,关闭时表示 context 被取消——这与我们前面学的 done channel 模式完全一致,只是被标准化了。

二、context.Background() 和 context.TODO()

这两个是 context 的「根」,所有 context 树都从它们开始。

1. context.Background()

context.Background() 返回一个永远不会取消、没有值、没有截止时间的 context。它通常作为顶层的 context,在 main 函数、初始化、请求处理的入口处使用。

2. context.TODO()

context.TODO() 返回的 context 和 Background() 行为一样,但它表达「这里还没决定用哪个 context,先用 TODO 占位」。通常在重构过程中、或还没想清楚怎么传 context 时用。

3. 使用示例

go
package main

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

func main() {
	// 在 main 中用 Background 作为根
	ctx := context.Background()

	// 派生一个可取消的 context
	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // 养成习惯:创建了就 defer cancel

	go func() {
		<-ctx.Done()
		fmt.Println("子 goroutine 检测到取消")
	}()

	time.Sleep(500 * time.Millisecond)
	cancel() // 取消
	time.Sleep(100 * time.Millisecond)
	fmt.Println("主 goroutine 结束")
}

4. 重要规则:创建的 context 必须 cancel

WithCancelWithTimeoutWithDeadline 返回的 context,必须调用对应的 cancel 函数,否则会资源泄漏(context 内部的 timer、goroutine 不会被释放)。最佳实践是 defer cancel()

三、context.WithCancel:手动取消

WithCancel(parent) 返回一个子 context 和一个 cancel 函数。调用 cancel 后,context 的 Done channel 会被关闭。

1. 基本用法

go
package main

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

func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("worker %d 取消: %v\n", id, ctx.Err())
			return
		case <-time.After(300 * time.Millisecond):
			fmt.Printf("worker %d 工作\n", id)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(ctx, i, &wg)
	}

	time.Sleep(time.Second)
	fmt.Println("发起取消")
	cancel() // 取消所有 worker

	wg.Wait()
	fmt.Println("全部退出")
}

2. cancel 可以多次调用

cancel 是幂等的,多次调用不会 panic,第一次调用后的调用都是 no-op:

go
package main

import (
	"context"
	"fmt"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	cancel() // 没问题
	cancel() // 也没问题

	fmt.Println("ctx.Err():", ctx.Err()) // context canceled
	<-ctx.Done()
	fmt.Println("Done 已关闭")
}

四、context.WithTimeout 和 context.WithDeadline

WithTimeout(parent, d)d 时间后自动取消。WithDeadline(parent, t) 在指定时刻 t 自动取消。实际上 WithTimeout 就是 WithDeadline(parent, time.Now().Add(d))

1. WithTimeout 基本用法

go
package main

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

func slowOperation(ctx context.Context) (string, error) {
	select {
	case <-time.After(2 * time.Second):
		return "完成", nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func main() {
	// 1 秒超时
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

	result, err := slowOperation(ctx)
	if err != nil {
		fmt.Println("错误:", err) // context deadline exceeded
		return
	}
	fmt.Println("结果:", result)
}

2. WithDeadline 用法

go
package main

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

func main() {
	// 2 秒后的具体时刻
	deadline := time.Now().Add(2 * time.Second)
	ctx, cancel := context.WithDeadline(context.Background(), deadline)
	defer cancel()

	select {
	case <-time.After(3 * time.Second):
		fmt.Println("操作完成")
	case <-ctx.Done():
		fmt.Println("到截止时间:", ctx.Err())
	}
}

3. 查看剩余时间

go
package main

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

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if deadline, ok := ctx.Deadline(); ok {
		fmt.Println("截止时间:", deadline.Format("15:04:05"))
		fmt.Println("剩余:", time.Until(deadline).Round(time.Second))
	}

	time.Sleep(2 * time.Second)
	if deadline, ok := ctx.Deadline(); ok {
		fmt.Println("2 秒后剩余:", time.Until(deadline).Round(time.Second))
	}
}

4. 超时时间的选择

  • 客户端请求:通常几秒到几十秒,根据业务定。
  • 下游 RPC:根据 SLA 设置,留出重试余量。
  • 数据库查询:几秒,避免长查询拖垮数据库。
  • 不要设置过长超时:超时太长等于没超时;也不要过短,否则正常请求被误杀。

五、context.WithValue:请求级别数据传递

WithValue(parent, key, val) 在 context 中存一个键值对,子 context 能通过 Value(key) 取出。这用于传递请求级别的数据,如 trace ID、用户 ID、认证信息。

1. 基本用法

go
package main

import (
	"context"
	"fmt"
)

// 推荐用自定义类型作为 key,避免冲突
type ctxKey string

const (
	keyTraceID ctxKey = "traceID"
	keyUserID  ctxKey = "userID"
)

func handleRequest(ctx context.Context) {
	traceID := ctx.Value(keyTraceID)
	userID := ctx.Value(keyUserID)
	fmt.Printf("处理请求: traceID=%v, userID=%v\n", traceID, userID)
	handleDB(ctx)
}

func handleDB(ctx context.Context) {
	traceID := ctx.Value(keyTraceID)
	fmt.Printf("查询数据库: traceID=%v\n", traceID)
}

func main() {
	ctx := context.Background()
	// 逐层添加值
	ctx = context.WithValue(ctx, keyTraceID, "abc-123")
	ctx = context.WithValue(ctx, keyUserID, 42)

	handleRequest(ctx)
}

2. key 必须是可比较的类型

context 用 == 比较 key,所以 key 必须是可比较类型。强烈推荐用自定义类型(如上面的 ctxKey),不要用 string——因为不同包用 string "id" 做 key 会冲突。

3. WithValue 的使用原则

  • 只放请求级别的数据:trace ID、认证信息、请求 ID,不要放业务参数。
  • 不要放可变状态:WithValue 是不可变的,每次创建新的子 context。
  • 不要用 context 传函数参数:函数需要的参数应该显式传,context 只传「横切关注点」。
  • 不要滥用:如果数据可以用参数传,就用参数,不要塞 context。
go
package main

import (
	"context"
	"fmt"
)

// ❌ 反模式:把业务参数塞进 context
// func doSomething(ctx context.Context) {
//     userID := ctx.Value("userID").(int)
//     ...
// }

// ✅ 正确:业务参数显式传,context 只传横切信息
func doSomething(ctx context.Context, userID int) {
	traceID := ctx.Value("traceID")
	fmt.Printf("traceID=%v, 处理用户 %d\n", traceID, userID)
}

func main() {
	ctx := context.WithValue(context.Background(), "traceID", "t-1")
	doSomething(ctx, 42)
}

六、context 的传播:父子关系与级联取消

context 形成一棵树:每个 context 都有父 context(除了根)。父 context 取消时,所有子 context 自动取消。这是 context 最强大的特性。

1. 级联取消示例

go
package main

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

func task(ctx context.Context, name string) {
	subCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	go func() {
		<-subCtx.Done()
		fmt.Printf("%s 被取消: %v\n", name, subCtx.Err())
	}()

	// 模拟工作
	select {
	case <-time.After(3 * time.Second):
		fmt.Printf("%s 正常完成\n", name)
	case <-subCtx.Done():
		fmt.Printf("%s 因父取消而退出\n", name)
	}
}

func main() {
	// 根 context,2 秒超时
	root, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	// 启动多个子任务
	go task(root, "任务A")
	go task(root, "任务B")

	time.Sleep(3 * time.Second)
	fmt.Println("主 goroutine 结束")
}

2 秒后 root 超时取消,任务 A 和 B 的子 context 也自动取消,它们都能感知到并退出。

2. 取消原因的传播

go
package main

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

func main() {
	parent, cancel := context.WithCancel(context.Background())
	child, childCancel := context.WithCancel(parent)
	defer childCancel()

	go func() {
		<-child.Done()
		fmt.Println("child Err:", child.Err()) // 继承自 parent
	}()

	// 父取消,子也取消
	cancel()
	time.Sleep(100 * time.Millisecond)

	fmt.Println("parent Err:", parent.Err())
}

子 context 的 Err() 会返回取消原因,如果是被父级取消则返回父级的错误。

七、在 HTTP 服务器中使用 context

net/http 包从 Go 1.7 开始自动为每个请求创建 context:r.Context()。客户端断开连接时,这个 context 会自动取消。

1. 基本用法

go
package main

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

func handler(w http.ResponseWriter, r *http.Request) {
	// 从请求获取 context
	ctx := r.Context()
	fmt.Println("handler: 开始处理请求")

	// 派生一个带超时的子 context
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	select {
	case <-time.After(2 * time.Second):
		fmt.Fprintln(w, "处理完成")
	case <-ctx.Done():
		fmt.Println("handler: 请求被取消:", ctx.Err())
		http.Error(w, "请求取消或超时", http.StatusServiceUnavailable)
	}
}

func main() {
	http.HandleFunc("/work", handler)
	fmt.Println("服务器启动在 :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		fmt.Println("服务器错误:", err)
	}
}

如果客户端在 2 秒前断开连接,r.Context() 会自动取消,handler 能立即感知并退出,不再浪费资源。

2. 把 context 传给下游调用

go
package main

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

// 模拟调用下游服务
func callDownstream(ctx context.Context, url string) (string, error) {
	// 创建带 context 的请求
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return "", err
	}

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	return fmt.Sprintf("状态码 %d", resp.StatusCode), nil
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	result, err := callDownstream(ctx, "https://httpbin.org/delay/1")
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	fmt.Fprintln(w, result)
}

func main() {
	http.HandleFunc("/api", handler)
	fmt.Println("服务器启动在 :8080")
	http.ListenAndServe(":8080", nil)
}

http.NewRequestWithContext 会把 context 关联到请求,请求发出后如果 context 取消,HTTP 客户端会中断请求——这就是 context 在 HTTP 调用链中的传播。

八、在数据库查询中使用 context

database/sql 包支持 context,能取消正在执行的查询。

1. 基本用法

go
package main

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

// 模拟用 context 查询数据库
func queryDB(ctx context.Context, db *sql.DB, query string) {
	// 设置 5 秒超时的子 context
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	// 用 QueryContext,支持取消
	rows, err := db.QueryContext(ctx, query)
	if err != nil {
		if ctx.Err() == context.DeadlineExceeded {
			fmt.Println("查询超时")
			return
		}
		fmt.Println("查询错误:", err)
		return
	}
	defer rows.Close()

	for rows.Next() {
		// 处理行...
	}
	fmt.Println("查询完成")
}

func main() {
	// 这里不真正连接数据库,只演示用法
	ctx := context.Background()
	var db *sql.DB // = sql.Open("mysql", dsn)
	queryDB(ctx, db, "SELECT 1")
}

QueryContextExecContextBeginTx 都支持 context。如果 context 取消,正在执行的查询会被中断(具体行为取决于驱动),避免长查询拖垮数据库。

2. GORM 中的 context

GORM 等 ORM 也支持 context:

go
package main

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

// 伪代码,演示 GORM 中使用 context
func gormExample() {
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// db.WithContext(ctx).Where("age > ?", 18).Find(&users)
	fmt.Println("GORM 会把 context 传到底层 SQL 查询")

	_ = ctx
}

func main() {
	gormExample()
}

九、context 最佳实践:作为第一个参数传递

Go 官方约定:context 应该作为函数的第一个参数,命名为 ctx

1. 正确的签名

go
package main

import (
	"context"
	"fmt"
)

// ✅ 正确:ctx 作为第一个参数
func DoSomething(ctx context.Context, arg1 string, arg2 int) error {
	fmt.Println("处理:", arg1, arg2, ctx)
	return nil
}

func main() {
	DoSomething(context.Background(), "hello", 42)
}

2. 不要把 context 放在结构体里

这是 Go 官方明确反对的做法。context 应该流经调用链,而不是被存在某个对象里。

go
package main

import "context"

// ❌ 反模式:context 存在结构体里
type BadService struct {
	ctx context.Context
}

func (s *BadService) DoWork() {
	// s.ctx 可能有不确定的生命周期,难以追踪
	_ = s.ctx
}

// ✅ 正确:context 作为方法参数传入
type GoodService struct{}

func (s *GoodService) DoWork(ctx context.Context) {
	_ = ctx
}

func main() {}

为什么不能放结构体?因为 context 的设计意图是「随请求流动」,放结构体里会让它的生命周期变得不明确——一个结构体可能被多个请求复用,每个请求的 context 不同。唯一例外是某些需要长期运行的「服务对象」(如 HTTP handler 的 server),它们有自己的生命周期管理。

十、常见误用:context 存储在结构体中

来看一个具体的误用案例和修复:

1. 误用案例

go
package main

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

// ❌ 误用:把 context 存进 handler
type BadHandler struct {
	ctx context.Context
}

func (h *BadHandler) Process() {
	// 如果这个 handler 被复用,ctx 可能已经过期
	select {
	case <-time.After(100 * time.Millisecond):
		fmt.Println("处理完成")
	case <-h.ctx.Done():
		fmt.Println("ctx 已取消:", h.ctx.Err())
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	h := &BadHandler{ctx: ctx}
	h.Process()
	cancel() // cancel 后 h.ctx 还在,复用 h 就出问题
	h.Process()
}

2. 正确做法

go
package main

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

// ✅ 正确:每次调用传入 context
type GoodHandler struct{}

func (h *GoodHandler) Process(ctx context.Context) {
	select {
	case <-time.After(100 * time.Millisecond):
		fmt.Println("处理完成")
	case <-ctx.Done():
		fmt.Println("ctx 已取消:", ctx.Err())
	}
}

func main() {
	h := &GoodHandler{}

	// 第一次请求
	ctx1, cancel1 := context.WithCancel(context.Background())
	h.Process(ctx1)
	cancel1()

	// 第二次请求,新的 context
	ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second)
	defer cancel2()
	h.Process(ctx2)
}

这样每个请求有自己的 context,handler 可安全复用。

十一、context 错误处理

context 取消后,Err() 返回两种错误:

  • context.Canceled:被主动 cancel() 取消。
  • context.DeadlineExceeded:超时(达到 deadline)。
go
package main

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

func main() {
	// 主动取消
	ctx1, cancel1 := context.WithCancel(context.Background())
	cancel1()
	fmt.Println("ctx1 Err:", ctx1.Err()) // context canceled

	// 超时
	ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Millisecond)
	defer cancel2()
	time.Sleep(50 * time.Millisecond)
	fmt.Println("ctx2 Err:", ctx2.Err()) // context deadline exceeded

	// 区分两种错误
	if ctx1.Err() == context.Canceled {
		fmt.Println("ctx1 是被主动取消的")
	}
	if ctx2.Err() == context.DeadlineExceeded {
		fmt.Println("ctx2 是超时的")
	}
}

在业务代码中,可以根据 Err() 的值决定是否重试、是否记录日志等。

十二、完整实战:带超时和取消的并发请求聚合

综合运用 context,实现一个常见场景:并发调用多个下游服务,任一失败或超时则返回错误。

go
package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"sync"
	"time"
)

type Result struct {
	Service string
	Data    string
	Err     error
}

func callService(ctx context.Context, name string, delay time.Duration) Result {
	select {
	case <-time.After(delay):
		if rand.Intn(10) < 3 {
			return Result{Service: name, Err: errors.New(name + " 失败")}
		}
		return Result{Service: name, Data: name + " 数据"}
	case <-ctx.Done():
		return Result{Service: name, Err: fmt.Errorf("%s 被取消: %w", name, ctx.Err())}
	}
}

func aggregate(ctx context.Context) []Result {
	services := []struct {
		name  string
		delay time.Duration
	}{
		{"用户服务", 300 * time.Millisecond},
		{"订单服务", 500 * time.Millisecond},
		{"库存服务", 200 * time.Millisecond},
		{"支付服务", 800 * time.Millisecond},
	}

	results := make([]Result, len(services))
	var wg sync.WaitGroup

	for i, svc := range services {
		wg.Add(1)
		go func(idx int, name string, delay time.Duration) {
			defer wg.Done()
			results[idx] = callService(ctx, name, delay)
		}(i, svc.name, svc.delay)
	}

	wg.Wait()
	return results
}

func main() {
	rand.Seed(time.Now().UnixNano())

	// 总超时 1 秒
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

	results := aggregate(ctx)
	for _, r := range results {
		if r.Err != nil {
			fmt.Printf("[失败] %s: %v\n", r.Service, r.Err)
		} else {
			fmt.Printf("[成功] %s: %s\n", r.Service, r.Data)
		}
	}
}

如果某个服务超过 1 秒,它会被 context 取消,不会拖慢整体响应。这是微服务架构中「请求聚合 + 超时兜底」的标准做法。

十三、小结

本篇我们系统学习了 context 包:

  1. 设计哲学:context 在 goroutine 树中传播取消、超时和值。核心接口有 Done()(取消信号 channel)、Err()(取消原因)、Deadline()(截止时间)、Value()(携带值)。

  2. Background 和 TODOBackground() 是根 context,用于 main、初始化、请求入口;TODO() 是占位符,表示「还没决定」。两者行为相同,语义不同。

  3. WithCancel:手动取消,调用 cancel 函数取消 context 和所有子 context。cancel 幂等,可多次调用。

  4. WithTimeout 和 WithDeadline:超时自动取消。WithTimeout 是「多久之后」,WithDeadline 是「到哪个时刻」。务必 defer cancel() 释放资源。

  5. WithValue:携带请求级别数据(trace ID 等)。key 用自定义类型避免冲突,只放横切关注点,不放业务参数和可变状态。

  6. 级联取消:父 context 取消时,所有子 context 自动取消——这是 context 最强大的特性,让取消信号能沿调用链传播。

  7. HTTP 服务器r.Context() 自动为每个请求创建 context,客户端断开自动取消。http.NewRequestWithContext 把 context 关联到出站请求。

  8. 数据库QueryContextExecContext 支持取消,避免长查询拖垮系统。GORM 等用 WithContext

  9. 最佳实践:context 作为函数第一个参数(命名 ctx),不要存在结构体里(除非是有生命周期的服务对象)。创建了 context 必须 cancel。

  10. 错误处理context.Canceled(主动取消)和 context.DeadlineExceeded(超时),可据此决定重试或记录。

下一篇我们将学习数据竞争与原子操作,这是写出正确并发程序的另一块基石。