Go-14 goroutine 与 GMP 调度模型
1. 进程、线程与协程
要理解 goroutine 为什么"轻",先要弄清它在整个并发抽象体系里所处的位置。从操作系统到语言运行时,并发执行单元大致分三层:进程(Process)→ 线程(Thread)→ 协程(Coroutine)。
1.1 三者的定位
┌────────────────────────── Operating System ──────────────────────────┐
│ │
│ Process ( independent address space, fd table, isolation ) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Thread ( kernel scheduling unit, shares process addr space ) │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ goroutine ( user-mode scheduling, runtime managed ) │ │ │
│ │ │ G1 G2 G3 G4 ... Gn │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
- 进程:操作系统资源分配的基本单位,拥有独立的虚拟地址空间、文件描述符表、信号处理等。进程间隔离性最强,切换开销也最大(要切换页表、刷 TLB)。
- 线程:操作系统 CPU 调度的基本单位。同一进程内的线程共享地址空间,切换比进程轻,但仍需陷入内核态完成寄存器保存/恢复、调度器选取下一个线程。
- 协程:由**用户态程序(语言运行时或库)**自己调度的执行单元,操作系统完全感知不到它的存在。切换只是普通的函数调用级别的寄存器保存,不进内核,因此极其廉价。
1.2 内核态切换 vs 用户态切换
线程切换必须经过内核,一次典型的上下文切换要做这些事:
- 触发陷入(时钟中断 / 系统调用),从用户态切到内核态;
- 保存当前线程的完整 CPU 上下文(通用寄存器、SP、PC、浮点状态等)到内核栈;
- 内核调度器挑选下一个可运行线程;
- 恢复新线程上下文,切回用户态;
- 可能伴随 TLB 失效、缓存冷启动。
一次内核级线程切换的直接开销通常在 1~2 微秒量级,加上缓存污染的间接成本可能更高。而 goroutine 的切换完全在用户态完成,只需保存/恢复少量寄存器(PC、SP、几个被调用者保存寄存器),开销约几十到一两百纳秒,相差一个数量级以上。
1.3 goroutine 的初始栈:2KB 可增长
线程的栈通常是固定大小且很大(Linux 默认 8MB),无论你用不用都预留着。这意味着开 1 万个线程光栈就要占 80GB 虚拟内存,根本不现实。
goroutine 则采用可增长/可收缩的分段栈(连续栈):
- 初始栈仅 2KB(
runtime常量_StackMin); - 函数调用时会在序言(prologue)检查栈是否够用,不够就触发 morestack:分配一块 2 倍大的新栈,把旧栈数据整体拷贝过去,并调整指针(栈拷贝,Go 1.4 起用连续栈取代早期的分段栈,避免"热点分裂"性能抖动);
- GC 期间栈也可能被收缩。
因此百万级 goroutine 只需约 GB 级内存,且大多数 goroutine 的栈始终很小。
1.4 对比表
| 维度 | 进程 | 线程 | goroutine |
|---|---|---|---|
| 调度者 | 操作系统内核 | 操作系统内核 | Go 运行时(用户态) |
| 地址空间 | 独立 | 进程内共享 | 进程内共享 |
| 创建开销 | 最大(复制页表等) | 较大(内核对象+栈) | 极小(分配 G 结构+2KB 栈) |
| 切换开销 | 微秒级(切页表/TLB) | ~1μs(陷内核) | 纳秒级(用户态寄存器) |
| 默认栈大小 | —— | 固定 8MB(Linux) | 初始 2KB,动态伸缩 |
| 通信方式 | IPC/管道/共享内存 | 共享内存+锁 | channel(推荐)/ 共享内存+锁 |
| 数量级 | 几百 | 几千 | 几十万~百万 |
一句话:goroutine = 用户态调度的、栈可伸缩的、极廉价的执行单元,Go 运行时把成千上万个 goroutine 复用(multiplex)到少量内核线程上运行,这正是 GMP 模型要解决的问题。
2. goroutine 基础
2.1 go 关键字启动
在任意函数调用前加 go,就把它变成一个 goroutine 异步执行:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) { // Go 1.22 起每次循环 i 是独立变量,但传参仍是最稳妥写法
defer wg.Done()
fmt.Printf("goroutine %d 执行\n", id)
}(i)
}
wg.Wait() // 等待所有 goroutine 完成
fmt.Println("main 结束")
}
go f() 的语义是:运行时创建一个新的 G,把 f 的入口和参数拷贝进去,放入调度队列,然后立即返回,main 继续往下走。何时真正执行由调度器决定。
2.2 轻量到什么程度:启动百万 goroutine
下面这段程序直接开一百万个 goroutine,观察内存占用:
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
const N = 1_000_000
var wg sync.WaitGroup
wg.Add(N)
start := time.Now()
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
time.Sleep(time.Second) // 让它们同时"存活",观察内存峰值
}()
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("已启动 %d 个 goroutine\n", runtime.NumGoroutine())
fmt.Printf("当前堆分配: %.1f MB\n", float64(m.Alloc)/1024/1024)
wg.Wait()
fmt.Printf("耗时: %v\n", time.Since(start))
}
典型输出(数值随机器不同):
已启动 1000001 个 goroutine
当前堆分配: 约 数百 MB
耗时: 约 1.x s
一百万个 goroutine 只消耗几百 MB 内存、创建耗时不到一秒。换成一百万个线程,操作系统早就 OOM 或拒绝创建了。
2.3 main 退出,所有 goroutine 立即结束
goroutine 不是守护线程,主 goroutine(main 函数)一旦返回,整个进程退出,其余 goroutine 无论执行到哪里都被强制终止,没有任何清理机会:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
time.Sleep(100 * time.Millisecond)
fmt.Println("这行大概率打印不出来") // main 已经退出了
}()
fmt.Println("main 直接结束")
// 没有等待,进程立刻退出
}
输出通常只有 main 直接结束。因此需要用 sync.WaitGroup、channel 或 context 来显式同步,绝不能靠 time.Sleep “碰运气"等待子 goroutine。
3. 为什么需要 GMP
3.1 早期的 GM 模型(Go 1.0)
最初的 Go 调度器只有两个角色:G(goroutine) 和 M(machine,内核线程),所有可运行的 G 放在一个全局运行队列里,M 从队列取 G 执行:
全局运行队列 (受一把全局锁保护)
┌─────────────────────────────┐
│ G G G G G G G G ... │
└─────────────────────────────┘
▲ ▲ ▲ ▲
│ │ │ │ (所有 M 抢同一把锁)
M1 M2 M3 M4
这个模型能跑,但有致命的性能问题:
- 全局锁争用严重:每次创建、调度、结束 G 都要抢全局锁,M 数量一多,锁竞争成为瓶颈。
- G 在 M 之间频繁迁移,破坏局部性:一个 G 在 M1 上跑到一半让出,下次可能在 M3 上恢复,CPU 缓存(cache)和内存局部性全丢了。
- 每个 M 都要维护内存缓存(mcache):M 阻塞在系统调用时也占着 mcache,内存浪费严重,且数据局部性差。
3.2 引入 P(Processor)
Go 1.1(2013 年,Dmitry Vyukov 的方案)引入了中间层 P(Processor,逻辑处理器),形成 GMP:
- P 是"执行 G 的资源/许可证",数量固定为
GOMAXPROCS; - 每个 P 拥有自己的本地运行队列(local run queue),大部分调度操作只碰本地队列,无需全局锁;
- mcache 挂到 P 上而不是 M 上,M 阻塞时把 P 连同 mcache 交给别的 M,内存不再浪费;
- M 必须先获得一个 P 才能执行 G,P 的数量天然限制了并行执行 G 的线程数。
引入 P 之后:全局锁退化为极少走的慢路径,绝大多数调度在无锁的本地队列完成;G 优先在同一个 P 上被调度,保住了缓存局部性;work-stealing 则解决了负载均衡。这就是现代 Go 调度器的基石。
4. GMP 三大组件
4.1 G — goroutine
G 是 goroutine 在运行时的表示(runtime.g 结构体),保存了它的栈、执行上下文和状态。关键字段(简化):
// 摘自 runtime/runtime2.go(简化并加注释)
type g struct {
stack stack // 栈的 [lo, hi) 边界
stackguard0 uintptr // 栈溢出检查的警戒线(morestack 判断用)
m *m // 当前正在执行本 G 的 M(没在执行则为 nil)
sched gobuf // 调度上下文:保存 SP/PC/BP 等寄存器,切换时用它恢复现场
atomicstatus atomic.Uint32 // G 的状态:_Grunnable/_Grunning/_Gwaiting...
goid uint64 // goroutine id
waitreason waitReason // 处于 _Gwaiting 时的原因(chan 阻塞、GC 等)
// ... 还有 defer、panic 链表等
}
// gobuf 就是"寄存器快照",切换 goroutine 本质是换一组 gobuf
type gobuf struct {
sp uintptr // 栈指针
pc uintptr // 程序计数器(下一条指令)
g guintptr
ret uintptr
bp uintptr // 帧指针
}
G 的主要状态(状态机):
| 状态 | 含义 |
|---|---|
_Gidle |
刚分配,尚未初始化 |
_Grunnable |
在运行队列中,等待被调度执行 |
_Grunning |
正在某个 M 上执行(占用 P) |
_Gsyscall |
正在执行系统调用 |
_Gwaiting |
阻塞中(等 channel、锁、timer、GC 等),不在运行队列 |
_Gdead |
执行完毕或刚被分配,等待复用(放入空闲 G 池) |
_Gcopystack |
正在进行栈拷贝(栈增长/收缩) |
状态流转示意:
go f() 调度器选中它 时间片到/主动让出
_Gidle ──▶ _Grunnable ──────────▶ _Grunning ──────────▶ _Grunnable
▲ │ │
唤醒(ready)│ chan block │ │ 系统调用
│ _Gwaiting│ │_Gsyscall
└────────────────────┘ └────────┐
▼
返回后重新入队 _Grunnable
执行结束 ──▶ _Gdead(回收进 gFree 复用)
4.2 M — machine(内核线程)
M 代表一个真正的操作系统线程(runtime.m),是实际干活、占用 CPU 的实体。M 由运行时通过 clone(Linux)创建,其入口是调度循环 schedule()。
type m struct {
g0 *g // 特殊的调度 goroutine,跑调度代码、用系统栈(不是用户 G 的栈)
curg *g // 当前正在运行的用户 G
p puintptr // 当前绑定的 P(执行用户代码时必须持有 P)
nextp puintptr // 即将绑定的 P
oldp puintptr // 进入系统调用前的 P
spinning bool // 是否处于"自旋找活"状态
// ... 信号处理、锁、线程本地存储等
}
关键点:
- 每个 M 都有一个 g0:这是运行时专用的 goroutine,栈很大(约 8KB),专门用来执行调度器代码、垃圾回收、栈增长等运行时逻辑。用户 G 与 g0 之间来回切换,就是"业务代码"与"调度代码"的切换。
- M 数量不固定,默认上限
10000(runtime.SetMaxThreads可调)。M 阻塞在系统调用时运行时会按需再创建 M,保证有足够线程干活。 - M 想执行用户 G,必须先绑定一个 P。
4.3 P — processor(逻辑处理器)
P(runtime.p)是介于 G 和 M 之间的调度上下文,持有执行 G 所需的本地资源:
type p struct {
id int32
status uint32 // _Pidle / _Prunning / _Psyscall / _Pgcstop ...
m muintptr // 关联的 M
// —— 本地运行队列(无锁访问,核心) ——
runqhead uint32
runqtail uint32
runq [256]guintptr // 固定 256 长度的环形数组
runnext guintptr // "下一个执行"槽,存刚被唤醒/创建的 G,优先级最高
mcache *mcache // 内存分配缓存(原来挂在 M 上,现在挂 P)
// ... timer 堆、GC 相关字段等
}
三者关系用一张图串起来:
┌─────────────────────────────────────────┐
全局队列 globrunq │ G G G G (all P share; needs lock) │
└─────────────────────────────────────────┘
▲ ▲
│ │ ← 队列空时来这偷
┌──────────────────┐ ┌──────────────────┐
│ P0 │ │ P1 │
│ runnext: G_a │ │ runnext: G_x │
│ runq: [Gb Gc..] │ │ runq: [Gy Gz..] │ ← 每个 P 一条本地队列
│ mcache │ │ mcache │
└────────┬─────────┘ └────────┬─────────┘
│ │ ← 绑定
┌───┴─────┐ ┌───┴─────┐
│ M0 │ │ M1 │ ← 内核线程,真正占 CPU
│(g0+curg)│ │(g0+curg)│
└─────────┘ └─────────┘
数量关系:P 的数量 = GOMAXPROCS(固定);M 的数量 ≥ P(动态,可能因系统调用暂时增多);G 的数量不限(几十万都行)。运行时里还有全局的空闲 P 链表(idle P)和空闲 M 链表(idle M)用于复用。
5. 调度流程
5.1 G 创建后放在哪里
执行 go f() 时,运行时调用 newproc 创建 G,放置策略是:
- 优先放入当前 P 的
runnext槽(这是"插队"槽,意味着新建的 G 很可能马上被执行,利于父子 goroutine 的局部性); - 若
runnext已有 G,则把原来的挤到本地runq环形队列尾部; - 若本地
runq已满(256 个),则把本地队列的前一半 + 新 G 一起打包,批量移到全局队列,给其他 P 匀负载。
go f() ──▶ newproc ──▶ ┌─ P.runnext 空? ── 是 ──▶ 放 runnext
│
└─ 否 ──▶ runq 满? ─ 否 ─▶ 放 runq 尾
│
└─ 是 ─▶ 拿出一半到 globrunq
5.2 M 的调度循环 schedule()
每个绑定了 P 的 M 都在跑一个永不停歇的调度循环,核心逻辑(伪代码):
// 极简伪代码,表达 findRunnable 的取 G 优先级
func schedule() {
for {
var g *g
// 1. 每调度 61 次,强制从全局队列取一次,防止全局队列饿死
if schedtick%61 == 0 {
g = globrunqget()
}
// 2. 先看本地 runnext / 本地队列
if g == nil {
g = runqget(p) // 含 runnext
}
// 3. 本地空了,去全局队列取一批
if g == nil {
g = globrunqget()
}
// 4. 还没有?检查 netpoller 是否有网络就绪的 G
if g == nil {
g = netpoll(false)
}
// 5. 仍没有 —— work-stealing:从其他 P 偷
if g == nil {
g = stealWork()
}
// 6. 真的没活干:解绑 P,M 进入休眠,等待被唤醒
if g == nil {
stopm()
continue
}
execute(g) // 切到 g 的栈,运行用户代码
}
}
取 G 的优先级顺序总结:
runnext ▶ 本地 runq ▶ 全局 globrunq ▶ netpoller ▶ 偷其他 P ▶ 休眠
(且每 61 次调度插一次全局队列,避免全局队列被本地队列饿死)
execute(g) 会把 G 状态置为 _Grunning,用 gogo(汇编)从 g0 栈切到 G 的栈,恢复 g.sched 里保存的 SP/PC,开始跑用户代码。G 让出时再切回 g0 栈继续 schedule()。
6. work-stealing 工作窃取
当一个 P 的本地队列和全局队列都空了,它不会闲着,而是去**“偷"其他 P 的活**,这就是 work-stealing,保证各 CPU 核心负载均衡。
6.1 偷取规则
- 从随机选一个受害者 P 开始(随机化避免多个 M 同时盯上同一个 P);
- 一次偷取目标 P 本地队列中一半的 G(“偷一半"是经典策略:既拿到足够多的活减少偷取频率,又不会把对方掏空导致对方立刻反过来偷你);
- 遍历所有 P 若干轮仍偷不到,才考虑检查 netpoller、最后休眠;
- 偷取时还会尝试偷对方的
runnext(但会稍作等待,因为 runnext 的 G 通常马上要被本 P 执行)。
偷取前:
P0(空) P1: [G1 G2 G3 G4 G5 G6]
│ 随机选中 P1,偷一半
▼
偷取后:
P0: [G1 G2 G3] P1: [G4 G5 G6]
6.2 自旋线程(spinning M)
为了让"有活马上有人干”,运行时会保留少量自旋 M:这些 M 没有 G 可跑,但不立即休眠,而是自旋地反复尝试 work-stealing 和查 netpoller。当有新 G 产生时,自旋 M 能立刻抢到,避免"新建线程/唤醒线程"的延迟。自旋 M 的数量受控(不超过忙碌 P 的数量),在响应速度和 CPU 空转之间取平衡。
7. 调度时机与抢占
Go 调度器是协作式 + 抢占式混合。理解在哪些点会发生调度,是排查"某个 goroutine 卡死不让出"问题的关键。
7.1 主动让出(gopark / Gosched)
大多数调度是 G 自愿让出 CPU 的:
- channel 收发阻塞、
sync.Mutex抢锁失败、time.Sleep、等待WaitGroup等:调用gopark,把 G 置为_Gwaiting,解绑当前 M,M 转去跑别的 G。条件满足时通过goready把 G 重新变_Grunnable入队。 runtime.Gosched():程序员显式让出,把当前 G 放回全局队列,立刻触发一次调度,但 G 仍是_Grunnable(不阻塞)。
package main
import (
"fmt"
"runtime"
)
func main() {
go func() {
for i := 0; i < 3; i++ {
fmt.Println("子 goroutine:", i)
runtime.Gosched() // 主动让出,给别人机会
}
}()
for i := 0; i < 3; i++ {
fmt.Println("main:", i)
runtime.Gosched()
}
}
7.2 抢占式调度的演进
如果一个 G 既不做系统调用、也不碰 channel,就在那里 for {} 死循环,纯协作式调度它永远不会让出,其他 G 就会饿死。Go 的抢占经历了三个阶段:
① Go 1.0:完全协作式——只有函数调用序言的栈检查点才可能被"标记抢占”。死循环里若没有函数调用,无从插入检查点,无法抢占。
② Go 1.2 ~ 1.13:基于栈标记的协作式抢占——sysmon 监控线程发现某 G 运行超过 10ms,就把它的 stackguard0 设成一个特殊值(stackPreempt)。当该 G 下次发生函数调用、执行栈检查序言时,发现这个标记就主动让出。缺陷依旧:没有函数调用的紧密循环无法被抢占。
经典的"卡死"例子(Go 1.13 及以前,GOMAXPROCS=1 时会一直打印不出 “done”):
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
go func() {
for {
} // 紧密死循环,无函数调用,Go 1.13 及以前无法被抢占
}()
time.Sleep(time.Millisecond)
fmt.Println("done") // Go 1.14+ 能打印,1.13- 卡死
}
③ Go 1.14+:基于信号的异步抢占(asynchronous preemption)——这是关键突破。sysmon 发现 G 运行超时后,向该 G 所在的 M(线程)发送一个 SIGURG 信号。信号处理程序在被中断的线程上跑,运行时借机检查并在安全点把当前 G 挂起、切回调度器。由于信号可以打断任意执行中的指令流(不再依赖函数调用),紧密死循环也能被抢占了。上面那段代码在 Go 1.14 及以后能正常打印 done。
抢占演进对比:
| 版本 | 机制 | 触发条件 | 能否抢占死循环 |
|---|---|---|---|
| Go 1.0 | 协作式 | 栈检查点 | ❌ |
| Go 1.2~1.13 | 协作式 + 栈标记 | 运行 >10ms 且有函数调用 | ❌(无函数调用时不行) |
| Go 1.14+ | 异步抢占(信号 SIGURG) | 运行 >10ms | ✅ |
8. 系统调用与网络轮询
阻塞是调度器最需要小心处理的场景:一个 G 阻塞了,绝不能让它把整个 M(线程)连同 P 一起拖住,导致 P 上其他 G 无法运行。
8.1 阻塞式系统调用与 handoff
当 G 要执行一个可能长时间阻塞的系统调用(如读写文件、syscall.Read)时:
进入 syscall 前 (entersyscall):
M0 ── P (runq: G2 G3 G4) G1 即将 syscall
系统调用阻塞后,sysmon 或 M 自己触发 handoff:
M0 ── (脱离 P,陷在 syscall) G1 (阻塞在内核)
│
└─ P 被交给空闲/新建的 M1
M1 ── P (runq: G2 G3 G4) ← G2/G3/G4 继续被执行,不受 G1 拖累
系统调用返回后 (exitsyscall):
M0 尝试重新获取一个 P:
- 抢到原来的 P 或空闲 P → 继续执行 G1
- 抢不到 → G1 变 _Grunnable 放入全局队列,M0 休眠进空闲池
具体流程:
- G 进入系统调用前调用
entersyscall,把 P 的状态标记为_Psyscall,并记录 P 与 M 的解绑意图(此时 M 仍暂时持有 P)。 - 若系统调用很快返回,M 直接拿回 P 继续跑,零开销。
- 若系统调用阻塞较久,
sysmon监控线程发现 P 处于_Psyscall且超时(约 20μs),就执行 handoff:把 P 从阻塞的 M 上剥离,交给一个空闲 M(没有就新建),让 P 上其余的 G 继续跑。 - 阻塞的系统调用返回后(
exitsyscall),原 M 尝试重新获取一个 P;获取不到就把当前 G 放回全局队列,自己进入空闲 M 池睡眠。
这套 handoff 机制保证了:即使成百上千个 G 同时阻塞在系统调用上,只要有 P 空出来就能被别的线程接管,不会浪费 CPU。
8.2 netpoller — 网络 I/O 不阻塞线程
如果每个网络读写都走上面的"阻塞系统调用 + handoff + 新建线程"路径,高并发下会创建海量线程。Go 用 netpoller(网络轮询器) 把网络 I/O 变成非阻塞的:
- Go 把所有 socket 都设为非阻塞模式,底层统一注册到操作系统的 I/O 多路复用机制:Linux 用 epoll、macOS/BSD 用 kqueue、Windows 用 IOCP。
- 当一个 G 对未就绪的 socket 做读/写,会得到
EAGAIN。运行时不让线程阻塞,而是把该 G 挂起(_Gwaiting),并在 netpoller 里注册"这个 fd 可读/可写时唤醒这个 G”,然后 M 立刻转去执行 P 上的其他 G。 - 调度循环里(
findRunnable)会周期性调用netpoll,sysmon也会调用;一旦 epoll/kqueue 报告某些 fd 就绪,就把对应的 G 批量变回_Grunnable、重新入队。
用户代码看起来是"阻塞"的:
conn.Read(buf) ← 写起来同步、直观
运行时底层实际:
┌──────────────────────────────────────────────────────────┐
│ socket not ready -> G parked (_Gwaiting) │
│ fd registered into epoll/kqueue, M runs other G │
│ ... (M never idles) ... │
│ epoll reports fd readable -> netpoll wakes G -> requeue │
└──────────────────────────────────────────────────────────┘
这就是 Go 网络编程的魔法:你用同步阻塞的代码风格(conn.Read / conn.Write)写业务,运行时在底层帮你转成 epoll 事件驱动的非阻塞 I/O,用少数几个线程扛住海量连接(类似 C10K 里 epoll 的效果,但对开发者完全透明)。
8.3 sysmon 监控线程
sysmon 是一个不绑定 P 的特殊后台 M,在后台独立循环(初始每 20μs 一次,逐步退避到最长 10ms 一次),承担全局巡检职责:
- 抢占:给运行超过 10ms 的 G 发信号,触发异步抢占;
- syscall handoff:把长时间卡在系统调用的 P 从 M 上剥离;
- netpoll:定期轮询网络就绪事件,唤醒等待 I/O 的 G;
- 强制 GC:距上次 GC 超过 2 分钟则强制触发;
- 归还内存:把长期空闲的内存 span 交还给操作系统。
sysmon 是整个调度器的"看门狗",很多"全局公平性"的保证都靠它。
9. GOMAXPROCS
GOMAXPROCS 决定了 P 的数量,也就是同一时刻最多有多少个 G 在并行执行用户代码(真正的 CPU 并行度)。
9.1 默认值与设置
从 Go 1.5 起,GOMAXPROCS 默认等于机器的逻辑 CPU 核数(runtime.NumCPU())。可以在运行时查询和设置:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("逻辑 CPU 数:", runtime.NumCPU())
fmt.Println("当前 GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 传 0 表示只查询不修改
old := runtime.GOMAXPROCS(2) // 设为 2,返回旧值
fmt.Println("旧 GOMAXPROCS:", old, "→ 新值:", runtime.GOMAXPROCS(0))
}
也可用环境变量 GOMAXPROCS=4 ./app 设置。
9.2 容器里的大坑
这是生产环境高频事故点:在 Kubernetes / Docker 里,即使给容器设了 limits.cpu: 2(2 核),runtime.NumCPU() 读到的仍是宿主机的物理核数(比如 64),于是 GOMAXPROCS 被设成 64。
后果:
- 运行时以为有 64 个核,创建 64 个 P、大量并行调度;
- 但 CFS 配额只允许用 2 核的 CPU 时间,一旦超额就被内核限流(throttling)、强制暂停;
- 结果是频繁的上下文切换、调度抖动、P99 延迟飙升,性能反而下降。
解决方案:uber-go/automaxprocs。它读取 cgroup 的 CPU quota,把 GOMAXPROCS 自动设成与容器配额匹配的值。用法极简,匿名导入即可:
import _ "go.uber.org/automaxprocs" // 匿名导入,init 时自动按 cgroup quota 设置 GOMAXPROCS
补充:Go 1.25 起标准运行时已内置容器感知,会读取 cgroup CPU 限额自动调整默认 GOMAXPROCS。但很多线上项目仍跑在旧版本 Go 上,automaxprocs 仍是稳妥选择。
9.3 该设多少
- CPU 密集型:设为 CPU 核数(默认值即可),P 太多只会徒增调度和缓存开销。
- I/O 密集型:GOMAXPROCS 主要影响并行计算度,I/O 阻塞由 netpoller 和 handoff 处理,与 P 数量关系不大;一般保持默认。并发度靠开更多 goroutine,而不是调大 GOMAXPROCS。
- 容器内:务必让它匹配 cgroup 配额(automaxprocs 或 Go 1.25+)。
10. 优化与实践
10.1 控制 goroutine 数量:用 worker pool
goroutine 虽廉价,但无限制地开依然会耗尽内存、拖垮调度器,尤其是"每个请求/每条数据开一个 goroutine"的写法遇到流量洪峰时。用带缓冲的信号量或 worker pool 限流:
package main
import (
"fmt"
"sync"
)
func main() {
tasks := make([]int, 100)
for i := range tasks {
tasks[i] = i
}
const maxConcurrency = 10
sem := make(chan struct{}, maxConcurrency) // 信号量:最多 10 个并发
var wg sync.WaitGroup
for _, t := range tasks {
wg.Add(1)
sem <- struct{}{} // 占一个名额,满了就阻塞(限流)
go func(task int) {
defer wg.Done()
defer func() { <-sem }() // 释放名额
_ = task // 处理任务...
}(t)
}
wg.Wait()
fmt.Println("全部完成,峰值并发不超过", maxConcurrency)
}
生产中更推荐成熟库如 golang.org/x/sync/errgroup(配合 SetLimit)或 ants 协程池。
10.2 协程泄漏(goroutine leak)
goroutine 阻塞在永远不会满足的条件上(channel 无人收发、无超时的等待),就永远不退出,栈内存无法回收——这就是协程泄漏,是 Go 服务内存缓慢增长、最终 OOM 的头号原因。
// ❌ 泄漏示例:ch 无人接收,goroutine 永远阻塞在发送
func leak() {
ch := make(chan int) // 无缓冲
go func() {
ch <- 42 // 阻塞在此,因为没有接收方,函数返回后这个 G 永久泄漏
}()
// 忘记接收,或提前 return
}
// ✅ 用 context 控制生命周期,可被取消
func worker(ctx context.Context, ch <-chan int) {
for {
select {
case <-ctx.Done(): // 收到取消信号,干净退出,避免泄漏
return
case v := <-ch:
_ = v // 处理
}
}
}
排查手段:
import "runtime"
fmt.Println("当前 goroutine 数:", runtime.NumGoroutine()) // 持续上涨即怀疑泄漏
配合 net/http/pprof 访问 /debug/pprof/goroutine?debug=1 看每个 goroutine 卡在哪一行;测试里可用 go.uber.org/goleak 自动检测。
10.3 观测调度:GODEBUG schedtrace
GODEBUG=schedtrace=1000 让运行时每 1000ms 打印一次调度器状态摘要:
GODEBUG=schedtrace=1000 ./app
# 输出示例:
# SCHED 1005ms: gomaxprocs=8 idleprocs=6 threads=12 spinningthreads=1 idlethreads=4 runqueue=0 [0 1 0 0 2 0 0 0]
字段含义:
| 字段 | 含义 |
|---|---|
gomaxprocs |
P 的数量 |
idleprocs |
空闲的 P 数量(越多说明越闲) |
threads |
M(线程)总数 |
spinningthreads |
正在自旋找活的 M 数 |
idlethreads |
空闲的 M 数 |
runqueue |
全局运行队列中的 G 数量 |
[...] |
各 P 本地队列中的 G 数量(判断负载是否均衡) |
加 scheddetail=1(配合 schedtrace)能打印每个 P、M、G 的详细状态,用于深度调试。runqueue 或某个本地队列长期堆积,说明 CPU 打满或存在调度不均。
11. 高频面试题
Q1:GMP 模型是什么?G、M、P 各代表什么?
GMP 是 Go 运行时的调度模型。G 是 goroutine,代表一个用户态执行单元(含栈和执行上下文);M 是 machine,即操作系统内核线程,是真正占用 CPU 干活的实体;P 是 processor(逻辑处理器),是"执行 G 所需的资源/许可证",数量固定为 GOMAXPROCS,持有本地运行队列和 mcache。M 必须绑定一个 P 才能执行 G。这样 Go 把海量 G 复用(M:N 调度)到少量线程上,兼顾了轻量、并行和缓存局部性。
Q2:为什么要引入 P?没有 P 的 GM 模型有什么问题?
早期 GM 模型只有一个受全局锁保护的全局运行队列,所有 M 抢锁调度 G,锁竞争严重;G 在 M 间频繁迁移破坏 CPU 缓存局部性;mcache 挂在 M 上导致阻塞的 M 也白占内存。引入 P 后:每个 P 有自己的本地队列,绝大多数调度无锁;G 优先在同 P 上调度,保住局部性;mcache 挂到 P,M 阻塞时把 P 连同 mcache 交给别的 M,内存不浪费。
Q3:work-stealing 是什么?为什么偷一半?
工作窃取:当某个 P 的本地队列和全局队列都空了,它随机选另一个 P,偷取其本地队列一半的 G 来执行,实现负载均衡、避免 CPU 空转。偷一半是权衡:偷太少则很快又要再偷、频繁偷取有开销;偷太多会把对方掏空,导致对方立刻反过来偷你,来回震荡。取一半最稳。
Q4:goroutine 和线程有什么区别?
① 调度者:线程由操作系统内核调度,goroutine 由 Go 运行时在用户态调度;② 栈:线程栈固定且大(默认 8MB),goroutine 初始仅 2KB 且可动态伸缩;③ 切换开销:线程切换要陷内核(~μs),goroutine 切换在用户态(~ns);④ 创建成本:线程涉及内核对象和大栈,goroutine 只需分配 g 结构和小栈;⑤ 数量级:线程几千顶天,goroutine 可达百万。本质是 M:N 模型——多个 goroutine 复用到少量线程上。
Q5:抢占式调度是怎么演进的?Go 1.14 解决了什么问题?
Go 1.0 是纯协作式,只有栈检查点能被抢占;Go 1.2~1.13 引入基于栈标记的协作式抢占(sysmon 发现 G 跑超 10ms 就打标记,G 下次函数调用时让出),但没有函数调用的紧密死循环无法被抢占,会饿死其他 G(尤其 GOMAXPROCS=1 时)。Go 1.14 引入基于信号(SIGURG)的异步抢占:sysmon 向目标线程发信号,信号处理程序在安全点挂起当前 G,因为信号能打断任意指令流,死循环也能被抢占,彻底解决了这个问题。
Q6:一个 goroutine 执行阻塞系统调用,会不会阻塞其他 goroutine?
不会。G 进入系统调用前(entersyscall)P 被标记为 _Psyscall。若系统调用短暂返回,M 直接拿回 P 继续跑。若阻塞较久,sysmon 会执行 handoff:把 P 从阻塞的 M 上剥离,交给空闲或新建的 M,让 P 上其余 G 继续执行。系统调用返回后,原 M 尝试重新获取 P,拿不到就把 G 放回全局队列、自己休眠。因此单个 G 的阻塞系统调用不会拖累同 P 上的其他 G。
Q7:netpoller 是什么?Go 如何用同步代码写出高性能网络程序?
netpoller 是 Go 运行时集成的网络轮询器,底层基于 epoll(Linux)/ kqueue(BSD/macOS)/ IOCP(Windows)。所有 socket 被设为非阻塞,当 G 读写未就绪的 socket 时,运行时不阻塞线程,而是把 G 挂起(_Gwaiting)并把 fd 注册进 netpoller,M 立刻去跑别的 G;fd 就绪后 netpoll 唤醒对应 G 重新入队。于是开发者用同步阻塞风格的代码(conn.Read),运行时底层帮你转成非阻塞事件驱动 I/O,用少量线程扛住海量连接。
Q8:GOMAXPROCS 应该设多少?容器里有什么坑?
GOMAXPROCS 决定 P 的数量即并行执行用户代码的最大线程数,默认等于 CPU 逻辑核数,通常保持默认即可(CPU 密集型设为核数,I/O 密集靠多开 goroutine 而非调大它)。容器里的坑:runtime.NumCPU() 读到的是宿主机核数而非 cgroup 限额,导致 P 过多、被 CFS 限流、调度抖动、延迟飙升。解决办法是用 uber-go/automaxprocs 按 cgroup quota 自动设置,或升级到 Go 1.25+(运行时已内置容器感知)。
Q9:什么是协程泄漏?如何排查?
goroutine 阻塞在永远无法满足的条件(无人收发的 channel、无超时的等待、未取消的循环)上而永不退出,其栈内存无法回收,导致内存持续增长最终 OOM。排查:runtime.NumGoroutine() 观察数量是否持续上涨;net/http/pprof 的 /debug/pprof/goroutine 看 goroutine 都卡在哪;测试用 go.uber.org/goleak。预防:channel 操作配合 select + context 取消或超时,确保每个 goroutine 都有明确的退出路径。
Q10:g0 是什么?runnext 又是什么?
g0 是每个 M 专属的调度 goroutine,栈较大,专门用来执行调度器代码、GC、栈增长等运行时逻辑;用户 G 与 g0 之间切换就是"业务代码"与"运行时代码"的切换。runnext 是每个 P 上一个特殊的"下一个执行"槽,优先级高于本地队列——新建或刚被唤醒的 G 会先放这里,让它极可能被立即执行,利于父子 goroutine 的缓存局部性和低延迟唤醒。
小结
本章围绕 Go 的并发引擎,自底向上讲透了 goroutine 与 GMP 调度模型:
- 三级抽象:进程 → 线程 → 协程。goroutine 是用户态调度、初始栈仅 2KB 且可伸缩的廉价执行单元,切换开销比线程低一个数量级,因此能轻松启动百万级。
- GMP 三组件:G(goroutine,含栈与状态机)、M(内核线程,真正占 CPU,每个 M 有 g0)、P(逻辑处理器,持本地队列与 mcache,数量 = GOMAXPROCS)。M 必须绑 P 才能跑 G,实现 M:N 调度。
- 调度流程:新 G 优先进 P 的 runnext / 本地 runq,满了溢出到全局队列;M 的
schedule循环按runnext ▶ 本地队列 ▶ 全局队列 ▶ netpoller ▶ 偷取的优先级找活,并每 61 次插一次全局队列保公平。 - 负载均衡:work-stealing 从其他 P 偷一半 G;自旋 M 让新活能被立即执行。
- 抢占演进:协作式 → 栈标记协作式 → Go 1.14 基于 SIGURG 信号的异步抢占,终于能抢占无函数调用的死循环。
- 阻塞处理:系统调用用 handoff 把 P 交给别的 M,避免拖累同 P 的 G;netpoller 用 epoll/kqueue 把网络 I/O 变非阻塞,让你用同步代码写出高并发;sysmon 后台看门狗统管抢占、handoff、netpoll、GC。
- 实践要点:容器里用 automaxprocs 修正 GOMAXPROCS;用 worker pool / 信号量控制 goroutine 数量;警惕协程泄漏,用 context 保证退出路径;用
GODEBUG=schedtrace与 pprof 观测调度。
理解 GMP,你才能真正解释清楚"为什么 Go 能用同步代码写出百万并发",也能在遇到调度抖动、协程泄漏、容器限流时快速定位。下一章我们将深入 channel 与 select 的实现原理。
xingliuhua