Go:GMP 调度、抢占、channel 阻塞和 goroutine 泄漏应该怎么讲清楚
讨论 Go 并发运行时时,GMP、抢占、channel 阻塞和 goroutine 泄漏经常会被放在同一条链路里。原因不复杂:这四个点本来就不是孤立知识点,而是一套运行时行为。
如果只背定义,答案很快会散掉:
- GMP 说成三个字母的缩写
- 抢占只记住“避免饿死”
- channel 阻塞只记住“没发送方或者没接收方”
- goroutine 泄漏只记住“协程没退出”
这样的回答信息是碎的,和工程里的真实问题也接不上。
这一篇换一个讲法:围绕一个批处理任务执行器,把调度、阻塞、退出和排障放进同一条执行链里。这样既能讲清运行时在做什么,也能讲清在短时间说明时怎样把答案组织得短、准、完整。
一、这篇文章要解决什么问题
读完这一篇,应该能独立回答这些问题:
- GMP 模型里 G、M、P 各自负责什么
- goroutine 为什么不是一启动就立刻执行
- Go 的抢占解决了什么问题,不同版本的差别怎么交代
- channel 为什么会阻塞,阻塞点具体落在哪
- goroutine 泄漏和死锁、慢请求的区别是什么
- worker pool 场景里最常见的泄漏路径有哪些
- 短时间说明时怎样把这几个点在 90 秒和 3 分钟里讲清楚
如果这些问题能连成一条线,Go 并发题就不容易答成零碎概念。
二、先把场景立住:一个批处理任务执行器
假设现在要写一个批处理任务执行器,用来并发处理一批任务:
- 从
jobschannel 读取任务 - 启动固定数量 worker
- 每个 worker 执行任务并把结果写回
results - 调用方汇总成功、失败和超时数量
- 整批任务超时后尽快收口
这个场景不大,但足够覆盖四类高频追问点:
- worker 为什么能并发跑起来,对应 GMP 调度
- 某个 goroutine 长时间占用 CPU 时,其他任务怎么得到执行机会,对应抢占
jobs和results为什么会卡住,对应 channel 阻塞- 超时返回后后台还有 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 | type Job struct { |
当主 goroutine 启动多个 worker:
1 | for i := 0; i < 4; i++ { |
发生的事情可以这样讲:
go worker(...)创建新的 G- 新 G 进入某个 P 的本地运行队列,或者进入全局运行队列
- 拿到 P 的 M 从队列里取出 G 执行
- 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 里的抢占演进应该怎么讲
讲抢占时,适合分成两个阶段:
- 早期更接近协作式抢占
- Go 1.14 起支持更强的异步抢占
简化后的说法是:
- 早期版本更多依赖安全点,例如函数调用等位置
- 如果 goroutine 长时间跑纯计算,其他 goroutine 可能调度不及时
- Go 1.14 起运行时可以在更合适的时机异步抢占正在执行的 goroutine
这段答案的价值在于把“版本差异”和“为什么要有抢占”同时交代清楚。
如果继续追问,不妨补一句:
抢占不是随时随地中断任意指令,而是在运行时可接受的安全点附近完成,这样才能兼顾公平性和正确性。
十一、用一个 CPU 忙循环看抢占的意义
下面这个例子很适合解释没有明显阻塞点时为什么还需要抢占:
1 | package main |
这段代码说明的是:
- 只有一个 P
- 一个 goroutine 长时间做纯计算
- 另一个 goroutine 想执行定时逻辑
现代 Go 版本里,抢占让第二个 goroutine 更有机会及时得到调度。没有这层机制,纯计算 goroutine 会明显影响其他任务响应。
十二、channel 为什么会阻塞
channel 阻塞题最容易答得过于简化。更完整的表达是:
channel 是 goroutine 之间的同步点。发送和接收是否阻塞,不取决于“写法像不像队列”,而取决于另一侧是否在合适时机配合。
最常见的阻塞场景有四类:
- 无缓冲 channel 发送时,没有接收方
- 无缓冲 channel 接收时,没有发送方
- 有缓冲 channel 已满,发送方继续发送
- 有缓冲 channel 为空,接收方继续接收
这时 goroutine 的状态会从 runnable 变成 waiting,等到条件满足再恢复执行。
十三、先看一个最小阻塞示例
无缓冲 channel 的阻塞最直观:
1 | package main |
这段代码会直接卡住,因为:
ch <- 1需要一个接收方同时配合- 主 goroutine 自己就是发送方
- 没有其他 goroutine 来接收
再看接收阻塞:
1 | package main |
原因一样,只是角色互换了。
解释 channel 阻塞时,不妨明确说出一句:
channel 不是自动异步队列,它本质上表达的是发送方和接收方之间的同步关系。
十四、缓冲 channel 不是阻塞问题的万能解法
给 channel 加缓冲区能缓解部分阻塞,但不能替代退出设计。
例如:
1 | results := make(chan Result, 16) |
缓冲区带来的效果是:
- 发送方不需要每次都等接收方立刻就位
- 只要缓冲区还有空间,发送可以继续
但它解决不了这些问题:
- 接收方彻底不消费,缓冲区迟早写满
- 生产速度长期大于消费速度,阻塞只是推迟
- 主流程提前返回后,无人再读
results
所以缓冲 channel 更像削峰,而不是彻底去掉同步约束。
十五、nil channel 和 closed channel 的边界容易混
这两个边界题很常见,答法需要明确:
nil channel:
- 向
nilchannel 发送会永久阻塞 - 从
nilchannel 接收也会永久阻塞 - 常用于动态关闭
select的某个分支
closed channel:
- 向已关闭 channel 发送会 panic
- 从已关闭 channel 接收会立刻返回零值和
ok=false - 已关闭 channel 可以被持续接收,直到缓冲区读空
下面这个例子能把关闭后的读取语义讲清楚:
1 | ch := make(chan int, 1) |
如果这个点说清楚,channel 语义已经过了大半。
十六、把这些点放回任务执行器的最小正确版本
下面给一个更接近工程实践的最小版本:
1 | type Job struct { |
这个版本比“裸 for range jobs”稳一点,原因有三条:
- worker 能响应整批取消
jobs关闭后 worker 能退出- 结果发送时也考虑了下游已经不再接收的情况
这三条正好对应调度题后半段最有工程味的部分:不是 goroutine 起起来就结束了,而是每个阻塞点都要有退出路径。
十七、错误示例一:结果无人接收,worker 卡死
下面这类代码在任务执行器里非常常见:
1 | func worker(jobs <-chan Job, results chan<- Result) { |
问题在于:
run从来没有接收resultCh- worker 执行到
results <- handle(job)就会阻塞 - 阻塞的 worker 无法退出
这就是 goroutine 泄漏的一条典型路径:发送方还活着,接收方已经不存在。
十八、错误示例二:只退出上层函数,没有退出后台 goroutine
再看一个更隐蔽的例子:
1 | func runOne(ctx context.Context, job Job) error { |
如果 ctx 先超时,而 handle(job) 还在慢慢执行,会发生什么?
runOne已经返回- 外层调用方以为这次任务结束了
- 后台 goroutine 还在跑
- 如果
done无缓冲,后台 goroutine 最后还可能卡在发送上
这就是“函数返回了,但 goroutine 没收口”的泄漏。问题根源不是 select 写错,而是取消信号没有传进真正执行工作的那层逻辑。
十九、goroutine 泄漏到底该怎么定义
goroutine 泄漏不是“goroutine 数量稍微多一点”,而是:
goroutine 已经失去业务价值,但仍然因为阻塞、等待、未感知取消或错误的生命周期管理而长期存活。
它和死锁的区别在于:
- 死锁通常表现为系统整体推进不了
- 泄漏可能系统还能继续跑,只是 goroutine 数量持续增长
它和慢请求的区别在于:
- 慢请求最终能结束
- 泄漏是本该结束却没有结束
在执行器场景里,泄漏常见来源有:
- 向无人接收的 channel 发送
- 永远等不到关闭的
for range - 下游超时返回,但上游 goroutine 不感知取消
- ticker、timer、后台重试协程没有停止
二十、怎么把退出路径设计完整
写并发代码时,可以沿着阻塞点逐个检查退出路径。
检查顺序很实用:
- 哪些地方会阻塞在接收
- 哪些地方会阻塞在发送
- 哪些 goroutine 在等
WaitGroup - 哪些 I/O 或重试逻辑没接入
context - 哪个对象负责关闭 channel
对于任务执行器,更稳的约束通常是:
jobs由生产方关闭results由等待所有 worker 退出的协调方关闭- worker 内部所有
select都能响应ctx.Done() - 任务函数优先接收
context.Context
只要这四点缺一块,泄漏概率就会明显上升。
二十一、用测试验证 worker 是否真的退出
并发问题如果只靠肉眼看代码,很容易遗漏。更稳的方式是补最小测试,验证“取消后 goroutine 会不会退出”。
例如:
1 | func TestWorkerExitOnCancel(t *testing.T) { |
这类测试的价值不是证明调度顺序,而是验证:
- 是否存在退出路径
- 取消能否真正传到 worker
- worker 会不会在某个发送或接收点挂住
二十二、怎么观察是否出现阻塞和泄漏
工程排障时,常见手段有这些:
runtime.NumGoroutine():先看 goroutine 数量是否持续增长- goroutine profile:看卡在哪些栈上
- block profile:看阻塞热点
go tool trace:看调度、网络阻塞、syscall、GC 等时间线pprof:观察 goroutine、mutex、block 等剖面
如果 goroutine profile 里长期出现大量类似栈:
1 | goroutine 123 [chan send]: |
或者:
1 | goroutine 456 [chan receive]: |
定位方向就比较明确了:
chan send多,优先查接收方是否提前退出chan receive多,优先查发送方是否关闭或停止生产- 卡在
select的ctx.Done()之外,优先查是否遗漏取消透传
二十三、如何把答案组织成 90 秒版本
如果时间很短,可以按这个顺序答:
- GMP 是 Go 的调度模型,G 是 goroutine,M 是线程,P 是执行 Go 代码的调度资源
- goroutine 先进入运行队列,由绑定 P 的 M 执行,本地队列空了会走全局队列和 work stealing
- 抢占解决的是长时间不主动让出执行权的问题,Go 1.14 起异步抢占能力更强
- channel 阻塞本质上是发送方和接收方同步条件不满足
- goroutine 泄漏通常来自阻塞点没有退出路径,例如无人接收、永不关闭、取消没透传
这个版本的重点不是面面俱到,而是把四个点连成一条逻辑链。
二十四、如果给 3 分钟,可以怎么展开
时间稍微长一点,可以继续落到执行器场景:
- 先说 worker pool 里 goroutine 如何被调度
- 再说 CPU 密集任务为什么需要抢占保证响应性
- 接着说明
jobs、results上的发送接收为何会阻塞 - 最后说明超时返回后如果后台 worker 还卡在发送或接收,就形成 goroutine 泄漏
再补一句排障路径,答案会更完整:
排查时先看 goroutine 数量和 profile,再顺着 channel 发送、接收、等待关闭、context 取消这几条线找未收口点。
这样既回答了原理,也回答了工程里怎么落地。
二十五、这道题里常见的追问怎么接
常见追问通常集中在这些地方:
GOMAXPROCS控制的是什么- 为什么有了缓冲 channel 还是可能泄漏
close(channel)应该由谁做- 异步抢占是不是任何位置都能打断
- goroutine 泄漏怎么和正常长生命周期后台任务区分
回答时抓住两个标准就够了:
- 先说运行时语义
- 再说工程约束
例如 close(channel),语义上是“没有新数据了”;工程上通常由发送方或拥有发送权的一侧关闭,而不是任意接收方去猜什么时候结束。
二十六、边界和误区需要顺手点出来
这几个边界如果不提,答案容易显得过于理想化:
- goroutine 轻量,不等于可以无限制创建
- channel 能做同步,不等于共享状态都该塞进 channel
context传下去了,不等于取消真的生效- 函数返回了,不等于后台 goroutine 已退出
select写出来了,不等于阻塞就一定可控
这部分不用展开太长,但适合用来说明对工程风险有实际判断。
二十七、可以自己动手做的练习
如果准备系统梳理这组并发问题或者补并发基础,可以围绕这篇的小场景做三组练习:
- 写一个固定 4 个 worker 的任务执行器,支持超时取消和结果汇总
- 故意删掉
ctx.Done()分支,观察测试如何超时,goroutine 数量如何变化 - 把
results改成无人消费或错误关闭,观察阻塞、panic 和 profile 栈信息
这三组练习能把“看懂”和“真的讲清楚”区分开。
二十八、结语
GMP、抢占、channel 阻塞和 goroutine 泄漏,本质上是在回答同一个问题:Go 程序里的并发任务是怎样被调度、怎样被挂起、怎样恢复执行,以及怎样正确退出的。
把这条主线讲清楚,答案就不会散。工程里把每个阻塞点的退出路径设计清楚,程序才不容易在高并发和超时场景下慢慢积出问题。
如果要把全文压成一句结论,可以记成这句:
调度器负责让 goroutine 跑起来,抢占负责让执行权收得回来,channel 阻塞负责暴露同步关系,goroutine 泄漏负责提醒退出路径还没闭合。