Go 并发控制:常见用法与踩坑指南
Go 的并发模型基于 CSP(Communicating Sequential Processes)理念,核心口号是「不要通过共享内存来通信,而要通过通信来共享内存」。但实际项目中,sync 包和共享内存的用法同样常见。这份文档把常见工具和典型坑点放在一起,方便对照查阅。
一、基础并发原语
1. goroutine
go func() {
doSomething()
}()
- 启动成本很低(初始栈约 2KB,按需增长),但不代表可以无限制启动。
go关键字只是把函数丢给调度器,不保证执行顺序,也不保证主协程会等它结束。
常见坑:主协程提前退出
func main() {
go fmt.Println("hello")
// main 函数直接结束,goroutine 可能还没来得及执行
}
主协程退出时,其余 goroutine 会被直接杀死,不会等待。必须用 WaitGroup、channel 或其他同步手段显式等待。
2. channel
ch := make(chan int) // 无缓冲,发送阻塞直到有人接收
ch := make(chan int, 10) // 有缓冲,容量满了才阻塞
- 无缓冲 channel 是同步点,发送方和接收方必须「握手」。
- 有缓冲 channel 只在缓冲区满/空时才阻塞,容易掩盖设计问题(比如用大缓冲区掩盖生产消费速度不匹配)。
常见坑一:向已关闭的 channel 发送数据
close(ch)
ch <- 1 // panic: send on closed channel
常见坑二:重复关闭 channel
close(ch)
close(ch) // panic: close of closed channel
原则:由发送方关闭 channel,且只关闭一次;接收方永远不要关闭。如果有多个发送方,用 sync.Once 或专门的协调协程来关闭。
常见坑三:nil channel 导致永久阻塞
对 nil channel 的收发操作会永远阻塞(这在 select 里其实是一个有用的技巧,用来临时禁用某个 case,但如果是无意为之就是 bug)。
常见坑四:goroutine 泄漏
func leak() {
ch := make(chan int)
go func() {
val := <-ch // 永远没人发送,这个 goroutine 永远卡在这里
fmt.Println(val)
}()
// 函数返回,ch 再也没人用了,但 goroutine 还活着
}
goroutine 泄漏是 Go 里最隐蔽的内存泄漏之一,因为不会报错,只是慢慢堆积。每次开 goroutine 都要想清楚它的退出条件是什么,通常配合 context 或专门的 done channel。
3. select
select {
case v := <-ch1:
fmt.Println(v)
case ch2 <- 1:
fmt.Println("sent")
case <-time.After(time.Second):
fmt.Println("timeout")
default:
fmt.Println("nothing ready")
}
- 多个 case 同时就绪时,Go 会随机选一个,不是按顺序。
time.After每次调用都会创建一个新的 timer,在高频循环里反复使用会造成资源浪费,应改用time.NewTimer并手动Stop()。
常见坑:滥用 default 造成忙轮询(busy loop)
for {
select {
case v := <-ch:
process(v)
default:
// 没有 sleep,CPU 会被打满
}
}
如果确实需要非阻塞轮询,记得加退避(sleep 或 time.Ticker)。
二、sync 包常用工具
1. sync.WaitGroup
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println(i)
}(i)
}
wg.Wait()
常见坑一:Add 放在 goroutine 内部
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func() {
wg.Add(1) // 错误:Wait() 可能在 Add 之前就执行完了
defer wg.Done()
}()
}
wg.Wait()
Add 必须在 go 语句之前、在主协程里调用,否则会出现 Wait() 提前返回的竞态。
常见坑二:循环变量捕获问题
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // Go 1.22 之前:大概率全部打印 5
}()
}
Go 1.22 之前,for 循环的变量在每次迭代中是同一个变量,闭包捕获的是引用而非值,所有 goroutine 执行时 i 可能已经变成循环结束时的值。解决办法是显式传参 go func(i int) {...}(i),或者在循环体内 i := i 重新声明。Go 1.22 起循环变量改为每次迭代独立,这个坑在新版本里基本消失了,但线上很多代码库仍在用旧版本或历史代码,需要留意。
常见坑三:sync.WaitGroup 或含锁的 struct 被复制
type Job struct {
wg sync.WaitGroup
}
func process(j Job) { // 值传递,wg 被复制!
j.wg.Wait()
}
sync.Mutex、sync.WaitGroup 等一旦被使用就不能复制。函数传参、结构体赋值都算复制。go vet 能检测出大部分这类问题,建议 CI 里跑一下。
2. sync.Mutex / sync.RWMutex
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
counter++
常见坑一:忘记 Unlock 或者 defer 位置不对
mu.Lock()
if condition {
return // 忘了 Unlock,直接死锁
}
mu.Unlock()
尽量在 Lock() 后立刻 defer Unlock(),除非有明确的性能理由需要提前释放。
常见坑二:锁的粒度太粗或太细
粒度太粗会让并发失去意义(整个函数一把大锁);粒度太细则容易漏加锁、引发死锁风险和维护成本上升。经验法则:锁只保护「真正共享的数据」,业务逻辑尽量放在锁外面。
常见坑三:重入死锁
Go 的 Mutex 不是可重入锁,同一个 goroutine 二次 Lock() 会直接死锁:
mu.Lock()
mu.Lock() // 死锁,不是 panic,是永久阻塞
常见坑四:RWMutex 使用误区
RWMutex 只有在「读多写少」且读操作本身耗时的场景下才有性能优势,否则读写锁的额外开销可能比普通 Mutex 更差。不要默认觉得 RWMutex 一定更快。
3. sync.Once
var once sync.Once
once.Do(func() {
initConfig()
})
常用于单例初始化。坑点:如果 once.Do 里的函数 panic,Once 会认为已经执行过,之后不会重试,需要自行处理错误重试逻辑。
4. sync.Map
var m sync.Map
m.Store("key", 1)
v, ok := m.Load("key")
适合「写少读多」或者 key 集合稳定的场景。坑点:sync.Map 没有泛型类型检查(any 类型),也没有 len() 方法,滥用会让代码可读性变差。大多数场景下,map + Mutex/RWMutex 反而更直观、更好维护,只有在实测有性能瓶颈时才换 sync.Map。
三、atomic 包
var counter int64
atomic.AddInt64(&counter, 1)
val := atomic.LoadInt64(&counter)
或 Go 1.19+ 的类型化版本:
var counter atomic.Int64
counter.Add(1)
val := counter.Load()
适用场景:单个数值的原子读写(计数器、标志位)。不适用:需要保证多个字段一起变化的复合操作,这种情况还是要用 Mutex。
常见坑:atomic 操作的变量必须始终通过 atomic 函数访问,如果代码里有的地方用 atomic、有的地方直接读写,等于没加保护。
四、context.Context
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
select {
case <-ctx.Done():
return ctx.Err()
case result := <-doWork(ctx):
return result
}
常见坑一:忘记调用 cancel
WithCancel / WithTimeout / WithDeadline 返回的 cancel 函数必须调用,哪怫 context 已经自然超时。不调用会导致关联的定时器和资源无法及时释放,属于典型的隐性泄漏。
常见坑二:把 context 存进 struct
type Service struct {
ctx context.Context // 反模式
}
官方建议 context 应该作为函数的第一个参数显式传递(func Do(ctx context.Context, ...)),而不是存成字段。存成字段会导致 context 的生命周期和对象生命周期绑定不清晰。
常见坑三:用 context 传递可选参数或必需的业务参数
context.WithValue 应该只用来传递「请求范围的元数据」(如 trace ID、认证信息),不要用它来传递函数的必需参数——这会让函数签名失去类型检查,调用方也很难知道到底需要传什么。
常见坑四:子 goroutine 不监听 ctx.Done()
启动的子任务如果不检查 ctx.Done(),父 context 取消了也不会停止执行,等于白设置了超时。
五、更高层的模式
1. errgroup(golang.org/x/sync/errgroup)
g, ctx := errgroup.WithContext(context.Background())
for _, url := range urls {
url := url
g.Go(func() error {
return fetch(ctx, url)
})
}
if err := g.Wait(); err != nil {
log.Fatal(err)
}
比手写 WaitGroup + 错误收集更简洁:任意一个任务出错会自动取消关联的 ctx,并且能拿到第一个错误。注意:默认只返回第一个错误,其余任务的错误会被丢弃,如果需要收集所有错误要自己处理。
2. Worker Pool
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 0; w < 5; w++ {
go worker(jobs, results)
}
for _, j := range jobList {
jobs <- j
}
close(jobs)
常见坑:忘记 close(jobs),导致所有 worker 永远阻塞在 range jobs 上,程序看起来「卡住不退出」。同时要注意 results channel 也需要有对应的消费者,否则会写满阻塞。
3. Pipeline 模式
多个 stage 之间用 channel 串联,每个 stage 负责关闭自己产出的 channel。坑点:如果中间某个 stage 提前退出(比如出错),后面的 stage 会一直等数据,前面的 stage 会因为没人接收而阻塞——需要用 context 或专门的 done channel 统一传播取消信号。
六、Data Race 与检测工具
Data race(数据竞争)指多个 goroutine 并发读写同一内存地址,且至少有一个是写操作,而没有同步保护。Go 不会自动帮你避免这种情况,编译器不报错,运行时也不一定报错——它是「未定义行为」,可能今天跑得好好的,换个机器或加大负载就出问题。
排查建议:
go build -race/go test -race:官方内置的竞态检测器,开发和 CI 阶段务必开启,虽然会拖慢运行速度,但能捕获大部分数据竞争。go vet:能检测出锁的错误复制、常见的并发误用模式。staticcheck:静态检查工具,能发现更多潜在并发问题(如未使用的 mutex、错误的 channel 用法)。pprof:排查 goroutine 泄漏时,用net/http/pprof看 goroutine 数量是否随时间持续增长。
七、几条经验总结
- 每开一个 goroutine,先想清楚它怎么退出,不要只想它怎么启动。
- channel 由发送方关闭,且只关闭一次;不确定就别关,用其他方式表达「结束」。
- 不要复制已使用的 Mutex / WaitGroup,包括通过值传递函数参数。
context作为参数传递,不要塞进 struct;cancel()必须调用。- 能用简单的 channel/锁解决的问题,不要过早上
sync.Map、atomic等「看起来更高级」的工具——可读性优先,性能问题实测后再优化。 - CI 里常态化跑
-race,比事后排查线上诡异 bug 便宜得多。