Go:GMP 调度、抢占、channel 阻塞和 goroutine 泄漏应该怎么讲清楚

讨论 Go 并发运行时时,GMP、抢占、channel 阻塞和 goroutine 泄漏经常会被放在同一条链路里。原因不复杂:这四个点本来就不是孤立知识点,而是一套运行时行为。

如果只背定义,答案很快会散掉:

  • GMP 说成三个字母的缩写
  • 抢占只记住“避免饿死”
  • channel 阻塞只记住“没发送方或者没接收方”
  • goroutine 泄漏只记住“协程没退出”

这样的回答信息是碎的,和工程里的真实问题也接不上。

这一篇换一个讲法:围绕一个批处理任务执行器,把调度、阻塞、退出和排障放进同一条执行链里。这样既能讲清运行时在做什么,也能讲清在短时间说明时怎样把答案组织得短、准、完整。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  1. GMP 模型里 G、M、P 各自负责什么
  2. goroutine 为什么不是一启动就立刻执行
  3. Go 的抢占解决了什么问题,不同版本的差别怎么交代
  4. channel 为什么会阻塞,阻塞点具体落在哪
  5. goroutine 泄漏和死锁、慢请求的区别是什么
  6. worker pool 场景里最常见的泄漏路径有哪些
  7. 短时间说明时怎样把这几个点在 90 秒和 3 分钟里讲清楚

如果这些问题能连成一条线,Go 并发题就不容易答成零碎概念。

二、先把场景立住:一个批处理任务执行器

假设现在要写一个批处理任务执行器,用来并发处理一批任务:

  • jobs channel 读取任务
  • 启动固定数量 worker
  • 每个 worker 执行任务并把结果写回 results
  • 调用方汇总成功、失败和超时数量
  • 整批任务超时后尽快收口

这个场景不大,但足够覆盖四类高频追问点:

  1. worker 为什么能并发跑起来,对应 GMP 调度
  2. 某个 goroutine 长时间占用 CPU 时,其他任务怎么得到执行机会,对应抢占
  3. jobsresults 为什么会卡住,对应 channel 阻塞
  4. 超时返回后后台还有 goroutine 挂着,对应 goroutine 泄漏

后面的所有解释,都围绕这个执行器展开。

三、开场先给一句总答案

如果题目范围比较大,开场先给一句总答案更稳:

Go 并发这几个题可以放在一条链里理解:GMP 负责把 goroutine 调度到线程上执行,抢占负责避免某个 goroutine 长时间占住执行权,channel 阻塞体现的是 goroutine 之间的同步关系,goroutine 泄漏说明阻塞或退出路径设计没有收口。

这句话有三个作用:

  • 先把四个概念串成因果链
  • 让后面的展开有顺序
  • 让听的人知道答案会落到工程问题,不只是背名词

四、GMP 到底是什么

GMP 是 Go 运行时的调度模型:

  • G 是 goroutine,表示待执行的任务
  • M 是 machine,对应内核线程
  • P 是 processor,表示运行 Go 代码所需的调度资源

更短一点的理解方式是:

  • G 是工作单元
  • M 是真正跑代码的线程
  • P 是让 M 执行 Go 代码的许可证和本地调度上下文

说到这里,不需要把运行时源码全部展开,但要把一个关键点说出来:

P 不是 CPU,P 是调度器里的执行令牌。

GOMAXPROCS 控制的是 P 的数量,也就是同时有多少个 goroutine 可以并行执行 Go 代码。

五、G、M、P 各自负责什么

把三个角色拆开后,答案会更清楚。

G 负责保存 goroutine 的执行现场,例如:

  • 程序计数器
  • 状态信息

M 负责真正在线程上跑代码,但 M 单独存在没有意义,因为它需要拿到 P 才能执行 Go 代码。

P 负责:

  • 绑定一个可运行的 M
  • 维护本地可运行队列
  • 参与内存分配和调度决策

这时可以补一句常见判断:

Go 不是把每个 goroutine 直接映射成一个线程,而是用 GMP 在更少线程上复用大量 goroutine。

