mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1605 字
5 分钟
Go 调度器原理:GMP 模型详解
2023-07-27

前言#

Go 语言最强大的特性之一就是 goroutine。一个 Go 程序可以轻松创建数十万个 goroutine,它们由 Go 运行时的调度器管理,在少量操作系统线程上高效运行。这一切背后的核心就是 GMP 调度模型。本文将深入 Go 调度器的每一个细节,从数据结构到调度策略,从 Work Stealing 到抢占机制。

调度器演进#

从 GM 到 GMP#

Go 调度器经历了一次重大架构变革:

flowchart TB subgraph "GM 模型 (Go 1.0)" A[Global Queue] --> B[M1] A --> C[M2] A --> D[M3] B --> A C --> A D --> A end subgraph "GMP 模型 (Go 1.1+)" E[Global Queue] --> F[P1] --> G[M1] E --> H[P2] --> I[M2] E --> J[P3] --> K[M3] F --> E H --> E J --> E end

GM 模型的问题

GM 模型 (Go 1.0):
┌──────────────────────────────────────────────┐
│ Global Queue │
│ [G1] [G2] [G3] [G4] [G5] [G6] [G7] ... │
└──────────────┬───────────────────────────────┘
│ 全局锁竞争!
┌─────┼─────┐
▼ ▼ ▼
[M1] [M2] [M3]
问题:
1. 全局队列需要加锁, 严重的锁竞争
2. M 从全局队列获取 G 时, 所有 M 都在争抢同一把锁
3. G 的局部性差: 同一个 G 可能被不同的 M 执行
4. 没有本地的缓存, 每次都要访问全局队列

GMP 模型的改进

GMP 模型 (Go 1.1+):
┌─────────────────────────────────────┐
│ Global Queue │
│ [G1] [G2] [G3] [G4] [G5] ... │
└─────────────────────────────────────┘
P1 (本地队列) P2 (本地队列)
[G10][G11][G12] [G20][G21][G22]
│ │
▼ ▼
[M1] [M2]
优势:
1. P 本地队列无锁, 减少竞争
2. G 的局部性好: 同一个 P 上的 G 通常在同一个 M 上执行
3. Work Stealing: 空闲 P 可以从其他 P 窃取 G
4. Hand Off: M 阻塞时 P 可以绑定到新的 M

一、核心数据结构#

1.1 G (Goroutine)#

G 是 goroutine 的运行时表示,源码定义在 src/runtime/runtime2.go

// src/runtime/runtime2.go - 简化
type g struct {
// 栈信息
stack stack // 栈范围 [lo, hi]
stackguard0 uintptr // 栈溢出检查
stackguard1 uintptr // 栈溢出检查 (C 栈)
// 调度信息
_panic *_panic
_defer *_defer
m *m // 当前绑定的 M
sched gobuf // 保存的调度上下文
param unsafe.Pointer
// 状态
atomicstatus atomic.Uint32
gopc uintptr // 创建者的 PC
startpc uintptr // go 语句的 PC
// 抢占相关
preempt bool // 抢占标志
preemptStop bool // 抢占时停止
preemptShrink bool // 抢占时缩小栈
// 等待相关
waiting *sudog // 等待队列
timer *timer
// 栈增长
stktopsp uintptr
}

栈结构

Goroutine 栈布局 (动态增长):
高地址
┌────────────────────────────────┐
│ Stack Guard │ ← stackguard0
│ (栈溢出检测区域) │
├────────────────────────────────┤
│ │
│ 栈增长空间 │
│ (预留给栈增长) │
│ │
├────────────────────────────────┤ ← stktopsp
│ 函数帧 N │
├────────────────────────────────┤
│ 函数帧 N-1 │
├────────────────────────────────┤
│ ... │
├────────────────────────────────┤
│ 函数帧 1 (runtime.main) │
├────────────────────────────────┤
│ 函数帧 0 (goexit) │ ← sched.sp
└────────────────────────────────┘ ← stack.lo
低地址

G 的状态转换

stateDiagram-v2 [*] --> Dead: go func() Dead --> Runnable: newproc() Runnable --> Running: schedule() Running --> Waiting: chan/mutex/IO Waiting --> Runnable: 唤醒 Running --> Runnable: preempt/yield Running --> Dead: goexit() Running --> Syscall: 系统调用 Syscall --> Runnable: 退出系统调用 Syscall --> Running: 退出系统调用(同一M)

