文章目录
1. 引言
Go 语言自发布以来,以其轻量级的并发处理能力备受开发者的青睐。在传统的并发编程模型中,创建和管理线程往往是高成本且复杂的操作。而 Go 语言通过引入 Goroutine 和 GMP 调度模型,以一种高效且低开销的方式管理并发任务,使得并发编程变得更加简洁易用。
GMP 模型是 Go 语言并发的核心调度机制,它由三个关键组件构成:G(Goroutine)、M(Machine,即操作系统线程)和 P(Processor)。这些组件协同工作,确保 Goroutine 能够高效地在多个内核上调度执行。相比传统的线程模型,Go 语言的 GMP 模型具有更高的并发性能和调度灵活性。
在本文中,我们将通过对 Go 1.19 版本的源码进行深入解析,详细剖析 GMP 模型及其调度机制的内部实现。本文旨在帮助开发者理解 Go 并发调度的核心原理,并通过具体代码示例,展示 Go 调度器是如何高效地管理 Goroutine 的执行和调度的。
2. GMP 模型概述与核心结构体
在 Go 语言的调度模型中,GMP 是实现并发调度的三大核心组件:G(Goroutine)、M(Machine)和 P(Processor)。它们通过密切协作,确保 Goroutine 可以在多个线程和处理器上被有效地分配和执行。为了深入理解 GMP 模型的运作,我们需要从源码层面解析这三者的结构体及其关键字段。
在Go语言的GMP调度模型中,G
、M
、P
以及 schedt
是调度系统的核心结构体,它们分别代表了 Goroutine、操作系统线程和逻辑处理器等不同的角色。下面详细解析每个结构体的作用以及其中每个字段的功能。
2.1. G(Goroutine)
G
结构体表示 Go 语言中的 Goroutine。Goroutine 是 Go 语言中用于并发编程的最小调度单位。每个 Goroutine 都是轻量级的用户态线程,它是通过 G
结构体来管理的。
type g struct {
// g 的执行栈空间
stack stack
// g 从属的 m
m *m
// g的状态
atomicstatus uint32
}
字段解析:
-
stack: 这是
G
的执行栈,包含了这个 Goroutine 的所有函数调用栈帧和局部变量。Go 中的 Goroutine 使用的是可增长的栈,以支持 Goroutine 的轻量特性。 -
m: 表示该
G
当前从属的M
,即正在执行这个 Goroutine 的操作系统线程。G
是通过M
来运行的,因此该字段指向了负责运行此G
的M
。 -
atomicstatus:
G
的状态,表示当前这个 Goroutine 的运行状态。这个字段是原子操作的,用来确保线程安全。不同的状态可能包括:正在运行、阻塞、等待、退出等。
2.2. M(Machine/Thread)
M
结构体表示 操作系统线程,即 M
负责实际执行 Goroutine
的调度。M
是物理上的执行单元,它与操作系统的线程一一对应。
type m struct{
// 用于调度普通 g 的特殊 g
g0 *g
// m 的唯一 id
procid uint64
// 用于处理信号的特殊 g
gsignal *g
// m 上当前运行的 g
curg *g
// m 关联的 p
p puintptr
}
字段解析:
-
g0:
M
负责调度多个G
,而g0
是用于执行调度操作的特殊 Goroutine。g0
不参与用户代码的执行,而是处理调度、垃圾回收等系统任务。 -
procid: 该
M
的唯一 ID,通常对应于操作系统中的线程 ID,用于标识操作系统中的某个线程。 -
gsignal:
gsignal
是用于处理系统信号的特殊 Goroutine。系统信号(如SIGINT
、SIGTERM
等)会被gsignal
捕获并处理。 -
curg: 表示当前
M
上正在运行的G
。每个M
只能同时执行一个Goroutine
,这个字段保存的是当前正在运行的G
。 -
p: 表示与
M
关联的P
。M
需要通过P
才能执行Goroutine
,这个字段指向当前M
所拥有的P
。P
是调度的逻辑处理器,每个P
管理着待执行的G
列表。
2.3. P(Processor)
P
结构体代表 逻辑处理器,是调度系统中的核心部分。每个 P
负责管理一组待执行的 Goroutine
。P
和 M
结合后,M
才能运行 G
。
type p struct {
id int32
status uint32
// p 所关联的 m. 若 p 为 idle 状态,可能为 nil
m muintptr // back-link to associated m (nil if idle)
// lrq 的队首
runqhead uint32
// lrq 的队尾
runqtail uint32
// q 的本地 g 队列——lrq
runq [256]guintptr
// 下一个调度的 g. 可以理解为 lrq 中的特等席
runnext guintptr
}
字段解析:
-
id:
P
的唯一标识符,通常用于标识系统中的某个逻辑处理器。 -
status:
P
的当前状态。不同状态可以是:_Pidle
: 0 表示P
处于空闲状态,没有M
运行。_Prunning
: 1 表示P
正在运行某个G
,由M
所持有。_Psyscall
: 2 表示P
正在执行系统调用,此时可能会被抢占。_Pdead
: 4 表示P
已被终止。
-
m: 表示与
P
关联的M
。当P
处于运行状态时,这个字段指向当前正在执行的M
。如果P
是空闲状态,m
可能为nil
。 -
runqhead/runqtail: 这两个字段表示
P
的本地运行队列runq
的队首和队尾索引,runq
存放着待调度执行的G
。 -
runq:
P
的本地队列,用于保存待执行的Goroutine
。这个队列是每个P
自己的局部队列,与其他P
独立。 -
runnext: 用于标记下一个将被优先调度的
G
。runnext
是P
的特殊位置,可以理解为具有优先级的 Goroutine。
2.4. 全局调度器schedt(Scheduler)
schedt
是 全局调度器,负责管理所有的 G
、M
和 P
。它存储了空闲的 P
和 M
以及全局队列中的 G
。
type schedt struct {
// 锁
lock mutex
// 空闲 m 队列
midle muintptr
// 空闲 p 队列
pidle puintptr
// 全局 g 队列——grq
runq gQueue
// grq 中存量 g 的个数
runqsize int32
}
字段解析:
-
lock: 全局调度器的锁,用于保证调度数据结构的并发安全。在多线程环境下,对
schedt
的修改需要通过lock
来保证。 -
midle: 空闲的
M
列表。系统中未执行任务的M
会被放入该队列中,当需要调度新的G
时,从该队列中取出M
。 -
pidle: 空闲的
P
列表。没有正在执行G
的P
处于空闲状态时会进入这个队列,调度器会从中选择P
和M
绑定以执行G
。 -
runq: 全局
Goroutine
队列(grq
),当所有P
的本地队列都满时,G
会被放入全局队列。全局队列中的G
可以被任意P
窃取(work stealing)。 -
runqsize: 全局队列
runq
中的G
数量,用于记录目前全局等待执行的G
个数。
这些结构体与字段的设计使得 Go 的 GMP 调度系统能够有效地调度大量 Goroutine,确保程序在多核处理器上高效运行。
3. Goroutine 的生命周期与状态管理
Goroutine 的状态管理是 Go 语言调度器工作的核心。Go 调度器通过对 Goroutine 不同状态的管理,来决定何时执行、挂起或销毁一个 Goroutine。下面是 Goroutine 生命周期中涉及的核心状态,它们定义了每个 Goroutine 在不同阶段的具体情况。
3.1 Goroutine 的核心状态列表
Goroutine 的状态在 Go 源码中的定义如下:
const (
_Gidle = iota // 0 空闲状态
_Grunnable // 1 可运行状态
_Grunning // 2 正在运行状态
_Gsyscall // 3 系统调用中
_Gwaiting // 4 等待状态
_Gdead // 6 已结束状态
_Gcopystack // 8 复制栈中
_Gpreempted // 9 被抢占状态
)
每个状态代表了 Goroutine 在其生命周期中的不同阶段,调度器通过这些状态管理 Goroutine 的调度、挂起和销毁。
3.2 各个状态的详细解析
-
_Gidle(空闲状态,值为 0):
Goroutine 处于空闲状态,尚未被初始化。处于该状态的 Goroutine 还没有被调度器分配任何工作,通常是新创建的 Goroutine 或者已经结束的 Goroutine 被重新分配资源之前的状态。 -
_Grunnable(可运行状态,值为 1):
Goroutine 已经准备好,可以被调度器选中执行。处于该状态的 Goroutine 会被放入 P 的本地运行队列或者全局队列中,等待 M 线程分配 CPU 资源进行执行。 -
_Grunning(正在运行状态,值为 2):
Goroutine 正在被某个 M(操作系统线程)执行。当 Goroutine 被从 P 的任务队列中取出后,调度器会将其状态设置为_Grunning
,表示该 Goroutine 正在占用 CPU 资源。 -
_Gsyscall(系统调用中,值为 3):
Goroutine 进入了系统调用(如文件 I/O、网络操作等)状态,此时 Goroutine 并不会占用 CPU 资源,但它暂时无法继续执行。系统调用完成后,Goroutine 会从_Gsyscall
状态转换为_Grunnable
,等待调度器再次调度。 -
_Gwaiting(等待状态,值为 4):
Goroutine 因等待某个事件(如通道操作、锁、定时器等)而处于阻塞状态。在此状态下,Goroutine 不会消耗 CPU 资源,只有当等待条件满足时,调度器才会将其状态切换回_Grunnable
,准备再次调度执行。 -
_Gdead(已结束状态,值为 6):
Goroutine 已经执行完毕,并且不再需要被调度执行。处于_Gdead
状态的 Goroutine 不会被调度器选中,最终会被垃圾回收器回收。 -
_Gcopystack(复制栈中,值为 8):
Goroutine 正在进行栈空间的扩展或收缩操作。由于 Goroutine 的栈是可动态调整大小的,在某些情况下,调度器需要将 Goroutine 的栈从一块内存区域复制到另一块更大或更小的区域。这个状态表示 Goroutine 正处于这种栈调整的过程中。 -
_Gpreempted(被抢占状态,值为 9):
Goroutine 被抢占,暂停执行。Go 调度器为了防止长时间运行的 Goroutine 占用过多的 CPU 资源,会在适当时机主动抢占 Goroutine 的执行。被抢占的 Goroutine 会从_Grunning
状态切换到_Gpreempted
,等待再次被调度。
3.3 Goroutine 状态的转换过程
Goroutine 在其生命周期中不断在不同状态之间进行转换。以下是各个状态之间的转换逻辑及其触发条件:
-
从
_Gidle
到_Grunnable
:
Goroutine 在被创建之后会从空闲状态_Gidle
切换到_Grunnable
,准备好被调度执行。newg := new(g) // 创建新的 Goroutine newg.status = _Grunnable // 设置为可运行状态
-
从
_Grunnable
到_Grunning
:
当调度器选择一个可运行的 Goroutine 时,它的状态会从_Grunnable
转变为_Grunning
,表示它正在被某个 M 执行。func execute(gp *g) { casgstatus(gp, _Grunnable, _Grunning) // 状态从可运行到正在运行 run(gp) // 开始执行 Goroutine }
-
从
_Grunning
到_Gwaiting
:
当 Goroutine 等待某个外部条件(如通道操作、锁、定时器等)时,调度器会将其状态从_Grunning
切换为_Gwaiting
,以释放 CPU 资源。casgstatus(gp, _Grunning, _Gwaiting) // 切换到等待状态
-
从
_Gwaiting
到_Grunnable
:
当等待条件满足后,Goroutine 会被唤醒,并重新进入_Grunnable
状态,等待调度器再次分配执行机会。ready(gp) // 唤醒 Goroutine,状态切换为 _Grunnable
-
从
_Grunning
到_Gsyscall
:
当 Goroutine 进行系统调用(如文件或网络 I/O)时,状态会从_Grunning
切换为_Gsyscall
,此时 Goroutine 不再消耗 CPU 资源,直到系统调用完成。 -
从
_Grunning
到_Gdead
:
当 Goroutine 执行完成时,它的状态会被设置为_Gdead
,表示该 Goroutine 已经结束,不再需要被调度。casgstatus(gp, _Grunning, _Gdead) // 设置为结束状态
-
从
_Grunning
到_Gpreempted
:
如果调度器决定抢占某个正在运行的 Goroutine,它的状态会从_Grunning
切换到_Gpreempted
,等待下一次调度时再被执行。casgstatus(gp, _Grunning, _Gpreempted) // Goroutine 被抢占
-
_Gcopystack 状态的特殊性:
当 Goroutine 的栈空间不足,且需要扩展或收缩时,它会进入_Gcopystack
状态。在此期间,调度器会将 Goroutine 的栈内容复制到一个新的内存区域,然后再将其状态切回_Grunnable
,以继续执行。
3.4 Goroutine 状态图
从 Goroutine 的生命周期来看,它们的状态转换可以概述为如下图示:
4. G、M、P 的协作关系
Go 的 GMP 模型由三部分组成:Goroutine (G)、Machine (M) 和 Processor §。它们的协作构成了 Go 调度器的基础。在这一节中,我们将详细探讨 G、M、P 三者之间的协同工作机制,并结合源码解析它们如何共同完成高效的并发任务调度。
4.1 GMP 模型中的协同工作机制
在 Go 的并发模型中,G、M 和 P 的分工如下:
-
G(Goroutine): Goroutine 是 Go 语言中并发执行的最小单元,类似于其他编程语言中的线程或轻量级进程,但相比之下开销要小得多。Goroutine 被封装在
g
结构体中,调度器负责管理和调度这些 Goroutine 的执行。 -
M(Machine): M 代表操作系统的线程(OS thread),每个 M 都与一个或多个 Goroutine 关联。M 负责实际执行 Goroutine 中的任务。可以认为 M 是调度器在操作系统中的执行代理,它直接与系统内核进行交互。
-
P(Processor): P 是 Goroutine 的执行上下文。它持有本地的 Goroutine 运行队列,并与 M 进行协作,将 Goroutine 分配给 M 执行。P 的数量由
GOMAXPROCS
决定,表示可以同时执行 Goroutine 的最大核数。
协作关系的简要描述:
- Goroutine (G) 是执行单元,由 Processor § 管理,P 负责分配 Machine (M) 来运行 G。
- Machine (M) 是物理上的线程,负责运行被 P 分配的 Goroutine。
简化来说,M 是线程,P 是调度的上下文,G 是具体要执行的任务。
4.2 G、M、P 的具体工作流程
G、M、P 三者协作的整体调度流程如下:
-
Goroutine 创建: 当程序调用
go
关键字创建一个新的 Goroutine 时,新的 Goroutine 会被分配给当前的 P 并放入它的本地运行队列,处于_Grunnable
状态,等待执行。 -
M 执行 G: 每个 M 都会绑定一个 P,M 通过 P 的本地运行队列获取可运行的 Goroutine 并执行。P 将其本地队列中的 Goroutine 分配给 M 并切换 G 到
_Grunning
状态,M 负责实际运行 G。 -
任务完成或阻塞: 当 Goroutine 完成任务或者需要等待某个外部事件时(如 I/O、锁、信号等),它会进入
_Gwaiting
状态。M 会释放该 Goroutine,并继续从 P 的队列中取下一个 Goroutine 进行执行。 -
负载均衡: 当一个 P 的本地任务队列为空时,M 会尝试从全局任务队列或者其他 P 的本地队列中“窃取” Goroutine 任务执行。这种机制确保了所有 M 都能充分利用 CPU 资源,提高并发执行效率。
4.3 源码解析:G、M 和 P 之间的协作
在 GMP 模型中,M、P 和 G 是如何交互的?下面通过核心代码片段详细解析它们的协作关系。
4.3.1 G 的分配与执行
当一个新的 Goroutine 被创建时,它会被分配到当前 P 的本地运行队列中,P
会管理这些 Goroutine 并分配给 M
执行。P
的本地队列最多可以存储 256 个 Goroutine,如果队列已满,多余的 Goroutine 会被放入全局队列中。
func newproc1(fn *funcval, argp unsafe.Pointer, narg i