这句话能把模型和“轻量”联系起来,但没有停留在空泛表述。

六、调度器是怎么把 goroutine 跑起来的

回到批处理任务执行器,最小骨架通常像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Job struct {
ID int
}

type Result struct {
JobID int
Err error
}

func worker(jobs <-chan Job, results chan<- Result) {
for job := range jobs {
results <- Result{JobID: job.ID}
}
}

当主 goroutine 启动多个 worker:

1
2
3
for i := 0; i < 4; i++ {
go worker(jobs, results)
}

发生的事情可以这样讲:

  1. go worker(...) 创建新的 G
  2. 新 G 进入某个 P 的本地运行队列,或者进入全局运行队列
  3. 拿到 P 的 M 从队列里取出 G 执行
  4. G 遇到阻塞、系统调用、时间片切换或被抢占时,调度器再切走

这里不要把“启动 goroutine”说成“立刻开新线程”。多数情况下只是新增一个待调度的 G。

七、本地队列、全局队列和偷取任务怎么答

如果追问调度细节,可以补下面这层:

  • 每个 P 有自己的本地运行队列
  • 运行中的 M 优先从绑定 P 的本地队列取 G
  • 本地队列空了,会尝试从全局队列拿任务
  • 还拿不到时,可能去其他 P 偷一部分可运行 G

这个过程就是常说的 work stealing。

这样设计的好处是:

  • 本地队列命中更快
  • 减少全局锁竞争
  • 避免有的 P 很忙,有的 P 空着

如果题目只停在“GMP 是什么”,答到这里已经足够完整;如果继续追问调度公平性,再把 work stealing 和抢占接上。

八、系统调用和网络阻塞为什么没有把整个调度器拖死

批处理执行器里,worker 做的事往往不只是 CPU 计算,还会碰到:

  • 磁盘读写
  • 网络请求
  • 数据库访问

如果某个 goroutine 因系统调用阻塞,对应的 M 也可能被挂住。Go 运行时会尽量把 P 让出来,让其他 M 继续执行可运行 goroutine。

网络 I/O 这类场景还会配合 netpoll:

  • goroutine 发起 I/O 后先挂起
  • 运行时监听文件描述符就绪事件
  • I/O 就绪后再把对应 G 放回可运行队列

这里的重点不是把 netpoll 细节背全,而是说明:

Go 调度器处理的不只是 CPU 切换,还在处理阻塞状态转换。

九、抢占为什么是这个题里的关键点

如果没有抢占,调度器主要依赖 goroutine 在函数调用、channel 操作、系统调用等位置主动交出执行权。问题在于:

  • 某个 goroutine 长时间执行纯计算循环
  • 中间没有阻塞点
  • 也没有明显函数调用

这时其他 goroutine 得到执行机会的速度会很差,表现出来就是:

  • worker pool 吞吐抖动
  • 定时任务延迟升高
  • context 超时信号响应不及时

所以抢占的价值不是一句“更公平”,而是:

让运行时在 goroutine 不主动让出的情况下,也能把执行权拿回来。

十、Go 里的抢占演进应该怎么讲

讲抢占时,适合分成两个阶段:

  1. 早期更接近协作式抢占
  2. Go 1.14 起支持更强的异步抢占

简化后的说法是:

  • 早期版本更多依赖安全点,例如函数调用等位置
  • 如果 goroutine 长时间跑纯计算,其他 goroutine 可能调度不及时
  • Go 1.14 起运行时可以在更合适的时机异步抢占正在执行的 goroutine

这段答案的价值在于把“版本差异”和“为什么要有抢占”同时交代清楚。

如果继续追问,不妨补一句:

抢占不是随时随地中断任意指令,而是在运行时可接受的安全点附近完成,这样才能兼顾公平性和正确性。

十一、用一个 CPU 忙循环看抢占的意义

下面这个例子很适合解释没有明显阻塞点时为什么还需要抢占:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"runtime"
"time"
)

func busyLoop() {
sum := 0
for i := 0; i < 1<<30; i++ {
sum += i
}
fmt.Println(sum)
}