G 的完整状态列表:

┌──────────────┬──────────────┬──────────────────────────────────┐
│ 状态 │ 值 │ 说明 │
├──────────────┼──────────────┼──────────────────────────────────┤
│ _Gidle │ 0 │ 刚分配, 尚未初始化 │
│ _Grunnable │ 1 │ 在运行队列中等待执行 │
│ _Grunning │ 2 │ 正在执行 │
│ _Gsyscall │ 3 │ 正在执行系统调用 │
│ _Gwaiting │ 4 │ 被阻塞 (chan/IO/timer) │
│ _Gdead │ 6 │ 已退出或复用 │
│ _Gcopystack │ 8 │ 栈正在被复制/增长 │
│ _Gpreempted │ 9 │ 被抢占 (Go 1.14+) │
└──────────────┴──────────────┴──────────────────────────────────┘

1.2 M (Machine)#

M 代表一个操作系统线程:

// src/runtime/runtime2.go - 简化
type m struct {
g0 *g // M 的特殊 g, 用于调度器栈
curg *g // 当前运行的 G
p puintptr // 绑定的 P
nextp puintptr // 唤醒时绑定的 P
oldp puintptr // 系统调用前的 P
// 线程信息
id int64
procid uint64 // OS 线程 ID
osthread bool // 是否为 OS 线程
// 调度信息
spinning bool // 是否正在寻找 G
lockedg guintptr // 锁定的 G (LockOSThread)
park note // 休眠通知
alllink *m // 全局 M 链表
// 用于 cgo
ncgocall uint64
ncgo int32
}

g0 的作用

每个 M 都有一个特殊的 g0 (调度栈):
┌─────────────────────────────────────┐
│ 用户 G 的栈 │ ← curg.stack
│ (业务代码, 可动态增长/缩小) │
├─────────────────────────────────────┤
│ g0 的栈 │ ← g0.stack
│ (调度器代码, 固定大小 ~8KB) │
│ - schedule() │
│ - execute() │
│ - gosave() / gogo() │
│ - 栈增长 (newstack) │
│ - GC 相关操作 │
└─────────────────────────────────────┘
切换过程:
curg ──gosave()──▶ g0 (保存用户 G 的上下文)
g0 ──gogo()───▶ curg (恢复用户 G 的上下文)

1.3 P (Processor)#

P 是逻辑处理器,是 GMP 模型的核心创新:

// src/runtime/runtime2.go - 简化
type p struct {
id int32
status uint32 // P 的状态
link puintptr
m muintptr // 绑定的 M
// 本地运行队列 (无锁)
runqhead uint32
runqtail uint32
runq [256]guintptr // 本地队列 (环形缓冲区)
runnext guintptr // 优先运行的 G
// 可用的 G 缓存
gFree struct {
gList
n int32
}
// 延时执行的 G (timer)
timers []*timer
numTimers atomic.Int32
// GC 相关
gcAssistTime float64
gcFractionalMarkTime float64
}

P 的状态转换

stateDiagram-v2 [*] --> Pidle: schedinit Pidle --> Prunning: wirep (绑定M) Prunning --> Pidle: releasep (解绑) Prunning --> Psyscall: 进入系统调用 Psyscall --> Prunning: 退出系统调用(同一M) Psyscall --> Pidle: handoffp (系统调用超时) Prunning --> Pstop: stopm Pstop --> Pidle: 开始运行
┌──────────────┬──────┬──────────────────────────────────────┐
│ 状态 │ 值 │ 说明 │
├──────────────┼──────┼──────────────────────────────────────┤
│ _Pidle │ 0 │ 空闲, 没有 M 绑定 │
│ _Prunning │ 1 │ 被 M 绑定, 正在执行用户代码 │
│ _Psyscall │ 2 │ 绑定的 M 在系统调用中 │
│ _Pgcstop │ 3 │ 被GC 暂停 │
│ _Pdead │ 4 │ 不再使用 (GOMAXPROCS 减小) │
└──────────────┴──────┴──────────────────────────────────────┘

1.4 三者关系#

