1. Go 并发基础回顾:goroutine 的创建与生命周期、sync.WaitGroup 的使用、竞态条件与互斥锁 sync.Mutex

在深入 context 超时控制与错误传播之前,必须夯实 Go 并发编程的三大基石:goroutine(轻量级线程)、同步原语(WaitGroup / Mutex)以及竞态条件的认知。本章将系统梳理这些基础概念,为后续中间件链路中的并发协调打下坚实基础。

1.1 goroutine 的创建与生命周期

goroutine 是 Go 运行时管理的轻量级执行单元,由 go 关键字启动。其创建成本极低(初始栈仅 2KB),可动态扩缩,是构建高并发系统的核心。

关键特性:
  • 非阻塞启动: go f() 立即返回,不等待函数执行完毕。
  • 主 goroutine 退出即终止: 若 main 函数返回,所有子 goroutine 会被强制结束。
  • 生命周期不可控: 无法从外部杀死一个 goroutine,只能通过 channel 或 context 通知其自行退出。
package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    fmt.Printf("Worker %d 开始\n", id)
    time.Sleep(1 * time.Second)
    fmt.Printf("Worker %d 结束\n", id)
}

func main() {
    go worker(1) // 启动 goroutine
    go worker(2)

    time.Sleep(2 * time.Second) // 等待 goroutine 完成(不推荐)
    fmt.Println("主 goroutine 退出")
}
⚠️ 常见陷阱: 主 goroutine 不会等待子 goroutine 结束。上述代码使用 time.Sleep 是极不稳定的做法,必须使用 sync.WaitGroup 或 channel 进行同步。

1.2 sync.WaitGroup:等待 goroutine 集合完成

sync.WaitGroup 是 Go 标准库提供的计数信号量,用于等待一组 goroutine 全部执行完毕。其内部维护一个计数器,通过 Add、Done、Wait 三个方法协作。

方法 作用 典型调用时机
Add(delta int) 增加计数器(通常 +1) 启动 goroutine 前
Done() 计数器减 1(等价于 Add(-1)) goroutine 执行完毕时
Wait() 阻塞直到计数器归零 主 goroutine 中等待所有子任务
package main

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

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 确保计数器减 1
    fmt.Printf("Worker %d 开始\n", id)
    time.Sleep(time.Duration(id) * 500 * time.Millisecond)
    fmt.Printf("Worker %d 结束\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1)          // 启动前加 1
        go worker(i, &wg)  // 传递指针
    }

    wg.Wait() // 等待所有 worker 完成
    fmt.Println("所有 worker 已完成,主 goroutine 退出")
}
💡 最佳实践: Add 必须在 go 启动之前调用,且 wg 必须通过指针传递,否则 goroutine 会操作副本导致死锁。

1.3 竞态条件与互斥锁 sync.Mutex

当多个 goroutine 并发读写同一共享变量,且至少一个操作为写操作时,就会产生竞态条件(Race Condition)。其结果不可预测,是并发编程中最隐蔽的 bug。

竞态条件三要素:
  1. 并发访问: 至少两个 goroutine 同时操作同一内存地址。
  2. 至少一个写操作: 纯读操作不会产生竞态。
  3. 无同步机制: 未使用锁、channel 或原子操作保护临界区。

sync.Mutex 提供互斥锁,确保同一时刻只有一个 goroutine 进入临界区。其接口简洁:Lock() 获取锁,Unlock() 释放锁。

package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++ // 临界区:受锁保护
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    var wg sync.WaitGroup
    counter := &Counter{}

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }

    wg.Wait()
    fmt.Printf("最终计数: %d (期望 1000)\n", counter.Value())
}
⚠️ 死锁风险: 忘记 Unlock() 或重复 Lock() 会导致死锁。始终使用 defer mu.Unlock() 确保释放。

1.4 并发基础概念关系图

下图展示了 goroutine、WaitGroup、Mutex 在并发程序中的协作关系:

Go 并发基础概念关系图 Go 并发基础:goroutine、WaitGroup、Mutex 协作关系 主 goroutine (main) goroutine A goroutine B goroutine C sync.WaitGroup 共享资源 (Counter.value) 受 sync.Mutex 保护 WaitGroup 等待所有 goroutine 完成;Mutex 保护共享资源避免竞态条件

1.5 竞态检测与避坑指南

Go 提供了内置的竞态检测器(Race Detector),在测试或运行时加上 -race 标志即可启用:

go run -race main.go
go test -race ./...
常见竞态场景与解决方案:
场景 问题描述 解决方案
map 并发读写 多个 goroutine 同时读写 map 导致 panic 使用 sync.RWMutex 或 sync.Map
闭包捕获循环变量 goroutine 启动时捕获了循环变量的引用 显式传递副本:go func(i int) { ... }(i)
切片并发 append 多个 goroutine 同时 append 同一切片 加锁或使用 channel 串行化
全局变量无保护 多个 goroutine 读写全局计数器 使用 sync.Mutex 或原子操作 sync/atomic

1.6 本章小结

  • goroutine 是 Go 并发的最小单元,由 go 关键字启动,生命周期与主 goroutine 绑定。
  • sync.WaitGroup 提供 goroutine 集合的同步等待机制,通过 Add/Done/Wait 协作。
  • 竞态条件 源于无保护的并发写操作,sync.Mutex 通过互斥锁保证临界区的原子性。
  • 始终使用 go run -race 检测竞态,这是生产级代码的必备实践。