golang GMP模型
本篇写GMP
一直觉得GMP的内容比较散乱,这篇也是在草稿放了很久
还是用问答形式来写了
1、什么是GMP?
GMP 模型是 Go 语言运行时 (runtime) 为了实现高效并发而设计的核心调度方案。它是一种先进的 M:N 调度器,旨在以极低的开销管理成千上万的 Goroutine (G),并将它们映射到少量的操作系统线程 (M) 上执行。
核心组件 G M P
GMP 模型由三个核心结构体组成,它们定义在 runtime2.go 中:
G (Goroutine)
代表一个 goroutine,Go 语言中并发执行的基本单元,由 go 关键字创建,包含栈、状态和上下文信息。
-
代码包含
stack: 描述实际的栈内存范围[lo, hi)。atomicstatus: G 的状态(如_Grunnable,_Grunning,_Gwaiting,_Gsyscall)。m: 当前绑定执行该 G 的 M。sched:gobuf结构,保存了 SP (Stack Pointer) 和 PC (Program Counter),用于上下文切换。
- 特点:
- 轻量级:每个 Goroutine 的初始栈空间非常小(通常为 2KB),并且可以根据需要动态伸缩。因此,创建和销毁的成本远低于操作系统线程。
- 用户态管理:Goroutine 的创建、销毁和切换调度完全由 Go 运行时在用户态完成,不直接依赖操作系统内核。
- 保存上下文:每个 G 都保存了自己的执行栈、程序计数器和状态信息。
M (Machine/OS Thread)
指代操作系统的线程 (Kernel Thread)。是真正执行计算的实体。
-
代码包含
g0: 一个特殊的 G,拥有较大的系统栈。调度逻辑(如schedule,findRunnable)都在g0上执行。curg: 当前 M 上正在运行的用户 G。p: 当前 M 绑定的 P。nextp: 暂存的 P,当 M 从系统调用返回或被唤醒时可能会绑定。
-
特点:
- 执行者:M 是真正干活的。它从 P 的本地运行队列中获取 G,并执行 G 中的代码。
- 数量有限:M 的数量受到操作系统和 Go 运行时的限制。Go 运行时会控制 M 的数量,通常有一个活跃上限,但为了处理阻塞的系统调用,可能会临时创建更多的 M。
- 重量级:M 的创建和上下文切换都涉及到内核,开销较大。
P (Processor)
代表逻辑处理器,维护运行 G 所需的资源(如内存缓存、运行队列), Processor 代表一个虚拟的处理器或执行上下文。它是 G 和 M 之间的“中间人”,负责调度 G 到 M 上执行。
-
代码包含
runq: 本地运行队列(Local Run Queue),是一个无锁的环形数组([256]guintptr)。runnext: 一个特殊的槽位,用于存放下一个优先执行的 G(实现 LIFO,提高局部性)。m: 反向指向当前拥有该 P 的 M。mcache: 内存分配缓存(TCMalloc 机制的核心)。
-
特点:
- 调度核心:P 最重要的部分是它拥有一个本地可运行 Goroutine 队列 (Local Run Queue, LRQ)。这使得 G 的调度可以大部分在 P 的本地完成,避免了全局锁的竞争。
- 数量可控:P 的数量由环境变量
GOMAXPROCS或运行时函数runtime.GOMAXPROCS()设置,默认情况下等于 CPU 的核心数。这个值决定了 Go 程序可以真正并行执行的最大 Goroutine 数量。 - 解耦 G 和 M:P 的存在,使得 M 和 G 的关系不再是固定的。一个 M 可以在不同的 P 上执行任务,一个 G 也可以在不同的 P 和 M 上被调度。
Sched (Global Scheduler)
全局调度器结构 schedt。
runq: 全局运行队列(Global Run Queue),存放没有 P 承载的 G。midle/pidle: 空闲的 M 和 P 链表。lock: 全局锁,保护全局队列等资源。
2、GMP 调度流程何如?
A. 调度流程
- 一个 M 必须先与一个 P 绑定才能执行任务。
- M 从其绑定的 P 的本地运行队列 (LRQ) 中弹出一个可运行的 G。
- M 执行这个 G 的代码。
- 当 G 执行完毕、因 channel 阻塞或主动让出 CPU 时,M 会继续从 P 的 LRQ 中获取下一个 G。
结合代码的调度流程 (Source: proc.go)
调度器的核心是一个无限循环,由 schedule() 函数驱动。
**`schedule()
这是调度循环的主逻辑,运行在 g0 栈上:
- 检查异常:检查是否持有锁、是否在 cgo 中等。
findRunnable(): 阻塞式地寻找下一个可运行的 G。execute(gp, inheritTime):- 将 G 的状态改为
_Grunning。 - 将 M 的
curg指向该 G。 - 调用
gogo(&gp.sched)(汇编),从g0栈切换到用户 G 栈,并跳转到 G 的 PC 开始执行。
- 将 G 的状态改为
**`findRunnable()
这是调度器最复杂的逻辑,决定了 G 的获取优先级(为了公平和效率):
runnext(LIFO):- 最优先检查 P 的
runnext字段。这是为了优化刚刚被唤醒的 G(如通信后的 G),让它尽快运行。
- 最优先检查 P 的
- Local Run Queue (FIFO):
- 从 P 的本地队列
runq获取 G。
- 从 P 的本地队列
- Global Run Queue (Fairness):
- 为了防止全局队列饥饿,每 61 次调度(
schedtick % 61 == 0),会优先从全局队列sched.runq获取 G。
- 为了防止全局队列饥饿,每 61 次调度(
- Netpoll (Non-blocking):
- 检查网络轮询器(Netpoller),看是否有 G 从网络 I/O 中就绪。
- Work Stealing (Steal):
- 如果本地没任务,会尝试从其他 P 的本地队列窃取一半(
runqsteal)的任务。 - 这是 Go 高效利用多核的关键。
- 如果本地没任务,会尝试从其他 P 的本地队列窃取一半(
- Global Run Queue (Blocking):
- 如果还没找到,再次检查全局队列(这次会加锁并可能阻塞)。
- Netpoll (Blocking):
- 如果以上都失败,线程可能会进入休眠,等待 Netpoll 唤醒或被其他线程唤醒。
execute() 之后的流程
当 G 执行结束(goexit)或主动让出(Gosched)或阻塞(gopark):
goexit0: 清理 G 的状态,将其放回 P 的空闲列表gFree,然后再次调用schedule(),开始新一轮循环。
G 的抢占调度 (Preemption)
Go 使用协作式抢占(基于函数调用)和异步抢占(基于信号,Go 1.14+)相结合的方式。
- 触发者: 后台监控线程
sysmon会定期运行retake函数。 - 检测逻辑:
retake遍历所有 P。如果一个 P 处于_Prunning状态且当前 G 运行时间超过 10ms (forcePreemptNS),则触发抢占。 - 抢占实现 (
preemptone):- 设置标志: 将 G 的
preempt字段设为true。 - 修改栈守卫: 将
g.stackguard0设置为stackPreempt(一个非常大的值)。 - 协作式: 当 G 进行下一次函数调用时,编译器插入的栈检查指令会发现
SP > stackguard0,从而跳转到morestack,进而调用newstack。newstack检查到是抢占请求,会将 G 挂起并放回运行队列。 - 异步抢占 (Async Preemption): 如果支持(如 Linux),
preemptone还会向 M 发送SIGURG信号。信号处理函数会中断当前执行流,保存上下文,并强制调度。这解决了没有函数调用的死循环无法被抢占的问题。
- 设置标志: 将 G 的
B. Goroutine 创建 (go func())
当用户代码使用 go 关键字创建一个新的 G 时,这个 G 会优先被放入当前 Goroutine 所在的 P 的 LRQ。如果 LRQ 已满,则会将该 P 的一半 G 和新创建的 G 一起放入全局运行队列 (GRQ)。
G 的创建通常由 go 关键字触发,编译器将其转换为对 runtime.newproc 的调用。
- 入口函数:
newproc(位于 proc.go) - 核心逻辑:
- 获取上下文: 获取当前 G 和调用方的 PC。
- 切换到系统栈: 使用
systemstack切换到 g0 栈执行newproc1,这是为了防止栈溢出和保证调度安全。 - 调用
newproc1: 这是真正创建 G 的函数。- 复用或新建: 首先尝试从当前 P 的本地空闲列表 (
pp.gFree) 获取 G (gfget)。如果获取不到,则调用malg分配一个新的 G 结构体和栈空间。 - 初始化: 设置 G 的状态为
_Gdead,初始化栈指针 (SP)、指令指针 (PC)。PC 被设置为goexit的地址(以便 G 执行完后能正常退出),然后通过gostartcallfn伪造栈帧,让 G 看起来像是刚调用了目标函数。 - 设置状态: 将 G 的状态通过 CAS 操作设置为
_Grunnable。 - 分配 ID: 为 G 分配唯一的
goid。
- 复用或新建: 首先尝试从当前 P 的本地空闲列表 (
- 放入队列: 调用
runqput将新创建的 G 放入当前 P 的本地运行队列。如果本地队列满了,会放入全局运行队列。 - 唤醒 P: 如果有空闲的 P,调用
wakep尝试唤醒一个 P 来分担工作。
C. 工作窃取 (Work Stealing)
-
场景:当一个 P 的 LRQ 为空时,其绑定的 M 并没有任务可做。
-
机制:M 不会立即休眠。它会先尝试从 GRQ 中获取 G。如果 GRQ 也为空,它会变成一个“窃取者”,随机选择另一个 P,并尝试从那个 P 的 LRQ “窃取”一半的 G 到自己的 LRQ 中来执行。
-
目的:这是 GMP 实现自动负载均衡的关键。它能确保任务被均匀地分配到所有 P 上,避免了某些核心空闲而另一些核心过载的情况。
D. 系统调用处理 (Syscall Handling)
这是 GMP 模型的一个亮点,它解决了 M:N 调度模型中一个经典的难题。
-
场景:一个在 M 上运行的 G 执行了一个阻塞型系统调用(例如,同步的文件读写)。
-
机制:
-
M 即将进入内核态并被阻塞。
-
在阻塞前,Go 运行时会将这个 M 与其绑定的 P 分离。
-
运行时会寻找一个空闲的 M(或创建一个新的 M)来接管这个 P。
-
这个新的 M 会继续执行 P 的 LRQ 中的其他 G。
-
-
结果:即使一个 M 因为系统调用而阻塞,它所关联的 P 上的计算资源也不会被浪费,程序的并行度得以保持。
-
返回后:当系统调用完成后,原来的 M 会被唤醒。它会尝试获取一个空闲的 P 来继续执行它的 G,如果获取不到,这个 G 会被放入 GRQ,M 可能会进入休眠。
-
Netpoller: 对于网络 I/O,Go 运行时通过集成的网络轮询器 (netpoller) 将大部分网络操作转换为非阻塞的,从而从根本上避免了 M 因网络 I/O 而陷入上述的阻塞流程。
Go 对系统调用进行了封装,以优化调度。主要涉及 entersyscall 和 exitsyscall。
-
进入系统调用 (
entersyscall):- 保存现场: 保存当前的 SP 和 PC 到
g.sched。 - 修改状态: 将 G 的状态从
_Grunning修改为_Gsyscall。 - 解绑 P: 将 M 与 P “解绑”。此时 P 进入
_Psyscall状态。P 并没有完全释放,而是处于一种“半释放”状态。如果系统调用很快返回,M 可以快速重新获取这个 P。 - Sysmon 监控: 后台监控线程
sysmon会扫描处于_Psyscall的 P。如果发现阻塞时间过长(默认 10ms),sysmon会调用retake将 P 剥离(抢占),交给其他 M 使用。
- 保存现场: 保存当前的 SP 和 PC 到
-
退出系统调用 (
exitsyscall):- 快速路径 (
exitsyscallfast): M 尝试重新获取之前的 P (oldp)。如果成功,G 状态切回_Grunning,继续执行。 - 尝试获取空闲 P: 如果旧 P 被抢走了,尝试从全局空闲 P 列表 (
sched.pidle) 获取一个 P。 - 慢速路径: 如果获取不到 P,M 将 G 状态设为
_Grunnable,将 G 放入全局运行队列 (sched.runq)。 - M 休眠: M 调用
stopm将自己放入空闲 M 列表并休眠,等待被唤醒。
- 快速路径 (
E. M 的休眠与唤醒 (M Parking/Unparking)
如果一个 M 在尝试了所有方法(检查 LRQ、GRQ、工作窃取)后,仍然找不到可执行的 G,它会放弃其 P(将 P 放入空闲 P 列表),然后 M 自身进入休眠状态。当有新的工作需要处理时(例如,一个新 G 被创建或一个网络连接就绪),调度器会唤醒一个休眠的 M(或创建新的 M)。
M (Machine) 对应操作系统线程。Go 运行时尽量复用 M,避免频繁创建销毁线程。
-
M 的休眠 (
stopm):- 当 M 没有工作可做(没有 P,且无法从全局队列或网络轮询器获取 G)时,会调用
stopm。 - 放入空闲列表: 将 M 放入全局空闲 M 列表 (
sched.midle)。 - 底层休眠: 调用
mPark,底层使用操作系统原语(如 Linux 的 futex 或 semaphore)挂起线程,不再占用 CPU。
- 当 M 没有工作可做(没有 P,且无法从全局队列或网络轮询器获取 G)时,会调用
-
M 的唤醒 (
startm):- 当有新的 G 创建或者 P 处于空闲状态需要 M 来运行时,会调用
startm。 - 获取空闲 P: 如果没有指定 P,尝试从空闲 P 列表获取。
- 获取空闲 M: 尝试从
sched.midle获取一个休眠的 M。 - 唤醒: 如果获取到 M,通过
notewakeup(底层信号机制) 唤醒该线程。该 M 醒来后会绑定 P 并开始调度循环。 - 新建 M: 如果没有空闲 M,则调用
newm创建一个新的系统线程。
- 当有新的 G 创建或者 P 处于空闲状态需要 M 来运行时,会调用
GMP 协作总结
- M 必须拥有 P 才能执行 G。
- M 在 g0 栈上运行
schedule()寻找 G。 - M 找到 G 后,通过
execute()切换到 G 的栈执行用户代码。 - G 执行完毕或阻塞,M 切换回 g0,重新运行
schedule()。 - 如果 M 阻塞(如系统调用),P 会被剥离(Handoff),寻找新的 M 接管,保证 P 上的其他 G 能继续运行。
3、GO程序初始化流程 (Source: proc.go)
Go 程序的启动入口在汇编层面,最终会调用 runtime.schedinit。
schedinit() (lines 800+)
这是调度器的初始化函数:
sched.maxmcount = 10000: 设置最大 M 数量(防止线程耗尽)。mcommoninit(gp.m, -1): 初始化当前的 M,这通常是 m0(主线程)。procresize(procs):- 根据
GOMAXPROCS创建或调整 P 的列表 (allp)。 - 将
allp[0]绑定到m0上,使主线程拥有执行能力。 - 多余的 P 会被放入空闲列表
pidle。
- 根据
关键点:
- m0: 进程启动时的第一个线程,负责初始化和启动第一个 G。
- g0: 每个 M 都有一个
g0,仅用于执行调度代码,不执行用户逻辑。
4、Work Stealing 的偷取策略
1. 偷取数量:一半 (Half)
从 runqgrab 函数中可以看到:
n := t - h // 计算队列中的 goroutine 数量
n = n - n/2 // 只偷取一半
偷取的是队列中 goroutine 总数的一半,而不是固定数量。
2. 偷取位置:前半部分 (Head)
从代码可以看出偷取的是队列的前半部分(从 head 开始):
for i := uint32(0); i < n; i++ {
g := pp.runq[(h+i)%uint32(len(pp.runq))] // 从 head(h) 开始偷取
batch[(batchHead+i)%uint32(len(batch))] = g
}
if atomic.CasRel(&pp.runqhead, h, h+n) { // 更新 head 指针
return n
}
3. Work Stealing 的完整流程
-
计算偷取数量:
n = (tail - head) - (tail - head)/2,即偷取队列中一半的 goroutine -
偷取位置:从被偷取 P 的
runqhead开始,连续偷取n个 goroutine -
原子操作:使用 CAS 操作更新被偷取 P 的
runqhead指针 -
备选策略:如果 runq 为空,还会尝试偷取
runnext中的 goroutine
4. 为什么偷取前半部分?
偷取前半部分(head 端)而不是后半部分的原因:
- FIFO 特性:Go 的本地队列基本按照 FIFO 顺序执行,head 端的 goroutine 通常等待时间更长
- 缓存局部性:较老的 goroutine 可能已经不在 CPU 缓存中,偷取到其他 P 上执行影响较小
- 减少竞争:生产者(当前 P)主要在 tail 端操作,偷取者从 head 端操作,减少竞争
5. 偷取示例
假设一个 P 的本地队列有 8 个 goroutine:
[G1, G2, G3, G4, G5, G6, G7, G8]
↑head ↑tail
Work Stealing 会偷取前 4 个:
- 偷取的:
[G1, G2, G3, G4] - 剩余的:
[G5, G6, G7, G8]
这种设计既保证了负载均衡,又最大化了系统的并发性能。Work Stealing 会偷取前 4 个:
- 偷取的:
[G1, G2, G3, G4] - 剩余的:
[G5, G6, G7, G8]
5、M (Machine) 和 P (Processor) 数量的确定
- P 数量:固定为 GOMAXPROCS,只在显式调用
runtime.GOMAXPROCS()时改变, 需要注意的是1.25之前容器中的golang无法感知Cgroup分配的核数,会使用宿主机的执行核心数量 - M 数量:动态变化,按需创建,主要在以下情况新增:
- 有可运行的 goroutine 但没有可用的 M
- M 因系统调用或其他原因被阻塞
- 需要更多并行度来处理工作负载
P 的数量确定
P 的数量 = GOMAXPROCS
先环境变量后读执行核心
-
初始化时确定:
- 在
schedinit()函数中,通过以下优先级确定:if n, ok := strconv.Atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 { procs = n // 环境变量 GOMAXPROCS } else { procs = defaultGOMAXPROCS(numCPUStartup) // 默认为 CPU 核心数 }
- 在
-
运行时调整:
- 通过
runtime.GOMAXPROCS(n)函数动态调整 - 会调用
procresize(nprocs)重新调整 P 的数量
- 通过
M 的数量确定
M 的数量是动态的,没有固定上限(除了 sched.maxmcount = 10000 的安全上限)
- 初始时:只有一个 M0(主线程)
- 按需创建:根据工作负载动态创建新的 M
新增 M 的触发条件
-
没有空闲的 M 可用时:
// 在 startm() 函数中 nmp := mget() // 尝试获取空闲的 M if nmp == nil { // 没有可用的 M,需要创建新的 newm(fn, pp, id) } -
主要触发场景:
a) 有新的 Goroutine 需要执行但没有足够的 M:
wakep()被调用时(新建 goroutine 或 goroutine 变为可运行状态)- 如果没有 spinning 的 M,会创建新的 M
b) 系统调用阻塞:
- 当 M 执行系统调用时,会释放 P
- 如果有其他可运行的 goroutine 但没有可用的 M,会创建新的 M
c) 网络 I/O 或其他阻塞操作:
- M 被阻塞时,调度器需要新的 M 来执行其他 goroutine
新增 M 的具体流程
// 在 startm() 中的关键逻辑
func startm(pp *p, spinning, lockheld bool) {
nmp := mget() // 尝试从空闲池获取 M
if nmp == nil {
// 没有空闲 M,创建新的
id := mReserveID()
newm(fn, pp, id) // 创建新 M
return
}
// 唤醒已有的空闲 M
notewakeup(&nmp.park)
}
何时会新增 P
P 的数量变化只发生在 GOMAXPROCS 改变时:
- 启动时:
schedinit()中调用procresize(procs) - 运行时:
runtime.GOMAXPROCS(n)调用时
procresize() 函数的关键逻辑:
func procresize(nprocs int32) *p {
old := gomaxprocs
// 如果需要更多 P
if nprocs > int32(len(allp)) {
// 扩展 allp 切片
nallp := make([]*p, nprocs)
copy(nallp, allp[:cap(allp)])
allp = nallp
}
// 初始化新的 P
for i := old; i < nprocs; i++ {
pp := allp[i]
if pp == nil {
pp = new(p) // 创建新的 P
}
pp.init(i)
allp[i] = pp
}
// 如果减少 P,销毁多余的 P
for i := nprocs; i < old; i++ {
pp := allp[i]
pp.destroy()
}
}
6、g0 和 m0 是什么
TLDR
- g0:每个 M 的系统栈 goroutine,用于执行 runtime 底层操作
- m0:程序的主线程 M,是程序启动的入口点
g0(调度栈 Goroutine)
g0 是每个 M 的调度栈 goroutine
-
定义:
type m struct { g0 *g // goroutine with scheduling stack // ... 其他字段 } -
作用:
- 系统栈(System Stack):g0 提供了一个固定大小的栈,用于执行 runtime 代码
- 调度栈(Scheduling Stack):所有调度相关的操作都在 g0 栈上执行
- Runtime 操作栈:垃圾回收、内存分配、系统调用等底层操作都在 g0 栈上进行
-
特征:
- 固定大小:通常为 8KB(在 64 位系统上)
- 不会增长:与普通 goroutine 的栈不同,g0 栈大小固定,不会动态增长
- 系统级:用于执行 runtime 系统级代码,不运行用户代码
-
使用场景:
- 调度器切换 goroutine 时
- 执行
systemstack()调用时 - 垃圾回收操作时
- 内存分配和释放时
- 系统调用的准备和清理工作
g0 与普通 goroutine 的区别
| 特性 | g0 | 普通 goroutine |
|---|---|---|
| 栈大小 | 固定(通常 8KB) | 动态增长(初始 2KB) |
| 用途 | 执行 runtime 代码 | 执行用户代码 |
| 调度 | 不被调度 | 被调度器管理 |
| 栈增长 | 不会增长 | 会动态增长 |
| 数量 | 每个 M 一个 | 可以有数百万个 |
工作机制
-
调度流程:
- 用户 goroutine 执行用户代码
- 需要调度时切换到 g0 栈
- 在 g0 栈上执行调度逻辑
- 选择新的 goroutine 后切换回用户栈
-
内存管理:
- 垃圾回收标记和清扫在 g0 栈上进行
- 内存分配的底层操作在 g0 栈上执行
m0(主线程 M)
m0 是程序的主线程对应的 M,它是特殊的:
-
定义:
var ( m0 m // 主线程 M g0 g // 主线程的 g0 // ... ) -
特殊性:
- 主线程:对应操作系统的主线程
- 启动入口:Go 程序从 m0 开始执行
- 固定存在:m0 在程序整个生命周期中都存在
- 特殊处理:在某些操作中需要特殊处理,如信号处理
-
初始化过程:
func main() { mp := getg().m if mp != &m0 { throw("runtime.main not on m0") // 确保在 m0 上运行 } // ... 初始化代码 } -
职责:
- 执行
runtime.main函数 - 启动系统监控goroutine(sysmon)
- 处理程序的初始化工作
- 执行