GMP 关系全景:
┌──────────────────────────────────────────────────────────┐
│ Global Run Queue │
│ [G1] [G2] [G3] [G4] [G5] [G6] ... │
└──────────────────────────────────────────────────────────┘
│ │ │
┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
│ P0 │ │ P1 │ │ P2 │
│ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │
│ │本地队列│ │ │ │本地队列│ │ │ │本地队列│ │
│ │[G][G] │ │ │ │[G][G] │ │ │ │[G][G] │ │
│ │[G][G] │ │ │ │[G][G] │ │ │ │[G][G] │ │
│ └──────┘ │ │ └──────┘ │ │ └──────┘ │
│ runnext:G│ │ runnext:G│ │ runnext:G│
│ │ │ │ │ │ │ │ │
└────┼─────┘ └────┼─────┘ └────┼─────┘
│ │ │
▼ ▼ ▼
[M0] [M1] [M2]
(OS Thread) (OS Thread) (OS Thread)
│ │ │
▼ ▼ ▼
执行 curg 执行 curg 执行 curg

二、调度流程#

2.1 从 go func() 到被调度#

当你写下 go func() 时,运行时做了什么?

// 用户代码
go hello("world")

编译器会将 go 关键字翻译为 runtime.newproc 调用:

// src/runtime/proc.go - 简化
func newproc(fn *funcval, argp unsafe.Pointer) {
gp := getg()
pc := getcallerpc()
// 获取当前 G 所在的 P
pp := gp.m.p.ptr()
// 从 P 的空闲列表获取或新建 G
newg := gfget(pp)
if newg == nil {
// 创建新的 G, 初始栈大小 2KB (Go 1.4+)
newg = malg(stackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg)
}
// 设置 G 的入口函数和参数
memmove(unsafe.Pointer(newg.stack.hi-argSize), argp, argSize)
gostartcallfn(&newg.sched, fn)
newg.gopc = pc
// 将 G 放入运行队列
casgstatus(newg, _Gdead, _Grunnable)
runqput(pp, newg, true)
// 如果有其他空闲的 P, 唤醒一个 M 来执行
if mainStarted {
wakep()
}
}

完整流程:

sequenceDiagram participant U as 用户代码 participant R as Runtime participant P as P (本地队列) participant G as Global Queue participant M as 空闲 M U->>R: go func() R->>R: newproc() R->>P: gfget() 获取空闲 G alt 有空闲 G P-->>R: 返回复用的 G else 无空闲 G R->>R: malg() 创建新 G (2KB 栈) end R->>R: 初始化 G 的栈和调度上下文 R->>P: runqput() 放入本地队列 alt 本地队列满 (256个) P->>G: runqputslow() 移一半到全局队列 end R->>M: wakep() 唤醒空闲 M M->>M: 切换到 g0 栈 M->>P: 从本地队列获取 G M->>M: gogo() 切换到 G 执行

2.2 调度核心循环#

M 的执行循环在 src/runtime/proc.goschedule 函数中:

// src/runtime/proc.go - 简化
func schedule() {
mp := getg().m
// 每调度 61 次, 优先从全局队列获取 (防止饥饿)
if mp.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(mp.p.ptr(), 1)
unlock(&sched.lock)
if gp != nil {
execute(gp, inheritTime)
return
}
}
// 1. 从 P 的本地队列获取
gp, inheritTime := runqget(mp.p.ptr())
// 2. 没有可运行的 G, 开始寻找
if gp == nil {
gp, inheritTime = findrunnable()
}
execute(gp, inheritTime)
}

findrunnable 查找顺序:

flowchart TB A[findrunnable] --> B[1. 本地队列] B --> C{找到 G?} C -->|是| Z[执行] C -->|否| D[2. 全局队列] D --> E{找到 G?} E -->|是| Z E -->|否| F[3. netpoll] F --> G{找到 G?} G -->|是| Z G -->|否| H[4. 从其他 P 窃取] H --> I{找到 G?} I -->|是| Z I -->|否| J[5. 再次检查全局队列] J --> K{找到 G?} K -->|是| Z K -->|否| L[6. 休眠当前 M]
// src/runtime/proc.go - 简化
func findrunnable() (gp *g, inheritTime bool) {
mp := getg().m
pp := mp.p.ptr()
// 1. 本地队列
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime
}
// 2. 全局队列
if sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false
}
}
// 3. 网络轮询器
if netpollinited() {
if gp := netpoll(false); gp != nil { // 非阻塞
return gp, false
}
}
// 4. Work Stealing: 从其他 P 窃取
gp, inheritTime := stealWork(now)
if gp != nil {
return gp, inheritTime
}
// 5. 再次检查全局队列和 netpoll...
// 6. 实在没有 G, 休眠
stopm()
// 被唤醒后重新 schedule()
return nil, false
}

