Appearance
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
WithCancel、WithTimeout、WithDeadline 返回的 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")
}QueryContext、ExecContext、BeginTx 都支持 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 包:
设计哲学:context 在 goroutine 树中传播取消、超时和值。核心接口有
Done()(取消信号 channel)、Err()(取消原因)、Deadline()(截止时间)、Value()(携带值)。Background 和 TODO:
Background()是根 context,用于 main、初始化、请求入口;TODO()是占位符,表示「还没决定」。两者行为相同,语义不同。WithCancel:手动取消,调用 cancel 函数取消 context 和所有子 context。cancel 幂等,可多次调用。
WithTimeout 和 WithDeadline:超时自动取消。WithTimeout 是「多久之后」,WithDeadline 是「到哪个时刻」。务必
defer cancel()释放资源。WithValue:携带请求级别数据(trace ID 等)。key 用自定义类型避免冲突,只放横切关注点,不放业务参数和可变状态。
级联取消:父 context 取消时,所有子 context 自动取消——这是 context 最强大的特性,让取消信号能沿调用链传播。
HTTP 服务器:
r.Context()自动为每个请求创建 context,客户端断开自动取消。http.NewRequestWithContext把 context 关联到出站请求。数据库:
QueryContext、ExecContext支持取消,避免长查询拖垮系统。GORM 等用WithContext。最佳实践:context 作为函数第一个参数(命名 ctx),不要存在结构体里(除非是有生命周期的服务对象)。创建了 context 必须 cancel。
错误处理:
context.Canceled(主动取消)和context.DeadlineExceeded(超时),可据此决定重试或记录。
下一篇我们将学习数据竞争与原子操作,这是写出正确并发程序的另一块基石。