func main() {
runtime.GOMAXPROCS(1)

go busyLoop()

go func() {
time.Sleep(10 * time.Millisecond)
fmt.Println("timer fired")
}()

time.Sleep(100 * time.Millisecond)
}

这段代码说明的是:

  • 只有一个 P
  • 一个 goroutine 长时间做纯计算
  • 另一个 goroutine 想执行定时逻辑

现代 Go 版本里,抢占让第二个 goroutine 更有机会及时得到调度。没有这层机制,纯计算 goroutine 会明显影响其他任务响应。

十二、channel 为什么会阻塞

channel 阻塞题最容易答得过于简化。更完整的表达是:

channel 是 goroutine 之间的同步点。发送和接收是否阻塞,不取决于“写法像不像队列”,而取决于另一侧是否在合适时机配合。

最常见的阻塞场景有四类:

  1. 无缓冲 channel 发送时,没有接收方
  2. 无缓冲 channel 接收时,没有发送方
  3. 有缓冲 channel 已满,发送方继续发送
  4. 有缓冲 channel 为空,接收方继续接收

这时 goroutine 的状态会从 runnable 变成 waiting,等到条件满足再恢复执行。

十三、先看一个最小阻塞示例

无缓冲 channel 的阻塞最直观:

1
2
3
4
5
6
package main

func main() {
ch := make(chan int)
ch <- 1
}

这段代码会直接卡住,因为:

  • ch <- 1 需要一个接收方同时配合
  • 主 goroutine 自己就是发送方
  • 没有其他 goroutine 来接收

再看接收阻塞:

1
2
3
4
5
6
package main

func main() {
ch := make(chan int)
<-ch
}

原因一样,只是角色互换了。

解释 channel 阻塞时,不妨明确说出一句:

channel 不是自动异步队列,它本质上表达的是发送方和接收方之间的同步关系。

十四、缓冲 channel 不是阻塞问题的万能解法

给 channel 加缓冲区能缓解部分阻塞,但不能替代退出设计。

例如:

1
results := make(chan Result, 16)

缓冲区带来的效果是:

  • 发送方不需要每次都等接收方立刻就位
  • 只要缓冲区还有空间,发送可以继续

但它解决不了这些问题:

  • 接收方彻底不消费,缓冲区迟早写满
  • 生产速度长期大于消费速度,阻塞只是推迟
  • 主流程提前返回后,无人再读 results

所以缓冲 channel 更像削峰,而不是彻底去掉同步约束。

十五、nil channel 和 closed channel 的边界容易混

这两个边界题很常见,答法需要明确:

nil channel

  • nil channel 发送会永久阻塞
  • nil channel 接收也会永久阻塞
  • 常用于动态关闭 select 的某个分支

closed channel

  • 向已关闭 channel 发送会 panic
  • 从已关闭 channel 接收会立刻返回零值和 ok=false
  • 已关闭 channel 可以被持续接收,直到缓冲区读空

下面这个例子能把关闭后的读取语义讲清楚:

1
2
3
4
5
6
ch := make(chan int, 1)
ch <- 7
close(ch)

v1, ok1 := <-ch // v1=7, ok1=true
v2, ok2 := <-ch // v2=0, ok2=false

如果这个点说清楚,channel 语义已经过了大半。

十六、把这些点放回任务执行器的最小正确版本

下面给一个更接近工程实践的最小版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Job struct {
ID int
}

type Result struct {
JobID int
Err error
}

func worker(ctx context.Context, jobs <-chan Job, results chan<- Result) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}

err := handle(job)

select {
case <-ctx.Done():
return
case results <- Result{JobID: job.ID, Err: err}:
}
}
}
}

这个版本比“裸 for range jobs”稳一点,原因有三条:

  1. worker 能响应整批取消
  2. jobs 关闭后 worker 能退出
  3. 结果发送时也考虑了下游已经不再接收的情况

这三条正好对应调度题后半段最有工程味的部分:不是 goroutine 起起来就结束了,而是每个阻塞点都要有退出路径。