三、Work Stealing:窃取机制#

3.1 窃取策略#

当一个 P 的本地队列和全局队列都没有可运行的 G 时,它会尝试从其他 P 窃取:

flowchart LR A["P0 (空闲)"] -->|"窃取一半"| B["P1 (忙碌)"] A -->|"窃取一半"| C["P2 (忙碌)"] A -->|"窃取一半"| D["P3 (忙碌)"] B --> B1["[G1][G2][G3]<br/>[G4][G5][G6]"] A --> A1["窃取: [G4][G5][G6]"]

窃取算法实现(stealWork):

// src/runtime/proc.go - 简化
func stealWork(now int64) (gp *g, inheritTime bool) {
pp := getg().m.p.ptr()
p2 := randomStealOrder(pp.id) // 随机选择起始 P
for i := 0; i < int(gomaxprocs); i++ {
// 跳过自己
if p2 == pp {
continue
}
// 从 P2 的本地队列窃取一半
gp := runqsteal(pp, p2, true)
if gp != nil {
return gp, true
}
// 还可以窃取 P2 上正在运行的 G (如果它在系统调用中)
// 以及 timer
p2 = nextStealOrder(p2)
}
return nil, false
}

窃取细节

P1 本地队列 (环形缓冲区):
┌──────────────────────────────────────────────┐
│ [G1] [G2] [G3] [G4] [G5] [G6] [_] [_] ... │
│ ▲ ▲ │
│ runqhead runqtail │
└──────────────────────────────────────────────┘
P0 窃取一半 (3 个 G):
┌──────────────────────────────────────────────┐
│ [G1] [G2] [G3] [_] [_] [_] [_] [_] ... │
│ ▲ ▲ │
│ runqhead runqtail │
└──────────────────────────────────────────────┘
P0 获得: [G4] [G5] [G6]

3.2 runqput:放入本地队列#

// src/runtime/proc.go - 简化
func runqput(pp *p, gp *g, next bool) {
// next=true 表示优先执行 (放到 runnext 位置)
if next {
oldnext := pp.runnext
if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
// runnext 被其他线程修改了, 重试
}
if oldnext != 0 {
// 把旧的 runnext 放到队列尾部
runqputslow(pp, oldnext.ptr(), 0)
}
return
}
// 放到本地队列尾部
h := atomic.LoadAcq(&pp.runqhead)
t := pp.runqtail
if t-h < uint32(len(pp.runq)) {
pp.runq[t%uint32(len(pp.runq))].set(gp)
atomic.StoreRel(&pp.runqtail, t+1)
return
}
// 本地队列满了, 移一半到全局队列
runqputslow(pp, gp, 0)
}

四、Hand Off:系统调用处理#

4.1 系统调用时的 M 与 P 分离#

当 goroutine 执行系统调用时,M 会被操作系统阻塞。GMP 模型通过 Hand Off 机制,将 P 从阻塞的 M 上分离,绑定到新的 M 继续执行其他 G:

sequenceDiagram participant G as Goroutine participant M1 as M1 (原线程) participant P as P participant M2 as M2 (新线程) Note over G: G 执行系统调用 G->>M1: entersyscall() M1->>P: 解绑 P (status=Psyscall) Note over M1: M1 被OS阻塞 Note over P: sysmon 检测到 Psyscall P->>M2: handoffp() M2->>P: 绑定 P (status=Prunning) M2->>M2: 执行其他 G Note over G: 系统调用返回 G->>M1: exitsyscall() M1->>M1: 尝试获取 P alt 原来的 P 还在 M1->>P: 重新绑定 P M1->>M1: 继续执行 G else 原来的 P 已被夺走 M1->>M1: 把 G 放入全局队列 M1->>M1: 休眠 (进入空闲 M 列表) end

源码实现:

