1. GMP是什么?
GMP是Go语言运行时(runtime)的核心调度模型,由三个关键组件组成:
- G (Goroutine):Go协程,是Go语言并发执行的基本单位。每个Goroutine包含执行栈、程序计数器(PC)、寄存器等执行上下文信息。
- M (Machine/Thread):操作系统线程,是真正执行代码的实体。M与操作系统的线程一一对应,负责执行Goroutine的代码。
- P (Processor):逻辑处理器,是Goroutine调度的重要组件。P维护一个本地Goroutine队列,并管理M与G之间的调度关系。
三者之间的关系
P (Processor) ←→ M (Machine/Thread) ←→ G (Goroutine)
- P与M的关系:一个P可以绑定一个M,也可以不绑定(空闲状态)。P的数量通常等于CPU核心数(可通过
GOMAXPROCS设置)。 - M与G的关系:M负责执行G的代码。一个M在同一时刻只能执行一个G。
- P与G的关系:P维护一个本地队列(local queue),存储等待执行的G。P还持有指向当前正在执行的G的指针。
GMP模型的作用
GMP模型实现了高效的协程调度,使得Go程序可以:
- 创建数百万个Goroutine而不会导致系统资源耗尽
- 在少量操作系统线程上高效执行大量Goroutine
- 实现自动的负载均衡和任务窃取
2. GMP解决什么问题?
2.1 传统线程模型的局限性
在传统的线程模型中,每个线程对应一个操作系统线程,存在以下问题:
- 线程创建开销大:创建线程需要系统调用,涉及内核态和用户态切换,开销较大。
- 线程上下文切换成本高:线程切换需要保存和恢复完整的CPU上下文(寄存器、栈等),成本较高。
- 内存占用大:每个线程需要独立的栈空间(通常1-2MB),大量线程会消耗大量内存。
- 线程数量限制:受操作系统限制,无法创建大量线程(通常几千到几万个)。
2.2 Go语言并发编程的需求
Go语言的设计目标是简化并发编程,让开发者能够:
- 轻松创建大量轻量级并发任务
- 高效利用多核CPU
- 避免传统线程模型的各种问题
2.3 需要解决的核心问题
- 如何高效创建和管理大量协程:需要轻量级的协程实现,避免线程创建的开销。
- 如何实现协程调度:需要在少量线程上调度大量协程,实现协程的并发执行。
- 如何实现负载均衡:需要将任务均匀分配到各个CPU核心,避免某些核心空闲而其他核心过载。
- 如何避免协程阻塞整个线程:需要实现抢占式调度,避免一个协程长时间占用线程。
3. 怎么解决的?
3.1 GMP模型的设计思路
GMP模型通过以下设计解决了上述问题:
- 协程与线程分离:Goroutine是用户态的协程,不直接对应操作系统线程,创建和切换成本低。
- 两级队列设计:每个P维护一个本地队列(local queue),还有一个全局队列(global queue),实现高效的任务分配。
- 工作窃取机制:当某个P的本地队列为空时,可以从其他P的本地队列"窃取"Goroutine,实现负载均衡。
- 抢占式调度:通过系统监控(sysmon)实现抢占式调度,避免Goroutine长时间占用线程。
3.2 工作窃取(Work Stealing)机制
工作窃取是GMP调度器的核心机制之一:
- 本地队列优先:M优先从绑定的P的本地队列获取G执行。
- 全局队列补充:当本地队列为空时,从全局队列获取G。
- 窃取其他P的任务:当全局队列也为空时,随机选择一个P,从其本地队列尾部窃取一半的G。
这种机制确保了:
- 大部分情况下,G在创建它的P上执行(局部性原理)
- 当某个P空闲时,可以分担其他P的工作(负载均衡)
3.3 抢占式调度
Go运行时通过以下方式实现抢占式调度:
- 协作式抢占:在函数调用、通道操作等安全点检查是否需要抢占。
- 基于信号的抢占:Go 1.14+引入了基于信号的抢占,可以在任意位置抢占Goroutine。
- 系统监控:sysmon协程定期检查长时间运行的Goroutine,并触发抢占。
3.4 本地队列和全局队列的设计
- 本地队列(Local Queue):每个P维护一个固定大小的本地队列(通常256个G),使用无锁环形队列实现,访问速度快。
- 全局队列(Global Queue):所有P共享一个全局队列,当本地队列满时,新创建的G会被放入全局队列。
4. 核心流程
4.0 GMP调度器全局视图
在深入了解各个组件和流程之前,我们先从全局视角看一下GMP调度器的完整工作流程:
@startuml
skinparam backgroundColor transparent
skinparam sequenceArrowThickness 2
skinparam roundcorner 20
skinparam maxmessagesize 250
actor "用户程序" as User
box "Go运行时调度器" #LightBlue
participant "全局调度器\n(sched)" as Sched
participant "P0\n(Processor 0)" as P0
participant "M0\n(Thread 0)" as M0
participant "P1\n(Processor 1)" as P1
participant "M1\n(Thread 1)" as M1
end box
participant "Goroutine池" as GPool
== 初始化阶段 ==
User -> Sched: 程序启动
activate Sched
Sched -> P0: 创建P (数量=CPU核心数)
activate P0
Sched -> P1: 创建P
activate P1
Sched -> M0: 创建初始M
activate M0
M0 -> P0: 绑定P0
Sched -> M1: 创建初始M
activate M1
M1 -> P1: 绑定P1
deactivate Sched
== Goroutine创建阶段 ==
User -> User: 执行 go func()
User -> GPool: 创建新Goroutine (G1, G2, G3...)
activate GPool
GPool -> GPool: 初始化G1 (_Grunnable)
GPool -> P0: G1加入P0本地队列
GPool -> GPool: 初始化G2 (_Grunnable)
GPool -> P0: G2加入P0本地队列
GPool -> GPool: 初始化G3 (_Grunnable)
GPool -> P1: G3加入P1本地队列
deactivate GPool
note over P0,P1
各个P的本地队列中
已有待执行的Goroutine
end note
== 调度执行阶段 (P0视角) ==
M0 -> M0: 调用 schedule()
M0 -> P0: 从本地队列获取G1
P0 --> M0: 返回G1
M0 -> M0: execute(G1)
activate M0 #DarkGreen
M0 -> M0: G1状态: _Grunning
M0 -> M0: 执行G1的代码...
alt G1执行完成
M0 -> M0: G1状态: _Gdead
M0 -> GPool: 将G1放回对象池
M0 -> M0: 调用 schedule()
deactivate M0
M0 -> P0: 从本地队列获取G2
P0 --> M0: 返回G2
else G1阻塞 (channel/锁)
M0 -> M0: G1状态: _Gwaiting
deactivate M0
M0 -> M0: 保存G1上下文
note right of M0
G1被挂起,等待条件满足
会在ready()时重新入队
end note
M0 -> M0: 调用 schedule()
M0 -> P0: 从本地队列获取G2
else G1系统调用
M0 -> M0: G1状态: _Gsyscall
M0 -> P0: M0与P0解绑
note right of M0
M0进入系统调用
P0可以绑定新的M继续工作
end note
deactivate M0
end
== 调度执行阶段 (P1视角) ==
M1 -> M1: 调用 schedule()
M1 -> P1: 从本地队列获取G3
P1 --> M1: 返回G3
M1 -> M1: execute(G3)
M1 -> M1: 执行G3的代码...
== 工作窃取阶段 ==
M0 -> P0: P0本地队列为空
M0 -> Sched: 检查全局队列
Sched --> M0: 全局队列也为空
M0 -> P1: 尝试从P1窃取Goroutine
alt P1队列有G
P1 --> M0: 窃取一半的G
M0 -> P0: 将窃取的G放入P0队列
note right of M0
负载均衡:
将P1的部分工作
转移到P0
end note
else P1队列也为空
M0 -> M0: 所有队列都为空
M0 -> Sched: M0进入休眠
deactivate M0
note right of M0
M0休眠,等待新的G创建
或其他G变为可运行状态
end note
end
== 唤醒阶段 ==
User -> User: 创建新的Goroutine
User -> GPool: 创建G4
activate GPool
GPool -> Sched: 检测到有休眠的M
Sched -> M0: 唤醒M0
activate M0
GPool -> P0: G4加入P0队列
deactivate GPool
M0 -> M0: 继续调度循环...
note over User,GPool
**GMP调度器核心特点**
1. M:P = 1:1 绑定(动态)
2. P的数量 = CPU核心数(可配置)
3. M的数量动态调整(最多10000个)
4. G的数量可达百万级
5. 多级队列:本地队列 → 全局队列 → 工作窃取
6. 抢占式调度:避免G长时间占用
end note
@enduml正在加载图表...
![PlantUML Diagram]()
全局流程说明
上图展示了GMP调度器的完整工作周期,包括:
- 初始化阶段:创建P(逻辑处理器)和M(线程),数量基于CPU核心数。
- Goroutine创建:用户代码通过
go关键字创建G,分配到各个P的本地队列。 - 并发调度执行:多个M并发从各自绑定的P获取G并执行。
- 状态转换处理:
- G执行完成 → 回收到对象池
- G阻塞 → 挂起等待
- G系统调用 → M与P解绑
- 工作窃取:当某个P的队列为空时,从其他P窃取任务,实现负载均衡。
- 休眠与唤醒:M在无任务时休眠,有新任务时被唤醒。
这个全局视图展示了GMP调度器如何通过M:P:G = 线程:处理器:协程的三层模型,实现了高效的并发调度。
4.1 底层存储结构
G、M、P 结构及其关系
@startuml
skinparam classAttributeIconSize 0
skinparam class {
BackgroundColor LightYellow
BorderColor Black
ArrowColor Black
}
class G {
+ stack: stack
+ stackguard0: uintptr
+ m: *m
+ sched: gobuf
+ goid: int64
+ status: uint32
--
状态说明:
_Gidle: 刚创建
_Grunnable: 可运行
_Grunning: 正在运行
_Gsyscall: 系统调用中
_Gwaiting: 等待中
_Gdead: 已死亡
}
class M {
+ g0: *g
+ curg: *g
+ p: puintptr
+ nextp: puintptr
+ id: int64
--
g0: 调度用的Goroutine
curg: 当前正在执行的G
p: 当前绑定的P
nextp: 下一个要绑定的P
}
class P {
+ id: int32
+ status: uint32
+ m: muintptr
+ runq: [256]guintptr
+ runqhead: uint32
+ runqtail: uint32
+ runnext: guintptr
--
runq: 本地队列(环形队列)
runnext: 下一个要运行的G
(优先级高)
}
class GlobalScheduler {
+ allp: []*p
+ allm: *m
+ allgs: []*g
+ globalQueue: []*g
--
allp: 所有P的列表
allm: 所有M的列表
allgs: 所有G的列表
}
' 关联关系
G "0..*" -- "0..1" M : 绑定
M "0..1" -- "0..1" P : 绑定
P "1" o-- "0..*" G : 本地队列
GlobalScheduler "1" o-- "0..*" P : 管理
GlobalScheduler "1" o-- "0..*" M : 管理
GlobalScheduler "1" o-- "0..*" G : 管理
note right of G
**Goroutine (G)**
- Go协程,轻量级执行单元
- 包含执行栈和调度信息
- 通过status字段表示状态
end note
note right of M
**Machine (M)**
- 操作系统线程
- 负责执行G的代码
- 通过P获取要执行的G
end note
note right of P
**Processor (P)**
- 逻辑处理器
- 维护本地Goroutine队列
- 数量通常等于CPU核心数
end note
note bottom of GlobalScheduler
**全局调度器**
- 管理所有的G、M、P
- 维护全局队列
- 负责整体调度协调
end note
@enduml正在加载图表...
![PlantUML Diagram]()
4.2 Goroutine是如何添加的?
当使用go关键字创建Goroutine时,各组件之间的交互流程如下:
@startuml
skinparam backgroundColor transparent
skinparam sequenceArrowThickness 2
skinparam roundcorner 20
skinparam maxmessagesize 200
actor "用户代码" as User
participant "编译器" as Compiler
participant "runtime.newproc()" as Runtime
participant "G对象池" as GPool
participant "新Goroutine (G)" as NewG
participant "当前P" as P
participant "全局队列" as GlobalQ
participant "空闲M池" as MPool
User -> Compiler: 执行 go func()
activate Compiler
Compiler -> Runtime: 转换为 newproc() 调用
deactivate Compiler
activate Runtime
Runtime -> GPool: 获取G结构
activate GPool
GPool --> Runtime: 返回G (复用或新建)
deactivate GPool
Runtime -> NewG: 初始化G
activate NewG
Runtime -> NewG: 计算并分配栈空间 (默认2KB)
Runtime -> NewG: 设置函数入口地址和参数
Runtime -> NewG: 初始化sched调度信息
Runtime -> NewG: 设置状态为 _Grunnable
deactivate NewG
Runtime -> P: 检查本地队列是否已满
activate P
alt 本地队列未满
Runtime -> P: 将G添加到本地队列尾部 (runq)
P --> Runtime: 添加成功
else 本地队列已满 (256个)
Runtime -> GlobalQ: 将G添加到全局队列
activate GlobalQ
GlobalQ --> Runtime: 添加成功
deactivate GlobalQ
end
deactivate P
alt 需要唤醒或创建M
Runtime -> MPool: 检查是否有空闲M
activate MPool
alt 有空闲M
MPool -> MPool: 唤醒空闲的M
else 无空闲M且未达上限
MPool -> MPool: 创建新的M
end
deactivate MPool
end
Runtime --> User: newproc() 返回
deactivate Runtime
note right of NewG
G状态: _Grunnable
等待被M调度执行
end note
@enduml正在加载图表...
![PlantUML Diagram]()
详细流程说明
go关键字编译:go func()会被编译器转换为对runtime.newproc()的调用。- 创建G结构:
newproc()从G对象池(gFree)中获取或创建新的G结构。 - 初始化G:
- 分配栈空间(通常2KB,可动态增长)
- 设置函数入口地址和参数
- 初始化调度上下文(sched字段)
- 加入队列:
- 优先加入当前P的本地队列(
runq) - 如果本地队列满(256个),则加入全局队列
- 如果本地队列未满但
runnext为空,可能直接设置runnext(优先级更高)
- 唤醒M:如果当前有空闲的M,或者需要创建新的M来执行G,会触发调度。
4.3 Goroutine是怎么被调度的?
调度器各组件之间的交互时序如下:
@startuml
skinparam backgroundColor transparent
skinparam sequenceArrowThickness 2
skinparam roundcorner 20
skinparam maxmessagesize 200
participant "M (线程)" as M
participant "runtime.schedule()" as Scheduler
participant "当前P" as P
participant "P.runnext" as Runnext
participant "P.本地队列" as LocalQ
participant "全局队列" as GlobalQ
participant "其他P" as OtherP
participant "Goroutine (G)" as G
M -> Scheduler: M启动或空闲,调用schedule()
activate Scheduler
Scheduler -> P: 获取当前绑定的P
activate P
' 第一优先级:runnext
Scheduler -> Runnext: 检查runnext是否有G
activate Runnext
alt runnext有G
Runnext --> Scheduler: 返回G
Scheduler -> Runnext: 清空runnext
deactivate Runnext
else runnext为空
deactivate Runnext
' 第二优先级:本地队列
Scheduler -> LocalQ: 检查本地队列
activate LocalQ
alt 本地队列有G
LocalQ --> Scheduler: 从队列头部获取G
deactivate LocalQ
else 本地队列为空
deactivate LocalQ
' 第三优先级:全局队列
Scheduler -> GlobalQ: 检查全局队列
activate GlobalQ
alt 全局队列有G
GlobalQ --> Scheduler: 获取一批G (最多n/2+1个)
Scheduler -> LocalQ: 将其余G放入本地队列
note right
只返回第一个G执行,
其余G放入本地队列
end note
deactivate GlobalQ
else 全局队列为空
deactivate GlobalQ
' 第四优先级:工作窃取
Scheduler -> OtherP: 尝试从其他P窃取G
activate OtherP
alt 窃取成功
OtherP --> Scheduler: 返回窃取的G
note right
从其他P的队列尾部
窃取一半的G
end note
deactivate OtherP
else 所有P都为空
deactivate OtherP
Scheduler -> M: M进入休眠状态
note right of M
M休眠,等待被唤醒
当有新G创建时会唤醒
end note
deactivate P
deactivate Scheduler
end
end
end
end
' 执行G
Scheduler -> M: 返回要执行的G
deactivate P
deactivate Scheduler
M -> G: 调用 runtime.execute(G)
activate G
M -> G: 切换到G的栈
M -> G: 执行G的代码
note right of G
G状态: _Grunning
M与G绑定执行
end note
alt G执行完毕
G -> M: G执行完成
deactivate G
M -> G: 设置G状态为 _Gdead
M -> G: 将G放回对象池
M -> Scheduler: 重新调用schedule()获取下一个G
else G发生阻塞 (channel/锁/syscall)
G -> M: G阻塞
deactivate G
M -> G: 保存G的上下文
M -> G: 设置G状态为 _Gwaiting
M -> P: 将G从P移除
M -> Scheduler: 重新调用schedule()获取下一个G
note right of G
阻塞的G会在条件满足后
被重新放入队列,状态改为_Grunnable
end note
end
@enduml正在加载图表...
![PlantUML Diagram]()
工作窃取流程详解
当P的本地队列和全局队列都为空时,会触发工作窃取机制:
@startuml
skinparam backgroundColor transparent
skinparam sequenceArrowThickness 2
skinparam roundcorner 20
participant "当前M" as M
participant "当前P" as P1
participant "P2" as P2
participant "P2.本地队列" as P2Q
participant "P3" as P3
participant "P3.本地队列" as P3Q
M -> P1: 本地队列和全局队列都为空
activate P1
P1 -> P1: 需要进行工作窃取
deactivate P1
M -> P2: 随机选择P2作为窃取目标
activate P2
M -> P2Q: 检查P2的本地队列
activate P2Q
alt P2队列为空
P2Q --> M: 队列为空
deactivate P2Q
deactivate P2
M -> P3: 尝试下一个P (P3)
activate P3
M -> P3Q: 检查P3的本地队列
activate P3Q
alt P3队列有G
P3Q --> M: 返回队列长度
M -> P3Q: 计算窃取数量 (队列长度/2)
P3Q -> P3Q: 从队列尾部取出一半的G
P3Q --> M: 返回窃取的G列表
deactivate P3Q
deactivate P3
M -> P1: 将窃取的G放入当前P的本地队列
activate P1
P1 --> M: 保存成功
deactivate P1
M -> M: 返回第一个G用于执行
note right of M
窃取成功!
从P3窃取了一半的G
保证负载均衡
end note
else P3队列也为空
P3Q --> M: 队列为空
deactivate P3Q
deactivate P3
M -> M: 继续尝试其他P...
alt 所有P都已遍历完
M -> M: 所有P都为空
M -> M: 返回nil,M准备休眠
note right of M
窃取失败
所有P都没有可执行的G
M将进入休眠状态
end note
end
end
else P2队列有G
P2Q --> M: 返回队列长度
M -> P2Q: 计算窃取数量 (队列长度/2)
P2Q -> P2Q: 从队列尾部取出一半的G
P2Q --> M: 返回窃取的G列表
deactivate P2Q
deactivate P2
M -> P1: 将窃取的G放入当前P的本地队列
activate P1
P1 --> M: 保存成功
deactivate P1
M -> M: 返回第一个G用于执行
note right of M
窃取成功!
从P2窃取了一半的G
end note
end
@enduml正在加载图表...
![PlantUML Diagram]()
详细调度流程说明
调度入口:runtime.schedule()是调度器的核心函数,负责为M选择下一个要执行的G。
优先级顺序:
- 最高优先级:
runnext中的G(如果有) - 高优先级:本地队列头部的G
- 中优先级:全局队列中的G
- 低优先级:从其他P窃取的G
执行G:
- 调用
runtime.execute()切换到G的栈 - G开始执行用户代码
- M与G绑定,
m.curg = g
G的切换:
- 主动让出:G调用
runtime.Gosched()主动让出CPU - 系统调用:G进行系统调用时,M会与P解绑,执行系统调用
- 阻塞操作:G等待channel、锁等时,会进入
_Gwaiting状态 - 抢占:sysmon检测到G运行时间过长,触发抢占
重新调度:当G执行完毕或阻塞时,M会重新调用schedule()获取下一个G。
5. GMP模式存在的问题与挑战
虽然GMP调度器在大多数场景下表现优异,但这种模式仍然存在一些问题和局限性:
5.1 全局队列的竞争问题
问题描述:
- 当本地队列满时,G会被放入全局队列
- 从全局队列获取G时需要加锁,存在竞争
- 高并发场景下,全局队列可能成为性能瓶颈
影响场景:
- 大量短生命周期的Goroutine快速创建和销毁
- 某些P的本地队列经常满载
可视化展示:
@startuml
skinparam backgroundColor transparent
skinparam sequenceArrowThickness 2
participant "P0" as P0
participant "P1" as P1
participant "P2" as P2
participant "全局队列" as GQ
note over GQ #Pink
竞争点:多个P同时
访问全局队列需要加锁
end note
== 场景1: P0持有锁,P1等待 ==
P0 -> GQ: 本地队列满,请求加锁
GQ -> P0: 获得锁
activate GQ
P1 -> GQ: 本地队列空,请求加锁
note right of P1 #Yellow
P1被阻塞
等待P0释放锁
end note
P0 -> GQ: 添加G到全局队列
P0 -> GQ: 释放锁
deactivate GQ
GQ -> P1: P1获得锁
activate GQ
P1 -> GQ: 从全局队列获取G
P1 -> GQ: 释放锁
deactivate GQ
== 场景2: P1持有锁,P2等待 ==
P1 -> GQ: 请求加锁
GQ -> P1: 获得锁
activate GQ
P1 -> GQ: 操作全局队列
P2 -> GQ: 本地队列空,请求加锁
note right of P2 #Yellow
P2被阻塞
等待P1释放锁
end note
P1 -> GQ: 释放锁
deactivate GQ
GQ -> P2: P2获得锁
activate GQ
P2 -> GQ: 获取G
P2 -> GQ: 释放锁
deactivate GQ
note over P0,P2 #LightYellow
**性能问题**
串行访问全局队列
多个P竞争同一个锁
降低并发性能
end note
@enduml
正在加载图表...
![PlantUML Diagram]()
缓解措施:
- 优化本地队列大小(256个G通常足够)
- 批量从全局队列取G(每次取多个),减少加锁次数
- 优先使用本地队列和工作窃取
5.2 工作窃取的开销
问题描述:
- 工作窃取需要遍历其他P的本地队列
- 随机选择目标P,可能多次尝试才能窃取成功
- 窃取操作本身有一定的CPU开销
影响场景:
- P数量很多(GOMAXPROCS设置很大)
- 负载不均衡,部分P经常空闲
问题示意:
@startuml
start
:P0本地队列为空;
:随机选择P1尝试窃取;
if (P1队列为空?) then (是)
:随机选择P2尝试窃取;
if (P2队列为空?) then (是)
:随机选择P3尝试窃取;
if (P3队列为空?) then (是)
:继续尝试P4, P5, P6...;
note right
**开销问题**
1. 多次随机选择
2. 多次队列检查
3. 可能遍历所有P
4. CPU时间浪费
end note
endif
endif
endif
stop
@enduml正在加载图表...
![PlantUML Diagram]()
缓解措施:
- 使用随机算法减少冲突
- 限制窃取尝试次数
- 优先从全局队列获取(定期检查)
5.3 抢占延迟问题
问题描述:
- Go 1.14之前只有协作式抢占,依赖函数调用时的检查点
- 如果G执行密集计算且不调用函数,无法被抢占
- 可能导致其他G饥饿,影响调度公平性
问题场景示例:
@startuml
participant "M0" as M0
participant "P0" as P0
participant "G1\n(长时间运行)" as G1
participant "G2\n(等待中)" as G2
M0 -> P0: 获取G1
P0 --> M0: 返回G1
M0 -> G1: 开始执行
activate G1 #Red
note right of G1 #Pink
G1执行密集计算
for i := 0; i < 1e10; i++ {
result += i * i
}
没有函数调用,无法被抢占!
end note
G1 -> G1: 执行10秒...
G2 -> G2: 在队列中等待...
note right of G2 #Yellow
G2饥饿
无法获得执行机会
end note
G1 -> G1: 继续执行...
G2 -> G2: 继续等待...
G1 -> M0: 执行完成
deactivate G1
M0 -> P0: 获取G2
note right of G2
G2终于得到执行
但已经等待很久
end note
@enduml正在加载图表...
![PlantUML Diagram]()
解决方案:
- Go 1.14+ 引入基于信号的异步抢占
- sysmon线程定期检查长时间运行的G并发送抢占信号
- 建议在密集计算中主动调用
runtime.Gosched()
5.4 系统调用的处理开销
问题描述:
- 当G进行系统调用时,M会与P解绑
- 需要创建或唤醒新的M来接管P
- M和P的频繁绑定/解绑有一定开销
流程示意:
@startuml
participant "M0" as M0
participant "P0" as P0
participant "G1" as G1
participant "M1\n(备用)" as M1
M0 -> G1: 执行G1
activate G1
G1 -> G1: 执行到系统调用\n(如: 网络IO、文件读取)
G1 -> M0: 进入系统调用状态
M0 -> P0: M0与P0解绑
note right of M0 #Pink
**开销1**: 解绑操作
保存状态、更新指针
end note
P0 -> M1: 唤醒或创建新的M1
note right of M1 #Pink
**开销2**: M的创建/唤醒
线程创建/上下文切换
end note
M1 -> P0: M1绑定P0
note right of M1 #Pink
**开销3**: 绑定操作
更新M和P的指针关系
end note
M0 -> M0: 执行系统调用...
M1 -> P0: 继续从P0获取其他G执行
M0 -> M0: 系统调用返回
M0 -> P0: 尝试重新绑定P0
note right of M0
如果P0已被占用,
M0会尝试获取其他P
或进入休眠
end note
@enduml
正在加载图表...
![PlantUML Diagram]()
影响:
- 频繁的系统调用会导致M和P的频繁切换
- 可能创建过多的M(虽然有上限10000)
- 线程创建和上下文切换的开销
缓解措施:
- 使用Go的网络库(netpoller),避免阻塞式系统调用
- 使用buffer IO减少系统调用次数
- 异步IO模式
5.5 内存开销
问题描述:
- 每个G都有自己的栈空间(最小2KB,可增长到1GB)
- P的本地队列和全局队列占用内存
- M结构体本身的内存开销
内存使用示意:
@startuml
component "100万个Goroutine (G)" as G #LightBlue
component "8个P (CPU核心数)" as P #LightGreen
component "100个M (线程)" as M #LightCoral
note right of G
栈空间: 2KB × 1,000,000 = 2GB
G结构体: ~320B × 1,000,000 = 305MB
------------------------
总计: ~2.3GB
end note
note right of P
本地队列: 256 × 8 × 8B = ~16KB
P结构体: ~5KB × 8 = 40KB
------------------------
总计: ~56KB
end note
note right of M
M结构体: ~6KB × 100 = 600KB
系统线程栈: 8MB × 100 = 800MB
------------------------
总计: ~800MB
end note
note bottom
**内存占用问题**
大量Goroutine会占用大量内存
即使栈空间可以动态增长和收缩
end note
@enduml
正在加载图表...
![PlantUML Diagram]()
影响场景:
- 需要创建百万级别的Goroutine
- 内存受限的环境(如嵌入式系统、容器环境)
缓解措施:
- 使用Goroutine池限制并发数
- 及时回收不用的Goroutine
- 监控和限制栈增长
5.6 调试和可观测性问题
问题描述:
- 大量Goroutine使得调试变得困难
- 难以追踪特定Goroutine的执行路径
- 性能分析时难以定位瓶颈
挑战:
- 传统的调试工具(如gdb)对Goroutine支持有限
- Goroutine ID不对外暴露,难以追踪
- 竞态条件难以复现
工具支持:
// 虽然有一些工具,但仍存在挑战
runtime.NumGoroutine() // 只能获取数量
runtime.Stack() // 获取所有Goroutine的栈信息(可能很大)
pprof // 性能分析,但信息量大
6. 总结
6.1 GMP调度器的优势
GMP调度器通过以下设计实现了高效的协程调度:
- 轻量级协程:Goroutine是用户态协程,创建和切换成本低。
- 两级队列:本地队列提供快速访问,全局队列实现负载均衡。
- 工作窃取:确保所有CPU核心都能得到充分利用。
- 抢占式调度:避免单个Goroutine长时间占用线程。
- M:P分离:将线程和逻辑处理器分离,提高灵活性。
这些机制使得Go程序能够高效地运行数百万个Goroutine,同时保持良好的性能和响应性。
6.2 需要注意的问题
尽管GMP模型设计优秀,但在使用时仍需注意:
- 全局队列竞争:避免本地队列频繁溢出到全局队列
- 工作窃取开销:合理设置GOMAXPROCS,避免过多的P
- 抢占延迟:避免长时间运行的密集计算,适时调用
runtime.Gosched() - 系统调用开销:优先使用Go的异步IO库(如net包)
- 内存占用:大规模并发时使用Goroutine池控制数量
- 调试困难:使用pprof等工具进行性能分析和调试
6.3 最佳实践建议
- 根据实际CPU核心数设置
GOMAXPROCS - 合理控制Goroutine数量,避免创建过多
- 使用channel和sync包进行同步,而非共享内存
- 在密集计算中适当添加调度点
- 使用context进行超时和取消控制
- 定期进行性能分析和优化
GMP调度器是Go语言并发编程的基石,理解其工作原理有助于我们写出更高效、更可靠的并发程序。