十七、错误示例一:结果无人接收,worker 卡死

下面这类代码在任务执行器里非常常见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func worker(jobs <-chan Job, results chan<- Result) {
for job := range jobs {
err := handle(job)
results <- Result{JobID: job.ID, Err: err}
}
}

func run(jobs []Job) []Result {
jobCh := make(chan Job)
resultCh := make(chan Result)

for i := 0; i < 4; i++ {
go worker(jobCh, resultCh)
}

for _, job := range jobs {
jobCh <- job
}
close(jobCh)

return nil
}

问题在于:

  • run 从来没有接收 resultCh
  • worker 执行到 results <- handle(job) 就会阻塞
  • 阻塞的 worker 无法退出

这就是 goroutine 泄漏的一条典型路径:发送方还活着,接收方已经不存在。

十八、错误示例二:只退出上层函数,没有退出后台 goroutine

再看一个更隐蔽的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func runOne(ctx context.Context, job Job) error {
done := make(chan error, 1)

go func() {
done <- handle(job)
}()

select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}

如果 ctx 先超时,而 handle(job) 还在慢慢执行,会发生什么?

  • runOne 已经返回
  • 外层调用方以为这次任务结束了
  • 后台 goroutine 还在跑
  • 如果 done 无缓冲,后台 goroutine 最后还可能卡在发送上

这就是“函数返回了,但 goroutine 没收口”的泄漏。问题根源不是 select 写错,而是取消信号没有传进真正执行工作的那层逻辑。

十九、goroutine 泄漏到底该怎么定义

goroutine 泄漏不是“goroutine 数量稍微多一点”,而是:

goroutine 已经失去业务价值,但仍然因为阻塞、等待、未感知取消或错误的生命周期管理而长期存活。

它和死锁的区别在于:

  • 死锁通常表现为系统整体推进不了
  • 泄漏可能系统还能继续跑,只是 goroutine 数量持续增长

它和慢请求的区别在于:

  • 慢请求最终能结束
  • 泄漏是本该结束却没有结束

在执行器场景里,泄漏常见来源有:

  • 向无人接收的 channel 发送
  • 永远等不到关闭的 for range
  • 下游超时返回,但上游 goroutine 不感知取消
  • ticker、timer、后台重试协程没有停止

二十、怎么把退出路径设计完整

写并发代码时,可以沿着阻塞点逐个检查退出路径。

检查顺序很实用:

  1. 哪些地方会阻塞在接收
  2. 哪些地方会阻塞在发送
  3. 哪些 goroutine 在等 WaitGroup
  4. 哪些 I/O 或重试逻辑没接入 context
  5. 哪个对象负责关闭 channel

对于任务执行器,更稳的约束通常是:

  • jobs 由生产方关闭
  • results 由等待所有 worker 退出的协调方关闭
  • worker 内部所有 select 都能响应 ctx.Done()
  • 任务函数优先接收 context.Context

只要这四点缺一块,泄漏概率就会明显上升。

二十一、用测试验证 worker 是否真的退出

并发问题如果只靠肉眼看代码,很容易遗漏。更稳的方式是补最小测试,验证“取消后 goroutine 会不会退出”。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestWorkerExitOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
jobs := make(chan Job)
results := make(chan Result)

done := make(chan struct{})
go func() {
worker(ctx, jobs, results)
close(done)
}()

cancel()

select {
case <-done:
case <-time.After(200 * time.Millisecond):
t.Fatal("worker did not exit after cancel")
}
}

这类测试的价值不是证明调度顺序,而是验证:

  • 是否存在退出路径
  • 取消能否真正传到 worker
  • worker 会不会在某个发送或接收点挂住

二十二、怎么观察是否出现阻塞和泄漏

工程排障时,常见手段有这些:

  • runtime.NumGoroutine():先看 goroutine 数量是否持续增长
  • goroutine profile:看卡在哪些栈上
  • block profile:看阻塞热点
  • go tool trace:看调度、网络阻塞、syscall、GC 等时间线
  • pprof:观察 goroutine、mutex、block 等剖面