// src/runtime/proc.go - 简化
// 进入系统调用
func entersyscall() {
mp := getg().m
gp := mp.curg
// 保存 G 的调度上下文
save(gp.sched.sp, gp.sched.pc)
// 将 P 的状态设为 Psyscall
pp := mp.p.ptr()
pp.status = _Psyscall
// 更新系统调用时间戳
pp.syscalltick++
}
// 退出系统调用
func exitsyscall() {
mp := getg().m
gp := mp.curg
// 尝试重新获取 P
pp := mp.p.ptr()
if pp.status == _Psyscall {
// 原来的 P 还在, 重新绑定
casgstatus(gp, _Gsyscall, _Grunning)
return
}
// 原来的 P 已被夺走
// 尝试获取一个空闲的 P
pp = pidleget()
if pp != nil {
mp.p.set(pp)
pp.m.set(mp)
casgstatus(gp, _Gsyscall, _Grunning)
return
}
// 没有空闲的 P, 放入全局队列
globrunqput(gp)
// M 进入休眠
stopm()
}

4.2 Hand Off 触发条件#

系统监控 sysmon 定期检查处于 _Psyscall 状态的 P:

// src/runtime/proc.go - 简化
func retake(now int64) uint32 {
n := 0
for i := 0; i < len(allp); i++ {
pp := allp[i]
s := pp.status
if s == _Psyscall {
// 系统调用超过 20μs, 夺取 P
t := int64(pp.syscalltick)
if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 {
// P 本地队列为空且有其他空闲资源, 不夺取
continue
}
if now - t > 20*1000 { // 20μs
// 原子修改 P 的状态
if pp.casstatus(_Psyscall, _Pidle) {
n++
// 把 P 交给其他 M
handoffp(pp)
}
}
}
}
return n
}

五、抢占机制#

5.1 基于协作的抢占 (Go 1.2 - 1.13)#

早期的抢占机制依赖函数调用时的栈检查。编译器在每个函数的入口插入栈增长检查代码,如果发现 stackguard0 被设置为 stackPreempt,就会触发抢占:

// 编译器插入的栈检查代码 (伪代码)
func someFunction() {
// 函数入口: 检查栈
if stackguard0 == stackPreempt {
// 被标记为需要抢占
runtime.newstack() // 这里会调用 gosched+0
}
// ... 函数体
}

问题:纯计算密集型循环(无函数调用)无法被抢占:

// 这段代码在 Go 1.13 之前会导致其他 goroutine 饥饿
func busyLoop() {
for {
// 纯计算, 没有函数调用
// 编译器不会插入栈检查代码
// 永远不会被抢占!
}
}

5.2 基于信号的抢占 (Go 1.14+)#

Go 1.14 引入了异步抢占(signal-based preemption),解决了协作抢占的缺陷:

sequenceDiagram participant S as sysmon participant M as M (OS Thread) participant P as Signal Handler participant G as 被抢占的 G Note over S: 检测到 G 运行时间过长 S->>M: 发送 SIGURG 信号 M->>P: 信号处理器 doSigPreempt P->>P: 修改 G 的 PC P->>P: 插入 asyncPreempt 调用 P->>G: 从信号处理器返回 G->>G: 执行 asyncPreempt() G->>G: gopreempt_m() → schedule() Note over G: G 被放回运行队列

源码实现:

// src/runtime/signal_unix.go - 简化
func doSigPreempt(gp *g, ctxt *sigctxt) {
// 检查是否可以抢占
if wantAsyncPreempt(gp) {
// 修改 G 的执行上下文
// 在信号返回后执行 asyncPreempt
ctxt.pushCall(funcPC(asyncPreempt))
}
}
// src/runtime/preempt.go - 简化
func asyncPreempt() {
gp := getg()
// 保存当前 G 的完整上下文
// 然后调用 gopreempt_m
gopreempt_m(gp)
}
// src/runtime/proc.go - 简化
func gopreempt_m(gp *g) {
// 将 G 放回全局运行队列
casgstatus(gp, _Grunning, _Grunnable)
globrunqput(gp)
// 触发新的调度
schedule()
}

异步抢占的完整流程

sysmon 检测到 G 运行超过 10ms
发送 SIGURG 信号到目标 M
M 的信号处理器被触发
(sighandler → doSigPreempt)
修改 G 的寄存器上下文
(将 PC 指向 asyncPreempt)
信号处理器返回, M 继续执行
G 执行 asyncPreempt()
保存上下文 → gopreempt_m()
G 被放入全局队列
schedule() 选择下一个 G

