我要提问
ARTICLE / 009 · 后端开发

原创文章

资深开发者执笔的深度技术长文,从原理到工程落地,逐层拆解。

Go 语言 goroutine 调度与并发陷阱

Go 语言 goroutine 调度与并发陷阱

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 踩坑故事。

返回文章列表