如果 goroutine profile 里长期出现大量类似栈:

1
2
goroutine 123 [chan send]:
main.worker(...)

或者:

1
2
goroutine 456 [chan receive]:
main.collect(...)

定位方向就比较明确了:

  • chan send 多,优先查接收方是否提前退出
  • chan receive 多,优先查发送方是否关闭或停止生产
  • 卡在 selectctx.Done() 之外,优先查是否遗漏取消透传

二十三、如何把答案组织成 90 秒版本

如果时间很短,可以按这个顺序答:

  1. GMP 是 Go 的调度模型,G 是 goroutine,M 是线程,P 是执行 Go 代码的调度资源
  2. goroutine 先进入运行队列,由绑定 P 的 M 执行,本地队列空了会走全局队列和 work stealing
  3. 抢占解决的是长时间不主动让出执行权的问题,Go 1.14 起异步抢占能力更强
  4. channel 阻塞本质上是发送方和接收方同步条件不满足
  5. goroutine 泄漏通常来自阻塞点没有退出路径,例如无人接收、永不关闭、取消没透传

这个版本的重点不是面面俱到,而是把四个点连成一条逻辑链。

二十四、如果给 3 分钟,可以怎么展开

时间稍微长一点,可以继续落到执行器场景:

  1. 先说 worker pool 里 goroutine 如何被调度
  2. 再说 CPU 密集任务为什么需要抢占保证响应性
  3. 接着说明 jobsresults 上的发送接收为何会阻塞
  4. 最后说明超时返回后如果后台 worker 还卡在发送或接收,就形成 goroutine 泄漏

再补一句排障路径,答案会更完整:

排查时先看 goroutine 数量和 profile,再顺着 channel 发送、接收、等待关闭、context 取消这几条线找未收口点。

这样既回答了原理,也回答了工程里怎么落地。

二十五、这道题里常见的追问怎么接

常见追问通常集中在这些地方:

  1. GOMAXPROCS 控制的是什么
  2. 为什么有了缓冲 channel 还是可能泄漏
  3. close(channel) 应该由谁做
  4. 异步抢占是不是任何位置都能打断
  5. goroutine 泄漏怎么和正常长生命周期后台任务区分

回答时抓住两个标准就够了:

  • 先说运行时语义
  • 再说工程约束

例如 close(channel),语义上是“没有新数据了”;工程上通常由发送方或拥有发送权的一侧关闭,而不是任意接收方去猜什么时候结束。

二十六、边界和误区需要顺手点出来

这几个边界如果不提,答案容易显得过于理想化:

  • goroutine 轻量,不等于可以无限制创建
  • channel 能做同步,不等于共享状态都该塞进 channel
  • context 传下去了,不等于取消真的生效
  • 函数返回了,不等于后台 goroutine 已退出
  • select 写出来了,不等于阻塞就一定可控

这部分不用展开太长,但适合用来说明对工程风险有实际判断。

二十七、可以自己动手做的练习

如果准备系统梳理这组并发问题或者补并发基础,可以围绕这篇的小场景做三组练习:

  1. 写一个固定 4 个 worker 的任务执行器,支持超时取消和结果汇总
  2. 故意删掉 ctx.Done() 分支,观察测试如何超时,goroutine 数量如何变化
  3. results 改成无人消费或错误关闭,观察阻塞、panic 和 profile 栈信息

这三组练习能把“看懂”和“真的讲清楚”区分开。

二十八、结语

GMP、抢占、channel 阻塞和 goroutine 泄漏,本质上是在回答同一个问题:Go 程序里的并发任务是怎样被调度、怎样被挂起、怎样恢复执行,以及怎样正确退出的。

把这条主线讲清楚,答案就不会散。工程里把每个阻塞点的退出路径设计清楚,程序才不容易在高并发和超时场景下慢慢积出问题。

如果要把全文压成一句结论,可以记成这句:

调度器负责让 goroutine 跑起来,抢占负责让执行权收得回来,channel 阻塞负责暴露同步关系,goroutine 泄漏负责提醒退出路径还没闭合。