Table of contents
Open Table of contents
TL;DR
Go 运行时通过 G(goroutine)、M(OS thread)、P(logical processor)三级抽象实现 M:N 线程调度。P 的引入(Go 1.1)解决了全局锁竞争,work-stealing 保证负载均衡,异步抢占(Go 1.14+,SIGURG 信号)消除了 CPU 密集型 goroutine 的饥饿问题。Go 1.25 起 GOMAXPROCS 自动感知容器 cgroup CPU 限制。
1. GMP 解决什么问题
为什么 Go 需要自己的调度器
OS 线程的代价:
- 内存:每个 OS 线程默认栈空间 1-8MB;goroutine 初始栈仅 2KB,按需增长
- 创建/销毁:OS 线程创建涉及内核态切换,微秒级;goroutine 创建纳秒级
- 上下文切换:OS 线程切换 ~1-2 微秒(保存/恢复所有寄存器、TLB flush);goroutine 切换 ~50-100 纳秒(仅保存 SP、PC、BP)
- 数量限制:OS 线程数受内核限制(通常数千),goroutine 可轻松创建数十万甚至百万
M:N 线程模型
M 个 goroutine 复用到 N 个 OS 线程上执行(M >> N)。这是介于 1:1(每个用户线程对应一个内核线程,如 Java 传统线程)和 N:1(所有用户线程映射到一个内核线程,无法利用多核)之间的方案。
历史:GM 模型的问题(Go 1.0)
Go 1.0 只有 G 和 M,没有 P。所有可运行的 goroutine 放在一个全局队列中,受单一全局互斥锁保护。
Dmitry Vyukov 在 2012 年的 Scalable Go Scheduler Design Doc 中指出了四个核心问题:
| 问题 | 说明 |
|---|---|
| 全局锁竞争 | 所有 goroutine 操作(创建、完成、重新调度)都需要获取全局锁,严重限制可扩展性 |
| M 之间无法交接 | 当 M 阻塞在系统调用时,无法将其关联的可运行 goroutine 转移给其他 M |
| mcache 绑定在 M 上 | 内存分配缓存(mcache,约 2MB)绑定在 M 上,当 M 阻塞在系统调用时,mcache 闲置浪费 |
| 过度线程阻塞/唤醒 | 频繁的 M 阻塞和唤醒增加了系统开销 |
解决方案:引入 P(processor)作为中间层。P 持有本地运行队列和 mcache,数量 = GOMAXPROCS。M 必须获取 P 才能执行 Go 代码。M 阻塞时,P 可以交给其他 M。
来源:Scalable Go Scheduler Design Doc (Dmitry Vyukov, 2012),实现于 Go 1.1。
2. 核心概念:G、M、P
G(Goroutine)
代表一个并发执行单元。数据结构定义在 runtime/runtime2.go。
关键字段:
type g struct {
stack stack // 栈内存范围 [stack.lo, stack.hi)
stackguard0 uintptr // 栈增长检查;设为 stackPreempt 时触发抢占
sched gobuf // 保存的执行上下文(SP, PC, BP 等)
atomicstatus atomic.Uint32 // 当前状态
goid uint64 // goroutine ID
m *m // 当前绑定的 M(nil 如果不在运行)
preempt bool // 抢占信号
waiting *sudog // 等待的 sudog 链表(channel 操作等)
waitsince int64 // 阻塞开始时间
waitreason waitReason // 阻塞原因
}
type gobuf struct {
sp uintptr // 栈指针
pc uintptr // 程序计数器
g guintptr // 所属 goroutine
bp uintptr // 帧指针
}
状态机:
newproc() schedule() gopark()
_Gidle ---------> _Grunnable ---------> _Grunning ---------> _Gwaiting
^ | |
| | entersyscall() | goready()
| v |
| _Gsyscall |
| | |
+---------------------+---------------------+
exitsyscall() / goready()
_Grunning ---(完成)---> _Gdead(放入 gFree 列表复用)
_Grunning ---(栈复制)---> _Gcopystack ---> _Grunning
_Grunning ---(信号抢占)---> _Gpreempted ---> _Grunnable
| 状态 | 值 | 含义 |
|---|---|---|
_Gidle | 0 | 刚分配,尚未初始化 |
_Grunnable | 1 | 在运行队列中,未执行用户代码 |
_Grunning | 2 | 正在 M 上执行用户代码,拥有栈 |
_Gsyscall | 3 | 正在执行系统调用,不执行用户代码 |
_Gwaiting | 4 | 阻塞在运行时(channel/mutex/sleep 等) |
_Gdead | 6 | 已完成,可能在 gFree 列表中等待复用 |
_Gcopystack | 8 | 栈正在被复制(栈增长/收缩) |
_Gpreempted | 9 | 被 suspendG 抢占暂停 |
GC 扫描时会叠加 _Gscan(0x1000)标记位,例如 _Gscanrunning = 0x1002。
M(Machine / OS Thread)
代表一个操作系统线程。
关键字段:
type m struct {
g0 *g // 调度栈(16-48KB),执行 schedule() 等运行时代码
curg *g // 当前执行的用户 goroutine
p puintptr // 当前关联的 P(执行 Go 代码时非 nil)
nextp puintptr // 唤醒时将要关联的 P
oldp puintptr // 进入系统调用前关联的 P
spinning bool // 正在自旋寻找工作
blocked bool // 阻塞在 note 上
id int64
gsignal *g // 信号处理 goroutine
preemptGen atomic.Uint32 // 已完成的抢占信号计数
}
关键特性:
- 每个 M 有一个
g0,使用系统栈(而非 goroutine 栈)执行调度代码 - M 必须获取 P 才能执行 Go 代码,但可以在没有 P 的情况下阻塞在系统调用中
sysmon是一个特殊的 M,不需要 P,独立运行- Go 运行时默认最多 10,000 个 M(
runtime/debug.SetMaxThreads可调) - M spinning(自旋):当 M 本地没有工作但尚未放弃寻找时处于自旋状态。自旋 M 的数量由
sched.nmspinning跟踪,限制为最多 busy P 数量的一半
P(Processor)
逻辑处理器,是执行 Go 代码所需的资源。
关键字段:
type p struct {
id int32
status uint32 // _Pidle, _Prunning, _Pgcstop, _Pdead
m muintptr // 反向指向关联的 M
// 本地运行队列
runqhead uint32
runqtail uint32
runq [256]guintptr // 256 槽位的环形队列(lock-free)
runnext guintptr // 下一个优先运行的 G(producer-consumer 优化)
// 资源
mcache *mcache // 内存分配缓存
gFree gList // 已完成的 G 复用池
// 调度计数器
schedtick uint32 // 每次调度递增
syscalltick uint32 // 每次系统调用递增
sysmontick sysmontick // sysmon 上次观察到的 tick
// GC 相关
gcMarkWorkerMode gcMarkWorkerMode
gcAssistTime int64
// 定时器
timers timers // 最小堆定时器
}
P 的状态:
| 状态 | 含义 |
|---|---|
_Pidle | 空闲,不在运行用户代码或调度器 |
_Prunning | 被 M 持有,正在运行用户代码或调度器 |
_Pgcstop | 因 STW 停止 |
_Pdead | 不再使用(GOMAXPROCS 缩小后) |
P 的数量 = GOMAXPROCS(默认 = CPU 核心数;Go 1.25 起 = min(CPU 核心数, cgroup CPU 限制))。
三者关系图
┌──────────────────────────────────┐
│ Global Run Queue │
│ (mutex 保护,所有 P 共享) │
└──────────┬───────────────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ P0 │ │ P1 │ │ P2 │
│ runq[256] │ │ runq[256] │ │ runq[256] │
│ runnext │ │ runnext │ │ runnext │
│ mcache │ │ mcache │ │ mcache │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ M0 │ │ M1 │ │ M2 │
│ g0 + curg│ │ g0 + curg│ │ g0 + curg│
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ G(用户) │ │ G(用户) │ │ G(用户) │
└───────────┘ └───────────┘ └───────────┘
另有 sysmon M(无 P,独立运行)
以及可能处于系统调用中的 M(已释放 P)
来源:
runtime/runtime2.go,runtime/proc.go头部注释(第 24-116 行)。
3. 调度算法:完整流程
schedule() 函数
位于 runtime/proc.go,在每个 M 的 g0 栈上执行,是调度的主循环:
schedule() → findRunnable() → execute(gp) → [用户代码运行] → goexit0() / gopark() / Gosched() → schedule()
findRunnable() 查找下一个 G 的顺序
这是调度器的核心,按以下优先级搜索可运行的 goroutine:
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | Trace reader | 内部运行时追踪 |
| 2 | GC mark worker | 如果 GC 活跃且需要 mark worker |
| 3 | 全局队列(概率性) | 每 61 次调度检查一次(schedtick % 61 == 0)— 61 是质数,避免同步模式 |
| 4 | 本地运行队列 | 先检查 runnext,再检查 runq 环形缓冲区 |
| 5 | 全局队列(批量) | 取 min(len(globalq)/nprocs + 1, len(globalq), 128) 个 |
| 6 | 网络轮询器(netpoll) | 检查已完成 I/O 的 goroutine |
| 7 | Work Stealing | 随机选择其他 P,偷取其本地队列的一半 |
| 8 | 再次检查 GC、全局队列 | 如果是 spinning M |
| 9 | 放弃(park) | 释放 P,M 进入休眠 |
Work Stealing 算法
当本地队列为空时:
- 随机选择另一个 P
- 尝试最多 4 次:
- 前 3 次:只从目标 P 的
runq偷取(偷走一半) - 第 4 次:先尝试偷
runnext,再尝试runq
- 前 3 次:只从目标 P 的
- 使用
runqsteal()函数,通过 CAS 操作 lock-free 地转移 goroutine
为什么偷一半:如果只偷一个,在工作分布不均时需要频繁偷取;偷一半使得负载快速均衡。
全局队列公平性
schedtick % 61 == 0 的设计确保全局队列中的 goroutine 不会被饿死。61 是质数,使得不同 P 的检查时机错开,避免所有 P 同时竞争全局锁。
全局队列批量获取公式:
n := min(len(sched.runq)/gomaxprocs + 1, len(sched.runq), 128)
这样的分摊策略减少全局锁的获取频率。
Handoff 机制
当 M 阻塞在系统调用时:
entersyscall():G 状态变为_Gsyscall,M 与 P 的绑定标记为 syscall 状态sysmon检测到 P 在 syscall 状态超过 10msretake()→handoffp():将 P 从阻塞的 M 上分离- 分离的 P 尝试:先唤醒一个空闲 M,如果没有则创建新 M
- 系统调用返回后,原 M 执行
exitsyscall():尝试重新获取一个 P;如果没有空闲 P,G 放入全局队列,M 进入休眠
runnext 优化(生产者-消费者 handoff)
当 G1 通过 channel 唤醒 G2 时,G2 被放入当前 P 的 runnext 槽位而非队尾。这使得 G2 几乎立即获得执行,实现了类似直接 handoff 的效果,对 producer-consumer 模式的延迟极为友好。
关键调度函数
| 函数 | 作用 |
|---|---|
runtime.Gosched() | 主动让出处理器,当前 G 变为 Runnable 放回队列 |
runtime.LockOSThread() | 将当前 G 绑定到当前 OS 线程 |
runtime.UnlockOSThread() | 解除绑定 |
gopark() | 将 G 从 Running 变为 Waiting(channel/mutex/sleep 等场景) |
goready() / ready() | 将 G 从 Waiting 变为 Runnable,放入运行队列 |
mcall(fn) | 切换到 g0 栈执行 fn(调度函数的入口) |
gogo() | 从 g0 切换回用户 G 执行(汇编实现) |
4. 系统调用处理与网络轮询器
阻塞系统调用
Go 标准库中的系统调用通过 syscall.Syscall 包装,自动调用 entersyscall() / exitsyscall()。
流程:
G 调用系统调用
→ entersyscall(): G 状态 → _Gsyscall, 保存 SP/PC/BP
→ M 仍持有 P,但 P 标记为 syscall 状态
→ [系统调用执行中...]
快路径(syscall 很快返回,P 还在):
→ exitsyscall(): 重新获取原来的 P 或空闲 P
→ G 状态 → _Grunning,继续执行
慢路径(syscall 太久,P 被 sysmon 拿走):
→ exitsyscall(): 找不到 P
→ G 放入全局队列(_Grunnable)
→ M 休眠(stopm)
RawSyscall(不经过调度器):用于极快的系统调用(如 getpid()),不执行 entersyscall/exitsyscall,M-P 绑定不变。
网络轮询器(Netpoller)
Go 的 net 包将所有网络 I/O 设为非阻塞,通过平台特定的 I/O 多路复用机制管理:
| 平台 | 实现 |
|---|---|
| Linux | epoll (runtime/netpoll_epoll.go) |
| macOS / BSD | kqueue (runtime/netpoll_kqueue.go) |
| Windows | IOCP (runtime/netpoll_windows.go) |
流程:
goroutine 执行 net.Read()
→ 底层 fd 设为非阻塞
→ read() 返回 EAGAIN(无数据)
→ gopark(): G 状态 → _Gwaiting, 记录在 pollDesc 中
→ G 从 M 上摘下,M 继续调度其他 G
[数据到达...]
findRunnable() 中调用 netpoll()
→ epoll_wait() / kevent() 返回就绪的 fd
→ 找到关联的 G,调用 goready()
→ G 状态 → _Grunnable,放入运行队列
关键点:文件 I/O 不走 netpoller。Linux 上 os.File.Read() 仍然是阻塞系统调用,会触发 M-P 分离的 handoff 机制。(Go 运行时在某些平台对 regular file 使用 netpoller,但 Linux 上 epoll 不支持 regular file。)
sysmon:系统监控线程
sysmon 是一个特殊的 M,不需要 P,以守护线程方式独立运行。
职责:
| 功能 | 说明 |
|---|---|
| retake(P 回收) | 遍历所有 P,如果 P 在 _Prunning 或 _Psyscall 状态超过 10ms,触发抢占或 handoff |
| 抢占长运行 G | 检测 G 运行超过 10ms(forcePreemptNS = 10_000_000),设置 stackguard0 = stackPreempt 或发送 SIGURG |
| Syscall P handoff | P 在 syscall 状态超过 10ms 且有等待运行的工作 → handoffp() 将 P 交给其他 M |
| netpoll | 如果超过 10ms 没有轮询网络,触发一次 netpoll |
| 强制 GC | 如果超过 2 分钟没有 GC,强制触发 |
| 释放定时器 | 检查定时器是否需要触发 |
sysmon 休眠策略:初始 20 微秒,如果持续空闲则指数增长到最大 10ms。有活跃工作时重置为 20 微秒。
retake 逻辑细节:
func retake(now int64) uint32 {
for i := 0; i < len(allp); i++ {
pp := allp[i]
s := pp.status
if s == _Prunning || s == _Psyscall {
t := int64(pp.schedtick)
if int64(pd.schedtick) != t {
pd.schedtick = uint32(t)
pd.schedwhen = now // 重置计时
continue // 最近有调度活动,跳过
}
if pd.schedwhen+forcePreemptNS <= now {
preemptone(pp) // 对 _Prunning: 抢占
// 对 _Psyscall: handoffp()
}
}
}
}
5. 抢占机制的演进
Go 1.2:协作式抢占
机制:编译器在每个函数入口插入栈检查代码(morestack 检查),检查 stackguard0 是否被设为 stackPreempt。如果是,跳转到 morestack_noctxt 执行抢占。
触发条件:sysmon 检测到 G 运行超过 10ms → 设置 g.stackguard0 = stackPreempt → 下次函数调用时检测到并让出。
致命缺陷:没有函数调用的紧密循环无法被抢占。
// 这个程序在 Go 1.13- 且 GOMAXPROCS=1 时会死锁
package main
import "fmt"
func main() {
go fmt.Println("hi")
for {} // 永远不会调用函数,永远不会检查 stackguard0
}
其他无法抢占的场景:
- 原子操作的自旋循环
- 纯计算的紧密循环(无函数调用、无 channel 操作、无内存分配)
- CGO 调用(C 代码中没有 Go 的栈检查)
Go 1.14:异步抢占(信号抢占)
机制:使用 POSIX 信号(Linux/macOS: SIGURG;Windows: SuspendThread + GetThreadContext)中断正在执行的 goroutine。
完整流程:
1. sysmon 检测到 G 运行超过 10ms
2. sysmon 调用 preemptM(mp)
3. preemptM 对目标 M 发送 SIGURG(通过 tgkill 系统调用)
4. 目标 M 的信号处理器接收 SIGURG
5. 信号处理器在 gsignal goroutine 上运行
6. 检查中断点是否是 safe point
- 如果是 safe point:
a. 保存完整寄存器状态
b. 将 asyncPreempt 注入为返回地址
c. asyncPreempt → asyncPreempt2 → gopreempt_m
d. G 状态 → _Grunnable,放回运行队列
e. M 执行 schedule() 选择下一个 G
- 如果不是 safe point:
a. 直接恢复执行,稍后重试
为什么选择 SIGURG:
| 原因 | 说明 |
|---|---|
| 调试器兼容 | 默认情况下 Linux 调试器会传递 SIGURG(不会像 SIGSEGV 那样中断调试) |
| libc 不使用 | 不会与 C 库冲突 |
| 可以丢弃 | 即使出现虚假 SIGURG 也无害 |
| 几乎无人使用 | SIGURG 的原始用途(TCP 带外数据)在实践中基本不用 |
Safe Points(安全点):
安全点是 GC 能够准确找到所有存活指针的位置。不安全的位置包括:
unsafe.Pointer→uintptr转换期间(指针暂时不可见于 GC)- 写屏障序列中间(enabled 检查和实际写入之间)
- range 循环中可能产生 past-the-end 指针的位置
在不安全点被中断时,运行时直接恢复执行并稍后重试。
零运行时开销:与编译器在循环头插入检查的方案(有 7.8% 性能损失)不同,信号方案只在实际需要抢占时才有开销。
可以关闭:GODEBUG=asyncpreemptoff=1 关闭异步抢占(用于调试)。
来源:Non-cooperative goroutine preemption proposal,Go 1.14 实现。
6. 关键调度场景
| 场景 | G 状态流转 | P 行为 | M 行为 | 关键机制 |
|---|---|---|---|---|
| CPU 密集型 G | Running → (10ms后) Runnable → Running | P 不变 | M 收到 SIGURG → 抢占 G → 选择下一个 G | 异步抢占(Go 1.14+) |
| 网络 I/O(net.Read) | Running → Waiting → Runnable | P 不释放 | M 继续调度其他 G | netpoller(epoll/kqueue),G park 在 pollDesc |
| 文件 I/O(os.File.Read) | Running → Syscall → Running/Runnable | 可能被 sysmon 回收 | M 阻塞在内核 | 阻塞 syscall → handoff |
| Channel/Mutex 阻塞 | Running → Waiting → Runnable | P 不释放 | M 继续调度其他 G | gopark/goready,sudog 队列 |
| CGO 调用 | Running → Syscall | P 可能被回收 | M 阻塞在 C 代码 | entersyscall → sysmon handoff |
| 大量 G(100K+) | 大部分在 Runnable/Waiting | 各 P 本地队列 + 全局队列 | M 数 ≈ GOMAXPROCS + syscall 中的 | work stealing 均衡,全局队列兜底 |
runtime.Gosched() | Running → Runnable | P 不释放 | M 继续调度下一个 G | 主动让出,G 放入全局队列 |
runtime.LockOSThread() | Running(锁定到 M) | P 绑定到该 M | M 只运行该 G,其他 G 需要找其他 M | G 完成或 Unlock 前 M 不执行其他 G |
7. 代码示例(Go 1.22+,可直接运行)
示例 1:GOMAXPROCS 对并行执行的影响
// file: gomaxprocs_demo.go
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func cpuWork(id int, wg *sync.WaitGroup) {
defer wg.Done()
start := time.Now()
// 纯 CPU 计算
sum := 0
for i := 0; i < 1_000_000_000; i++ {
sum += i
}
fmt.Printf("Worker %d: %v (sum=%d)\n", id, time.Since(start), sum%100)
}
func main() {
for _, procs := range []int{1, 2, 4, runtime.NumCPU()} {
runtime.GOMAXPROCS(procs)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 4; i++ {
wg.Add(1)
go cpuWork(i, &wg)
}
wg.Wait()
fmt.Printf("GOMAXPROCS=%d, Total: %v\n\n", procs, time.Since(start))
}
}
运行:go run gomaxprocs_demo.go
预期:GOMAXPROCS=1 时 4 个 worker 串行执行,GOMAXPROCS=4 时并行,总耗时约 1/4。
示例 2:使用 GODEBUG=schedtrace 观察调度器
// file: schedtrace_demo.go
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
runtime.GOMAXPROCS(4)
fmt.Printf("GOMAXPROCS=%d, NumCPU=%d\n", runtime.GOMAXPROCS(0), runtime.NumCPU())
var wg sync.WaitGroup
// 启动大量 goroutine
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 混合 CPU 和 sleep
sum := 0
for j := 0; j < 1_000_000; j++ {
sum += j
}
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait()
fmt.Println("Done")
}
运行:
GODEBUG=schedtrace=100 go run schedtrace_demo.go
输出格式:
SCHED 100ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=45 [123 89 76 102]
| 字段 | 含义 |
|---|---|
gomaxprocs=4 | GOMAXPROCS 值 |
idleprocs=0 | 空闲 P 数量 |
threads=6 | OS 线程总数 |
spinningthreads=0 | 自旋寻找工作的 M 数 |
idlethreads=1 | 空闲 M 数 |
runqueue=45 | 全局队列长度 |
[123 89 76 102] | 各 P 的本地队列长度 |
更详细的输出:GODEBUG=schedtrace=100,scheddetail=1
示例 3:展示无抢占时的 goroutine 饥饿
// file: starvation_demo.go
// 教学目的:展示 Go 1.14 之前紧密循环的问题
// Go 1.14+ 默认有异步抢占,所以这个程序能正常运行
// 使用 GODEBUG=asyncpreemptoff=1 可以重现旧行为
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
done := make(chan struct{})
go func() {
fmt.Println("goroutine: started")
done <- struct{}{}
}()
// 紧密循环 —— 在 Go 1.14+ 会被异步抢占
// 在 GODEBUG=asyncpreemptoff=1 时会死锁
start := time.Now()
for time.Since(start) < 2*time.Second {
// busy loop
}
<-done
fmt.Println("main: done")
}
运行对比:
# Go 1.14+:正常运行
GOMAXPROCS=1 go run starvation_demo.go
# 关闭异步抢占:死锁(Ctrl+C 退出)
GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 go run starvation_demo.go
示例 4:正确使用 runtime.LockOSThread()
// file: lockosthread_demo.go
package main
import (
"fmt"
"runtime"
)
// 模拟需要在固定线程上执行的操作(如 OpenGL、GUI 框架)
var mainThreadWork = make(chan func())
func init() {
// 在 init 中锁定:确保 main goroutine 绑定到主线程
runtime.LockOSThread()
}
func main() {
// 启动工作 goroutine
done := make(chan struct{})
go func() {
// 其他 goroutine 通过 channel 提交工作到主线程
for i := 0; i < 5; i++ {
i := i
result := make(chan int, 1)
mainThreadWork <- func() {
// 这段代码保证在主线程执行
fmt.Printf(" Main thread work #%d (thread %d)\n", i, getThreadID())
result <- i * 10
}
fmt.Printf("Worker got result: %d\n", <-result)
}
close(done)
}()
// 主线程事件循环
fmt.Println("Main thread event loop started")
for {
select {
case f := <-mainThreadWork:
f()
case <-done:
fmt.Println("All work done")
return
}
}
}
func getThreadID() int {
// runtime 不直接暴露线程 ID,这里用 goroutine ID 替代演示
// 真正需要线程 ID 时用 syscall.Gettid()(Linux)
var buf [64]byte
n := runtime.Stack(buf[:], false)
// 解析 "goroutine N" 中的 N
id := 0
for i := len("goroutine "); i < n; i++ {
if buf[i] < '0' || buf[i] > '9' {
break
}
id = id*10 + int(buf[i]-'0')
}
return id
}
示例 5:使用 go tool trace 追踪调度器
// file: trace_demo.go
package main
import (
"fmt"
"os"
"runtime"
"runtime/trace"
"sync"
)
func main() {
// 创建 trace 文件
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
// 开始追踪
if err := trace.Start(f); err != nil {
panic(err)
}
defer trace.Stop()
runtime.GOMAXPROCS(4)
var wg sync.WaitGroup
// 创建不同类型的负载
for i := 0; i < 20; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
if id%3 == 0 {
// CPU 密集
sum := 0
for j := 0; j < 10_000_000; j++ {
sum += j
}
_ = sum
} else if id%3 == 1 {
// Channel 通信
ch := make(chan int, 1)
go func() { ch <- 42 }()
<-ch
} else {
// 主动让出
for j := 0; j < 10; j++ {
runtime.Gosched()
}
}
}(i)
}
wg.Wait()
fmt.Println("Trace written to trace.out")
}
运行并查看:
go run trace_demo.go
go tool trace trace.out
# 浏览器自动打开,可查看:
# - Goroutine analysis: 各 goroutine 的执行/等待时间
# - Scheduler latency profile: 调度延迟分布
# - Processor view: 各 P 上的 goroutine 执行时间线
# - Thread view: 各 M 的活动
8. 常见陷阱(Pitfalls)
陷阱 1:GOMAXPROCS=1 不能防止 race condition
// 错误认知:"单核就不会有 data race"
runtime.GOMAXPROCS(1)
// 即使 GOMAXPROCS=1,goroutine 仍然可以在任何调度点切换
// 只要有共享变量的非原子读写,就有 data race
// 正确做法:使用 sync.Mutex 或 atomic 操作
GOMAXPROCS=1 意味着只有一个 P,任意时刻只有一个 G 在运行。但 G 可以在函数调用、channel 操作、内存分配等时刻被抢占切换。go test -race 仍然能检测到 data race。
陷阱 2:LockOSThread 不调用 UnlockOSThread 导致 M 泄漏
func leakyHandler() {
runtime.LockOSThread()
// ... 忘记 UnlockOSThread
// 当这个 goroutine 结束时,绑定的 M 会被直接销毁
// 如果在循环中频繁创建这样的 goroutine,会不断创建新 M
}
// 正确做法:
func correctHandler() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// ...
}
注意:LockOSThread / UnlockOSThread 有引用计数语义,必须调用对等次数。
陷阱 3:Go < 1.14 紧密循环饿死其他 goroutine
如第 5 节所述。Go 1.14+ 已通过异步抢占解决,但如果设置 GODEBUG=asyncpreemptoff=1 仍可重现。
陷阱 4:大量 goroutine 阻塞在系统调用 → 线程爆炸
// 每个阻塞的 syscall 都需要一个 OS 线程
// 如果同时有 10000 个 goroutine 都在做文件 I/O:
for i := 0; i < 10000; i++ {
go func() {
f, _ := os.Open("/some/file")
f.Read(buf) // 阻塞 syscall → M 不释放 → 需要新 M
}()
}
// 可能创建 10000 个 OS 线程(受 SetMaxThreads 限制,默认 10000)
// 正确做法:使用 worker pool 限制并发文件 I/O
陷阱 5:用 channel 模式对抗调度器
// 反模式:忙轮询 channel
for {
select {
case msg := <-ch:
process(msg)
default:
// 忙等待 → 浪费 CPU,抢占机制有效但低效
runtime.Gosched() // 即使加了这个,仍然是忙等
}
}
// 正确做法:直接阻塞在 channel 上
for msg := range ch {
process(msg)
}
陷阱 6:Goroutine 泄漏
// 泄漏 1:忘记取消 context
func fetch(url string) {
ctx := context.Background() // 永远不会被取消
go func() {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
http.DefaultClient.Do(req) // 如果服务器不响应,永远阻塞
}()
}
// 正确做法:
func fetch(ctx context.Context, url string) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// ...
}
// 泄漏 2:向无人接收的 channel 发送
func leak() {
ch := make(chan int)
go func() {
ch <- 42 // 永远阻塞,goroutine 泄漏
}()
// 忘记读 ch
}
监控:runtime.NumGoroutine() 持续增长是泄漏的信号。
陷阱 7:假设 goroutine 调度顺序是确定的
for i := 0; i < 10; i++ {
go fmt.Println(i)
}
// 输出顺序不确定!不要依赖 goroutine 的创建顺序 == 执行顺序
// 调度器可能按任意顺序执行
陷阱 8:CGO 调用长时间持有 P
CGO 调用通过 entersyscall 机制处理。如果 C 函数执行时间长:
- P 被 sysmon 回收前的 10ms 内,该 P 上的其他 goroutine 无法被调度
- C 代码中无法被 Go 的信号抢占
- 如果大量 goroutine 同时进入 CGO,可能导致线程爆炸
/*
#include <unistd.h>
void slow_c_func() { sleep(10); }
*/
import "C"
func main() {
for i := 0; i < 100; i++ {
go C.slow_c_func() // 可能创建 100 个 OS 线程
}
}
9. 生产环境最佳实践
9.1 容器中的 GOMAXPROCS
Go 1.25+:运行时自动感知 cgroup CPU 限制,不需要额外处理。GOMAXPROCS 默认 = min(CPU_limit, core_count),并且会动态更新。
Go < 1.25:使用 Uber 的 automaxprocs:
import _ "go.uber.org/automaxprocs"
为什么重要:如果容器 CPU limit = 2 但宿主机有 64 核,GOMAXPROCS=64 会导致:
- 64 个 P 竞争 2 核 CPU 时间
- 频繁被 Linux CFS 调度器 throttle(完全暂停,通常 100ms 周期)
- 尾延迟严重恶化
9.2 GODEBUG=schedtrace 诊断
# 每秒输出一次调度器状态
GODEBUG=schedtrace=1000 ./myapp
# 带详细的 G/M/P 信息
GODEBUG=schedtrace=1000,scheddetail=1 ./myapp
诊断要点:
| 观察指标 | 异常信号 | 可能原因 |
|---|---|---|
runqueue 持续很高 | CPU 密集负载超过 GOMAXPROCS | 增加 GOMAXPROCS 或优化计算 |
threads 远大于 gomaxprocs | 大量 goroutine 阻塞在 syscall | 使用 worker pool 限制并发 syscall |
idleprocs 持续等于 gomaxprocs | I/O 等待为主 | 正常,或检查是否有不必要的阻塞 |
spinningthreads 持续非零 | 工作不均匀,频繁 steal | 检查 goroutine 分布 |
| 某个 P 的本地队列远大于其他 | 负载不均 | 避免从单一 goroutine 扇出到同一 P |
9.3 go tool trace 调度分析
# 方法 1:代码中嵌入(见示例 5)
go run trace_demo.go && go tool trace trace.out
# 方法 2:对运行中的程序(需要 net/http/pprof)
curl -o trace.out 'http://localhost:6060/debug/pprof/trace?seconds=5'
go tool trace trace.out
重点关注:
- Goroutine Analysis:各 goroutine 的执行/阻塞/等待时间分布
- Scheduler Latency Profile:从 Runnable 到 Running 的延迟
- Proc Timeline:各 P 的利用率,是否有 P 长时间空闲
9.4 调度延迟 profiling
Go 1.22+ 提供了更细粒度的 STW 指标:
/sched/pauses/stopping/gc:seconds— GC 停止所有 goroutine 花费的时间/sched/pauses/stopping/other:seconds— 其他 STW 操作停止时间/sched/pauses/total/gc:seconds— GC 总暂停时间/sched/pauses/total/other:seconds— 其他总暂停时间
import "runtime/metrics"
// 读取调度指标
samples := []metrics.Sample{
{Name: "/sched/pauses/stopping/gc:seconds"},
{Name: "/sched/goroutines:goroutines"},
{Name: "/sched/latencies:seconds"},
}
metrics.Read(samples)
9.5 何时使用 runtime.LockOSThread
| 场景 | 说明 |
|---|---|
| GUI 框架 | Cocoa、OpenGL、SDL 要求在主线程调用 |
| C 库的线程本地存储 | C 代码使用 TLS,goroutine 切换线程会破坏状态 |
| Linux namespace 操作 | setns() 等 syscall 是 per-thread 的 |
| 需要固定线程 ID | 某些审计/安全场景需要稳定的线程 ID |
正确模式:在 init() 中调用 LockOSThread(),用 channel 将工作派发到锁定线程执行(见示例 4)。
10. 与其他运行时调度器对比
| 维度 | Go GMP | Erlang/BEAM | Rust tokio | Java Virtual Threads (Loom) | Node.js Event Loop |
|---|---|---|---|---|---|
| 模型 | M:N(G 在 M 上通过 P 调度) | M:N(process 在 scheduler 上) | M:N(task 在 worker thread 上) | M:N(虚拟线程在 carrier thread 上) | 1:1 事件循环 + 线程池 |
| 任务开销 | ~2KB 初始栈,可增长 | ~300 字节/进程 | 几十字节/task(无栈协程) | ~几百字节(起始帧) | N/A(callback/Promise) |
| 抢占 | 协作 + 信号异步抢占(10ms) | 基于 reduction 计数的抢占(~4000 次函数调用) | 无抢占(协作式 .await) | 协作式(阻塞操作时让出 carrier) | 无抢占(单线程事件循环) |
| 公平性 | work-stealing + 全局队列每 61 tick 检查 | 高度公平(per-reduction 调度) | 无内建公平性保证 | JVM 调度(ForkJoinPool) | FIFO 事件队列 |
| I/O 模型 | netpoller(epoll/kqueue)+ 阻塞 syscall handoff | 内建 per-process mailbox | 全异步(epoll/io_uring) | 阻塞时自动 unmount 虚拟线程 | 全异步(libuv) |
| 内存隔离 | 共享内存(需要 mutex/channel) | 完全隔离(message passing only) | 共享内存(所有权系统保证安全) | 共享内存(需要同步) | 共享内存(单线程无竞争,worker_threads 除外) |
| GC | 并发三色标记清除 | per-process GC(无全局停顿) | 无 GC(所有权系统) | 分代 GC(STW 风险) | V8 分代 GC |
| 最大并发量 | 数十万~百万 goroutine | 数百万 process(久经验证) | 数百万 task | 数百万虚拟线程 | 受事件循环吞吐限制 |
| CPU 密集处理 | 好(多核并行 + 抢占) | 好(多调度器 + 抢占) | 差(需手动 spawn_blocking) | 好(多核并行) | 差(需 worker_threads) |
| 典型延迟 | goroutine 切换 ~50-100ns | process 切换 ~微秒级 | task 切换 ~10-50ns | 虚拟线程切换 ~微秒级 [需验证] | 事件循环 tick ~毫秒级 |
关键差异点评:
- Erlang vs Go:Erlang 的 per-process GC 消除了全局 STW,但内存隔离使得共享大数据集的成本更高。Go 共享内存更灵活但需要开发者自己保证安全。
- tokio vs Go:tokio 是无栈协程,内存开销最小但无法抢占——一个阻塞的
.await点之间的长计算会饿死同 worker 上的其他 task。Go 的栈式 goroutine 内存开销稍大但编程模型更简单(无 async/await 传染性)。 - Java Loom vs Go:Loom 的虚拟线程与 Go goroutine 概念相似,但 Loom 是在已有的庞大 JVM 生态上加装的,面临生态兼容挑战(如 synchronized 块仍会 pin carrier thread)。Go 从语言诞生起就围绕 goroutine 设计。
- Node.js vs Go:完全不同的模型。Node.js 单线程事件循环适合 I/O 密集但 CPU 密集任务会阻塞整个循环。Go 的多核并行 + 抢占在混合负载下优势明显。
11. GMP 与 GC 的交互
STW 需要停止所有 P
GC 的两个 STW 阶段(Sweep Termination 和 Mark Termination)需要停止所有 P:
// runtime/proc.go
func stopTheWorld(reason stwReason) worldStop {
semacquire(&worldsema)
// ... 停止所有 P
}
STW 的代价主要在于等待所有 P 到达安全点。如果某个 G 在紧密循环中(Go 1.14+ 通过异步抢占解决),或在系统调用中,等待时间会更长。
Go 1.22 新增了更细粒度的 STW 指标:
/sched/pauses/stopping/gc:seconds:从发起 STW 到所有 P 停止的等待时间/sched/pauses/total/gc:seconds:GC STW 总时间
GC Mark Workers 作为 goroutine 调度
GC 的并发标记阶段,mark workers 就是普通的 goroutine,由调度器调度:
// runtime/mgc.go - gcBgMarkStartWorkers()
// 为每个 P 创建一个 background mark worker goroutine
for gcBgMarkWorkerCount < gomaxprocs {
go gcBgMarkWorker(ready)
<-ready
}
三种 mark worker 模式:
| 模式 | 说明 |
|---|---|
gcMarkWorkerDedicatedMode | 专用模式:P 完全用于 GC 标记,不可被抢占 |
gcMarkWorkerFractionalMode | 部分模式:P 运行部分时间的 GC 标记,可被抢占 |
gcMarkWorkerIdleMode | 空闲模式:P 空闲时运行 GC 标记 |
GC 控制器目标是使用 25% 的 GOMAXPROCS 资源进行并发标记。例如 GOMAXPROCS=4 时,1 个 P 运行 dedicated worker,剩余 P 在空闲时运行 idle worker。
findRunnable() 中的 GC 优先级:调度器在搜索普通 goroutine 之前会检查是否需要运行 GC mark worker:
// runtime/proc.go - findRunnable()
if gcBlackenEnabled != 0 {
gp, tnow = gcController.findRunnableGCWorker(pp, now)
if gp != nil {
return gp, false, true
}
}
GC Assist(分配辅助)
当 GC 并发标记阶段进行时,分配内存的 goroutine 必须按比例辅助标记工作(back-pressure):
- 分配越快的 goroutine 需要做越多的 GC 标记工作
- 如果辅助工作不足,goroutine 会被阻塞直到完成足够的标记
- 这确保了即使在高分配率下,GC 标记也能跟上
// 简化的 GC assist 流程
func mallocgc(size uintptr, ...) unsafe.Pointer {
// 如果在 GC mark 阶段且当前 G 有 assist 债务
if assistG.gcAssistBytes < 0 {
gcAssistAlloc(assistG) // 做一些标记工作来"偿还"
}
// ... 实际分配
}
调度器版本演进总结
| Go 版本 | 调度器变化 |
|---|---|
| 1.0 (2012) | GM 模型,全局队列 + 全局锁 |
| 1.1 (2013) | 引入 P,GMP 模型,work-stealing(Dmitry Vyukov) |
| 1.2 (2013) | 协作式抢占(函数入口 morestack 检查) |
| 1.5 (2015) | 并发 GC(mark workers 作为 goroutine 调度) |
| 1.14 (2020) | 异步抢占(SIGURG 信号),解决紧密循环饥饿 |
| 1.22 (2024) | 执行追踪器重写,新 STW 指标 |
| 1.24 (2025) | 运行时内部锁优化(spinbit mutex),2-3% CPU 开销降低 |
| 1.25 (2025) | GOMAXPROCS 自动感知 cgroup CPU 限制,动态更新 GOMAXPROCS |
| 1.25 (2025) | 实验性 Green Tea GC(GOEXPERIMENT=greenteagc),预期降低 10-40% GC 开销 |
参考来源
- Scalable Go Scheduler Design Doc — Dmitry Vyukov (2012) — 引入 P 的原始设计文档
runtime/proc.go源码 — 调度器核心实现runtime/runtime2.go源码 — G/M/P/schedt 数据结构定义runtime/mgc.go源码 — GC 与调度器交互- Non-cooperative goroutine preemption proposal — Go 1.14 异步抢占设计文档
- Container-aware GOMAXPROCS — Go 官方博客 — Go 1.25 容器感知 GOMAXPROCS
- Go 1.22 Release Notes — 执行追踪器重写、STW 指标
- Go 1.24 Release Notes — 运行时性能优化
- Go 1.25 Release Notes — GOMAXPROCS cgroup 感知、Green Tea GC
- Understanding the Go Runtime: The Scheduler — 深度技术分析
- Go Scheduler (Melatoni, 2025) — 基于 Go 1.24 的详细源码分析
- Preemption in Go — hidetatz — 抢占机制演进详解
- Go Wiki: LockOSThread — LockOSThread 官方指南
- Scheduler Tracing in Go — Ardan Labs — GODEBUG=schedtrace 使用指南
- uber-go/automaxprocs — 容器 GOMAXPROCS 自动配置(Go < 1.25)