Go 把"并发"做到了语言级:go f() 一个关键字就起一个协程。这种"廉价"容易让人误以为 goroutine 可以随便用、不用懂调度。但线上事故往往就藏在这种"随便"里——协程泄漏撑爆内存、竞态导致数据错乱、调度饥饿让请求超时。要用好 goroutine,必须先理解它背后的 GMP 调度模型,再认清那些高频并发陷阱。
一、GMP 调度模型:协程如何跑在线程上
Go 的调度器用三个抽象描述并发执行:G(goroutine,协程)、M(machine,操作系统线程)、P(processor,逻辑处理器,持有可运行 G 的本地队列)。P 的数量等于 GOMAXPROCS,决定了同时执行 G 的并行度。M 必须绑定一个 P 才能执行 G。
GMP 的精髓:把"调度"从操作系统下沉到用户态。OS 只看到 M 在跑,看不到成千上万的 G;Go 调度器在用户态把 G 们分配到 P 的本地队列,由 M 取出执行。切换 G 只是用户态寄存器切换,没有内核态开销,这就是 goroutine "轻量"的根源。
- G:一个 goroutine,包含栈与执行状态。
- M:系统线程,真正执行代码的载体。
- P:逻辑处理器,持有一个本地 G 队列(256 容量),是 G 与 M 之间的"配对器"。
二、Work Stealing 与调度时机
当某个 P 的本地队列空了,它不会闲着,而是去"偷"别的 P 队列里一半的 G 来执行,这叫 Work Stealing。如果偷不到,就去全局队列取;再没有就 M 阻塞或挂起。这种"负载自平衡"让 Go 能高效利用多核,避免某些 P 堆积而另一些空闲。
2.1 调度的抢占点
早期 Go 的调度是"协作式"的——只有在函数调用栈检查时才可能切换 G,一个紧密循环不带函数调用就能霸占 M。Go 1.14 引入基于信号的异步抢占,能在任何安全点强制中断 G,解决了"死循环饿死其他 G"的问题。但理解调度时机仍有价值:
- channel 收发阻塞、IO 阻塞 → G 挂起,M 去跑别的 G。
- time.Sleep、锁等待 → G 让出。
- 函数调用栈检查点 → 可能触发抢占(异步抢占前的协作点)。
三、并发陷阱一:goroutine 泄漏
goroutine 泄漏是最隐蔽的陷阱:一个 G 启动后再也无法退出,却也不会被回收,慢慢占满内存与调度资源。典型场景是"发送方已走,接收方还在等"——channel 永远不会收到数据,接收 G 永久阻塞。
// ❌ 泄漏:外层超时返回后,内部 G 仍在等 ch
func leak() {
ch := make(chan int)
go func() {
val := compute() // 慢
ch <- val // 永远没人收,G 泄漏
}()
select {
case <-ch:
case <-time.After(100 * time.Millisecond):
return // 超时返回,但上面的 G 还活着
}
}
// ✅ 修复:用带缓冲的 channel 或 context 取消
func fixed(ctx context.Context) {
ch := make(chan int, 1) // 缓冲=1,发送不阻塞
go func() {
ch <- compute()
}()
select {
case val := <-ch:
_ = val
case <-ctx.Done():
return
}
}
3.1 检测与预防
- 用
runtime.NumGoroutine()监控协程数,持续上涨即泄漏。 - 用 pprof goroutine profile 看泄漏 G 的栈,定位阻塞点。
- 凡是启动 G,都要想清楚"它何时退出"——用 context 传递取消信号是标配。
- channel 发送要确保有接收方或缓冲足够,避免发送方永久阻塞。
四、并发陷阱二:竞态条件
多个 G 同时读写同一变量且至少一个是写,且无同步,就是竞态。Go 的竞态不一定会立刻崩,而是表现为"偶发数据错乱",最难排查。Go 自带 -race 检测器,能在测试时发现竞态,是开发期的必备工具。
// ❌ 竞态:多 G 并发写 map
m := map[int]int{}
for i := 0; i < 10; i++ {
go func(n int) {
m[n] = n // 并发写 map,可能 panic: concurrent map writes
}(i)
}
// ✅ 修复:用 sync.Mutex 或 sync.Map
var mu sync.Mutex
m := map[int]int{}
for i := 0; i < 10; i++ {
go func(n int) {
mu.Lock()
m[n] = n
mu.Unlock()
}(i)
}
并发铁律:不要通过共享内存通信,而要通过通信共享内存。Go 推崇用 channel 在 G 间传递数据所有权,而不是多个 G 直接共享变量。这从设计层面规避了大量竞态。
五、并发陷阱三:内存可见性与 happens-before
即便没有竞态,并发程序也有"可见性"问题:一个 G 的写,另一个 G 何时能看到?Go 内存模型用 happens-before 关系定义可见性。未建立 happens-before 的写,对另一个 G 可见性无保证。channel 发送、锁的 Lock/Unlock、WaitGroup 的 Done/Wait 都会建立 happens-before。
// ❌ 可见性问题:未同步,ready 可能永远不可见
var ready bool
var data int
go func() {
data = 42
ready = true // 无 happens-before,主 G 可能看不到
}()
for !ready { // 可能死循环:编译器/CPU 重排
runtime.Gosched()
}
fmt.Println(data)
// ✅ 修复:用 channel 或 sync.Once 建立同步
done := make(chan struct{})
go func() {
data = 42
close(done) // close 发生在主 G 收到信号之前(happens-before)
}()
<-done
fmt.Println(data) // 一定看到 42
- WaitGroup:Add 必须在 Wait 之前,Done 在 goroutine 内,建立 happens-before。
- channel:发送 happens-before 对应接收完成;close happens-before 接收到零值。
- Mutex:上一次 Unlock happens-before 下一次 Lock。
- 不要用
time.Sleep做同步——它不建立任何 happens-before。
六、并发模式与最佳实践
- worker pool:固定数量 G 消费任务 channel,避免无限创建 G。
- fan-out/fan-in:多 G 并行处理,结果汇总到一个 channel。
- context 取消:所有可取消的 G 第一个参数都应是 ctx。
- errgroup:一组 G 中任一出错即取消全部,比手动 WaitGroup 更安全。
- 限制并发数:用带缓冲 channel 当信号量,控制同时进行的 G 数量。
// 用 errgroup + semaphore 限制并发
g, ctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, 4) // 最多 4 并发
for _, item := range items {
item := item
sem <- struct{}{}
g.Go(func() error {
defer func() { <-sem }()
return process(ctx, item)
})
}
if err := g.Wait(); err != nil {
return err
}
结语
goroutine 让并发"廉价",但没让并发"简单"。GMP 调度给了我们高性能的并发原语,也带来了泄漏、竞态、可见性这些新的责任。在 mrgr.cn 后端组的实践中,"每个 G 都有退出路径"和"用 -race 跑测试"是两条铁律,能挡掉绝大多数并发事故。理解 happens-before、用 channel 传递所有权、用 context 传递取消,是写出可靠 Go 并发代码的基础。欢迎在问答社区交流你的 goroutine 踩坑故事。