六、调度器初始化#

6.1 schedinit#

程序启动时,运行时初始化调度器。入口在 src/runtime/proc.go

// src/runtime/proc.go - 简化
func schedinit() {
// 栈、内存分配器、GC 初始化...
// GOMAXPROCS: 默认等于 CPU 核心数
procs := ncpu
if n := atoi32(gogetenv("GOMAXPROCS")); n > 0 {
procs = n
}
if procs > _MaxGomaxprocs {
procs = _MaxGomaxprocs
}
// 创建所有 P
procresize(procs)
}

6.2 procresize#

// src/runtime/proc.go - 简化
func procresize(nprocs int32) *p {
// 1. 初始化所有 P
for i := int32(0); i < nprocs; i++ {
pp := allp[i]
if pp == nil {
pp = new(p)
}
pp.status = _Pgcstop
pp.id = i
pp.runqtail = 0
pp.runqhead = 0
// 分配 G 的缓存
pp.gFree.n = 0
}
// 2. 多余的 P 上的 G 移到全局队列
for i := nprocs; i < old; i++ {
p := allp[i]
// 将 P 本地队列的 G 放入全局队列
for p.runqtail != p.runqhead {
gp := p.runq[p.runqhead%uint32(len(p.runq))]
globrunqput(gp)
}
// 释放 P
p.status = _Pdead
}
// 3. 将空闲 P 放入空闲列表
var ppList pListNode
for i := nprocs - 1; i >= 0; i-- {
p := allp[i]
if p.status != _Pidle {
continue
}
pidleput(p)
}
return allp[0] // 返回 P0 给 m0
}
初始化流程:
┌─────────────────────────────────────────────────────┐
│ schedinit() │
├─────────────────────────────────────────────────────┤
│ 1. 确定GOMAXPROCS (默认=CPU核心数) │
│ 2. procresize(nprocs) │
│ ├── 创建 nprocs 个 P │
│ ├── P0 绑定 m0 │
│ └── P1..Pn 放入空闲列表 │
│ 3. 创建 main goroutine │
│ └── runtime.main → 用户 main() │
│ 4. 启动 sysmon 后台线程 │
└─────────────────────────────────────────────────────┘

七、全局运行队列与 P 本地队列#

7.1 两级队列架构#

flowchart TB subgraph "全局运行队列 (需要加锁)" GQ["[G1] [G2] [G3] [G4] [G5] ..."] end subgraph "P0" P0Q["本地队列: [G10][G11][G12]"] P0N["runnext: G13"] end subgraph "P1" P1Q["本地队列: [G20][G21]"] P1N["runnext: G22"] end GQ -.->|"每61次调度取1个"| P0Q GQ -.->|"窃取"| P1Q

7.2 队列容量#

本地队列:
固定大小 256 个 G (环形缓冲区)
runnext: 1 个优先 G
总计: 最多 257 个 G/P
全局队列:
无固定限制
通过 sched.runqsize 和 sched.runq 管理
队列溢出处理:
本地队列满 → 移一半 (128个) 到全局队列

7.3 调度优先级#

获取 G 的优先级 (从高到低):
1. runnext (最优先执行的 G)
↑ go func() 创建的 G 优先放在这里
2. 本地队列 runq[0..255]
↑ FIFO 顺序取出
3. 全局队列 (每61次调度检查一次)
↑ 防止全局队列的 G 饥饿
4. netpoll (网络就绪的 G)
5. Work Stealing (从其他 P 窃取)
6. 休眠

八、系统监控 (sysmon)#

8.1 sysmon 的职责#

sysmon 是一个不需要 P 就能运行的后台线程(M),负责多项监控任务:

// src/runtime/proc.go - 简化
func sysmon() {
// sysmon 不绑定 P, 独立运行
idleshift := 0
idle := 0
delay := uint32(0)
for {
// 动态调整休眠时间
if idle == 0 {
delay = 20 // 初始 20μs
} else if idle > 50 {
delay *= 2 // 最长 10ms
}
if delay > 10*1000 {
delay = 10 * 1000
}
usleep(delay)
// 1. 释放闲置超过 5 分钟的 span (内存回收)
if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() {
gcStart(t)
}
// 2. 检查是否需要强制 GC
lastgc := sched.lastgc.Load()
if lastgc != 0 && now-lastgc > forcegcperiod {
// 2 分钟未触发 GC, 强制触发
forcegc.idle = false
wakep()
}
// 3. 网络轮询
if netpollinited() {
gp := netpoll(false)
injectglist(gp)
}
// 4. 抢占长时间运行的 G
retake(now)
// 5. 唤醒沉睡的 M
if sched.npidle.Load() != 0 && sched.nmsys.Load() != 0 {
wakep()
}
}
}
flowchart TB A[sysmon 后台线程] --> B[检查间隔: 20μs ~ 10ms] B --> C[1. 释放闲置内存] B --> D[2. 强制 GC (2min)] B --> E[3. netpoll] B --> F[4. retake 抢占] B --> G[5. 唤醒空闲 M]

8.2 retake 抢占#

retake 负责两件事:夺取长时间系统调用的 P,和抢占长时间运行的 G:

retake 检查逻辑:
┌──────────────────────────────────────────┐
│ for 每个 P: │
│ if P.status == _Psyscall: │
│ if 系统调用 > 20μs: │
│ handoffp(P) → 夺取 P 给其他 M │
│ │
│ if P.status == _Prunning: │
│ if G 运行 > 10ms: │
│ preemptone(P) → 发送 SIGURG 信号 │
└──────────────────────────────────────────┘

九、网络轮询器 (netpoller)#

9.1 netpoll 与调度器的协作#

Go 的网络 I/O 使用非阻塞 I/O + epoll/kqueue 实现。当 goroutine 执行网络 I/O 时,如果数据没准备好,goroutine 会被挂起,等到数据就绪后再被唤醒:

sequenceDiagram participant G as Goroutine participant R as Runtime participant N as netpoll (epoll) participant S as sysmon G->>R: net.Read(fd) R->>N: epoll_ctl(ADD, fd, EPOLLIN) R->>R: gopark() 挂起 G Note over G: G 状态变为 _Gwaiting Note over N: 等待数据就绪... S->>N: sysmon: netpoll(false) N-->>S: 返回就绪的 G 列表 S->>R: injectglist() 唤醒 G Note over G: G 状态变为 _Grunnable R->>G: schedule() 调度执行 G->>G: 继续执行 net.Read 后的代码

9.2 netpoll 实现原理#

网络 I/O 流程:
┌────────────────────────────────────────────────────┐
│ G1: conn.Read(buf) │
│ ├── 设置 fd 为非阻塞 │
│ ├── read() → EAGAIN (数据未就绪) │
│ ├── epoll_ctl(EPOLL_CTL_ADD, fd, EPOLLIN) │
│ ├── gopark(netpollblock) │
│ │ └── G1 状态: _Gwaiting │
│ └── 等待... │
│ │
│ [内核] 数据到达, 触发 epoll 事件 │
│ │
│ sysmon / schedule: │
│ ├── netpoll(false) │
│ │ └── epoll_wait() → 返回就绪的 fd 列表 │
│ ├── 找到对应的 G1 │
│ └── goready(G1) → 放入运行队列 │
│ │
│ G1 被调度: │
│ └── 继续执行 read() → 成功读取数据 │
└────────────────────────────────────────────────────┘

十、调度器性能分析#

10.1 调度延迟追踪#

Go 提供了调度器追踪工具:

# 开启调度器追踪
GODEBUG=schedtrace=1000 ./myapp
# 输出示例 (每秒一次):
# SCHED 1000ms: gomaxprocs=8
# threads=12 spinning=0 idle=0
# P0: runq=3 [G1 G2 G3] gfreecnt=5
# P1: runq=1 [G4] gfreecnt=2
# ...
# global runq: 10
# idleprocs=0 idlethreads=2

10.2 常见性能问题#

┌───────────────────┬────────────────────┬──────────────────────────┐
│ 问题 │ 现象 │ 解决方案 │
├───────────────────┼────────────────────┼──────────────────────────┤
│ G 数量过多 │ 调度开销大 │ 使用 worker pool 限制 │
│ G 阻塞在系统调用 │ M 被占用 │ Hand Off 自动处理 │
│ 锁竞争 │ M 自旋等待 │ 减少 shared state │
│ GOMAXPROCS 过小 │ CPU 利用率低 │ 增大到 CPU 核心数 │
│ 内存不足 │ GC 频繁,暂停长 │ 减少内存分配 │
└───────────────────┴────────────────────┴──────────────────────────┘

