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("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 机制:
- Context 是什么:携带截止时间、取消信号和请求值的对象
- 创建方式:Background、TODO、WithCancel、WithTimeout、WithDeadline、WithValue
- 取消信号:通过
select监听ctx.Done() - 超时控制:用
WithTimeout限制操作时间 - 层级传播:父取消,子全部取消
- 传值:存储请求范围的元数据
- 最佳实践:第一个参数、不存储、总是 cancel、不传 nil
Context 是 Go 并发编程中不可或缺的工具。它让你能优雅地处理取消、超时和跨层级的信息传递。
练习时间
- 超时下载器:实现一个带超时和重试机制的下载函数
- 级联取消:实现一个场景,父任务取消时,所有子任务也要取消
- 请求追踪:用 Context 传递请求 ID,在多个函数中打印追踪信息
- 并发竞争:同时请求多个数据源,返回第一个成功的结果(竞速模式)
- 中间件模式:实现一个 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 区别
虽然两者都限制时间,但实现方式不同:
| 特性 | WithTimeout | WithDeadline |
|---|---|---|
| 参数 | 相对时长 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 bytes | channel + mutex | 需要手动取消 |
| WithValue | ~40 bytes | 键值对存储 | 传少量元数据 |
| WithTimeout | ~208 bytes | channel + 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 派生,这样既有个性化控制,又受全局约束。
延伸阅读
- Go 语言文档 - context 包
- Go 官方博客 - Go Concurrency Patterns: Context
- Go Wiki - Context
- The Go Programmer: Using contexts in Go
- Uber Go Style Guide: Context Usage
我们下篇见!
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。