3. context 最佳实践与并发模式
3. context 最佳实践与并发模式
前两篇讲了 API 和机制。这一篇讲真正决定代码质量的两件事:怎么用才不会踩坑,以及两个值得刻进肌肉记忆的并发模式。
七条最佳实践
1. context 作为函数第一个参数
// ✅ 正确
func DoSomething(ctx context.Context, userID int, name string) error { ... }
// ❌ 错误
func DoSomething(userID int, ctx context.Context, name string) error { ... } // 位置错乱
func DoSomething(ctx context.Context) error { ... } // 把所有参数塞进 ctx
约定俗成的位置是第一个参数,名字约定为 ctx。go vet 会检查这条约定。
2. 不要在结构体里存 context
// ❌ 错误:context 的生命周期会和结构体绑死
type Worker struct {
ctx context.Context
// ...
}
// ✅ 正确:每次调用时把 ctx 作为参数传入
type Worker struct { ... }
func (w *Worker) Run(ctx context.Context) error { ... }
context 是请求级别的,请求结束就该被丢弃。存在结构体里,等于让它的生命周期变成结构体的生命周期,最后会指向一个已经取消的 context。
3. 不要传 nil,用 TODO
// ❌ 错误
func bad(ctx context.Context) {
if ctx == nil {
ctx = context.Background() // 不安全,nil 检查在某些实现里会 panic
}
}
// ✅ 正确:调用方不知道传什么,就用 TODO
func caller() {
bad(context.TODO())
}
实际上 context.Context 接口不能是 nil(会被实现层判断),传 nil 是 bug。如果没有 context,用 TODO() 占位。
4. WithValue 的 key 用自定义类型
// ❌ 错误
ctx := context.WithValue(ctx, "user_id", 42)
// ✅ 正确
type contextKey string
const keyUserID contextKey = "user_id"
ctx := context.WithValue(ctx, keyUserID, 42)
string key 会跨包冲突——两个包都用 "user_id" 互相覆盖。自定义类型即使字面值相同,类型不同也不会冲突:
type myKey string
const k1 myKey = "user_id"
ctx1 := context.WithValue(ctx, k1, "alice")
type anotherKey string
const k2 anotherKey = "user_id"
ctx2 := context.WithValue(ctx1, k2, "bob")
ctx2.Value(k1) // "alice"
ctx2.Value(k2) // "bob" —— 互不覆盖
5. ctx 只传请求级别的元数据
// ❌ 错误:把业务参数塞进 ctx
func bad(ctx context.Context) error {
userID := ctx.Value("user_id").(int) // 隐式依赖,类型不安全
return process(userID)
}
// ✅ 正确:业务参数走函数参数
func good(ctx context.Context, userID int) error {
return process(userID)
}
ctx 只放 trace ID、认证信息、日志字段这类请求级别的元数据。一旦放业务参数,函数签名就丢失了显式契约。
6. 始终调用 cancel,最稳是 defer
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() // 立即 defer,永远会被调用
WithCancel / WithTimeout / WithDeadline 都会分配内部资源(goroutine、定时器),不调用 cancel() 会泄漏。defer cancel() 是最保险的写法——创建后立即 defer。
7. 在循环中要小心 cancel 时机
// ❌ 错误:defer 在函数返回时才执行,循环里每次迭代都泄漏一个 context
for _, item := range items {
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel() // 直到函数返回才执行!
process(ctx, item)
}
// ✅ 正确:把循环体抽成函数
for _, item := range items {
processItem(parent, item)
}
func processItem(parent context.Context, item Item) {
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
process(ctx, item)
}
// ✅ 或者每次循环显式调用
for _, item := range items {
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
process(ctx, item)
cancel() // 立即调用
}
defer 在函数返回时执行,不是在块结束时执行。循环里的 defer 会堆积,直到函数结束才统一释放,长循环会显著泄漏。
工作池模式
工作池(worker pool)是 Go 里最常见的并发模式:固定数量的 worker goroutine,从共享 channel 取任务处理。配合 context,可以统一控制所有 worker 的生命周期。
func workerPool(ctx context.Context, jobs <-chan int) {
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(ctx, id, jobs)
}(w)
}
wg.Wait()
}
func worker(ctx context.Context, id int, jobs <-chan int) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d: 退出\n", id)
return
case job, ok := <-jobs:
if !ok {
return
}
fmt.Printf("worker %d: 处理 %d\n", id, job)
time.Sleep(100 * time.Millisecond)
}
}
}
模式要点:
- 所有 worker 共享同一个
ctx - 每个 worker 在
select里同时监听ctx.Done()和jobschannel - 调用
cancel()后,所有 worker 几乎同时收到信号,优雅退出 sync.WaitGroup等所有 worker 退出后再继续
带超时的结果收集
实际场景:并发调用多个数据源,到点就放弃未完成的。
func fetchAll(ctx context.Context, sources []string) []string {
resultCh := make(chan string, len(sources))
var wg sync.WaitGroup
for _, src := range sources {
wg.Add(1)
go func(source string) {
defer wg.Done()
if data := fetch(ctx, source); data != "" {
resultCh <- data
}
}(src)
}
go func() {
wg.Wait()
close(resultCh)
}()
var results []string
for r := range resultCh {
results = append(results, r)
}
return results
}
func fetch(ctx context.Context, source string) string {
delay := time.Duration(100+source[0]%5*100) * time.Millisecond
select {
case <-time.After(delay):
return fmt.Sprintf("data from %s", source)
case <-ctx.Done():
return "" // 超时返回空
}
}
关键细节:用一个单独的 goroutine 在 wg.Wait() 后关闭 resultCh。这样主循环 for r := range resultCh 能正常退出,不会死锁。
Pipeline 模式
Pipeline:数据流过一系列处理阶段,每个阶段是一个或多个 goroutine。所有阶段共享同一个 context,整体可被取消。
generator → stage1 → stage2 → consumer
| | | |
+---ctx.Done() 控制所有阶段退出---+
基本 pipeline
func generate(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// 构建流水线
ch1 := generate(ctx, 1, 2, 3, 4, 5)
ch2 := square(ctx, ch1)
for r := range ch2 {
fmt.Println(r) // 1 4 9 16 25
}
每个阶段的套路:
- 接收上游的
<-chan T作为输入 - 启动 goroutine 处理
- 用
select同时尝试写下游 channel 和监听ctx.Done() defer close(out)——这是避免下游 goroutine 阻塞的关键
Fan-out / Fan-in
Fan-out:多个 worker 处理同一份输入,提高吞吐。Fan-in:把多个 channel 的结果合并到一个 channel。
// Fan-out:多个 worker 同时消费 in
in := make(chan int, 5)
workers := []<-chan int{
worker(ctx, 1, in),
worker(ctx, 2, in),
worker(ctx, 3, in),
}
// Fan-in:合并多个 channel
merged := merge(ctx, workers...)
for r := range merged {
fmt.Println(r)
}
func merge(ctx context.Context, channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for n := range c {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Fan-out 用同一个输入 channel:多个 goroutine 从同一个 channel 读,channel 自动负载均衡。
Fan-in 用 WaitGroup 收尾:所有上游 channel 都关闭后,再关闭下游 channel,避免下游 for-range 死锁。
小结
最佳实践的记忆点可以浓缩成一句话:ctx 是请求级别的、第一位的、显式的、必须被取消的参数。
两个并发模式的核心都是 <-ctx.Done() + select:
- Worker pool:多个 worker 抢同一个 channel,context 让它们集体退出
- Pipeline:数据流过多阶段,每个阶段都监听 context,
defer close(out)是链路畅通的保证