10.3 GOMAXPROCS 调优#

import "runtime"
func init() {
// 默认值: CPU 核心数
// CPU 密集型: 设置为 CPU 核心数
// I/O 密集型: 可以适当增大
runtime.GOMAXPROCS(runtime.NumCPU())
}
GOMAXPROCS 对调度的影响:
GOMAXPROCS=1:
只有 1 个 P
所有 G 串行执行
适合调试竞态条件
GOMAXPROCS=CPU核心数 (默认):
充分利用多核
Work Stealing 更高效
GOMAXPROCS > CPU核心数:
可能增加上下文切换开销
通常不推荐

总结#

GMP 模型全景#

flowchart TB subgraph "用户代码" A["go func()"] --> B["newproc()"] end subgraph "调度器" B --> C["放入 P 本地队列"] C --> D["schedule()"] D --> E{"查找 G"} E --> F["runnext"] E --> G["本地队列"] E --> H["全局队列"] E --> I["netpoll"] E --> J["Work Stealing"] end subgraph "执行" K["execute(G)"] --> L["gogo() 切换栈"] L --> M["运行用户代码"] M --> N{"发生什么?"} N -->|函数调用| O["协作抢占检查"] N -->|系统调用| P["entersyscall()"] N -->|channel/IO| Q["gopark()"] N -->|运行过久| R["SIGURG 信号抢占"] O --> D P --> S["Hand Off"] Q --> T["放入等待队列"] R --> D S --> D T -->|唤醒| D end

核心要点#

  1. GMP 模型:G (goroutine)、M (OS 线程)、P (逻辑处理器) 三层抽象
  2. Work Stealing:空闲 P 从忙碌 P 窃取 G,均衡负载
  3. Hand Off:系统调用阻塞 M 时,P 绑定到新 M 继续工作
  4. 两级队列:P 本地队列(无锁)+ 全局队列(有锁),减少竞争
  5. 异步抢占:基于 SIGURG 信号的抢占解决了协作抢占无法处理纯计算循环的问题
  6. sysmon:后台监控线程负责抢占、GC 触发、netpoll、资源回收

常见问题#

Q1:goroutine 和线程的区别是什么?#

goroutine 是用户态的轻量级线程。创建一个 goroutine 只需要 2KB 栈空间(线程通常 1-8MB),goroutine 的创建、切换和销毁都在用户态完成,不需要陷入内核。Go 调度器将 M 个 goroutine 调度到 N 个 OS 线程上执行(M 模型),而传统线程是 1:1 映射。

Q2:为什么 P 的本地队列大小是 256?#

这是一个经验值。太小的队列会导致频繁访问全局队列(锁竞争),太大的队列会浪费内存并增加 Work Stealing 的开销。256 在绝大多数场景下能提供良好的平衡。

Q3:什么时候应该用 runtime.LockOSThread()?#

当你需要将 goroutine 绑定到特定的 OS 线程时使用。典型场景:OpenGL/Cocoa 等 GUI 库要求在特定线程操作;使用线程局部存储 (TLS) 的 C 库 (通过 cgo 调用)。调用后,这个 goroutine 会一直绑定到当前 M,直到调用 UnlockOSThread。

Q4:Go 调度器如何保证公平性?#

三个机制保证公平:1) 每 61 次调度优先从全局队列取 G,防止全局队列饥饿;2) 基于信号的异步抢占确保每个 G 最多运行 10ms 就让出 CPU;3) sysmon 后台线程定期检查并触发抢占。

Q5:sysmon 不绑定 P,为什么还能运行?#

sysmon 运行在一个专用的 M 上,这个 M 不绑定 P。sysmon 只执行一些轻量的管理操作(检查时间戳、发送信号),不执行用户代码,所以不需要 P。M 在没有 P 的情况下可以执行一些有限的运行时代码。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go 调度器原理:GMP 模型详解
https://blog.souloss.com/posts/principles/go-scheduler-principles/
作者
Souloss
发布于
2023-07-27
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时