文章目录
- 1. 引言
- 2. GMP 模型概述与核心结构体
- 2.1. G(Goroutine)
- 2.2. M(Machine/Thread)
- 2.3. P(Processor)
- 2.4. 全局调度器schedt(Scheduler)
- 3. Goroutine 的生命周期与状态管理
- 3.1 Goroutine 的核心状态列表
- 3.2 各个状态的详细解析
- 3.3 Goroutine 状态的转换过程
- 3.4 Goroutine 状态图
- 4. G、M、P 的协作关系
- 4.1 GMP 模型中的协同工作机制
- 4.2 G、M、P 的具体工作流程
- 4.3 源码解析:G、M 和 P 之间的协作
- 4.3.1 G 的分配与执行
- 4.3.2 M 和 P 的绑定
- 4.3.3 负载均衡与工作窃取
- 4.4 P 的本地队列与全局队列的关系
- 5. GMP 宏观调度流程
- 5.1 Go 调度器的总体流程
- 5.2 核心函数解析
- 5.2.1 `schedule()` 函数:调度循环
- 5.2.2 `findRunnable()` 函数:寻找可运行的 Goroutine
- 5.2.3 `execute()` 函数:执行 Goroutine
- 5.3 全局队列和本地队列之间的调度关系
- 5.4 调度性能优化
- 6. G0 与 G 之间的调度切换
- 6.1 G0 与 G 切换的内存管理与调度开销
- 6.2 G0 到 G 的切换过程
- 6.3 G 到 G0 的切换过程
- 7. 调度过程中 Goroutine 状态的变化
- 7.1 Goroutine 的主要状态
- 7.2 状态变化的典型流程
- 7.3 核心状态切换函数解析
- 7.3.1 `casgstatus()`:原子性状态切换
- 7.3.2 `ready()`:将等待中的 Goroutine 标记为可运行
- 7.3.3 `dropg()`:从 M 上移除 Goroutine
- 7.3.4 `preemptone()`:抢占 Goroutine
- 7.4 Goroutine 状态变化图
- 7.5 状态切换的优化
- 8. 主动、正常、抢占、被动调度详解
- 8.1 主动调度:`gosched_m()`
- 8.2 正常调度:`goexit()`
- 8.3 抢占调度:`retake()` 和 `preemptone()`
- 8.4 被动调度:`park_m()`
- 8.5 调度方式对比
- 9. 总结与回顾
- 9.1 GMP 模型的优势
- 9.2 核心调度机制的解析
- 9.3 主动、正常、抢占、被动调度的高效协作
- 9.4 调度核心函数的深入理解
- 9.5 Go 调度器的设计理念与性能优化
- 9.6 总结
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 从属的 mm *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 的特殊 gg0 *g // m 的唯一 idprocid uint64// 用于处理信号的特殊 ggsignal *g // m 上当前运行的 gcurg *g // m 关联的 pp 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 int32status uint32// p 所关联的 m. 若 p 为 idle 状态,可能为 nilm muintptr // back-link to associated m (nil if idle)// lrq 的队首runqhead uint32// lrq 的队尾runqtail uint32// q 的本地 g 队列——lrqrunq [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 队列——grqrunq 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 int32) {_g_ := getg() // 获取当前的 Goroutine// 创建新的 Goroutine,设置它的初始状态为 _Grunnablenewg := newg()newg.status = _Grunnable// 将新的 Goroutine 放入当前 P 的本地队列中runqput(_g_.m.p.ptr(), newg)
}
runqput
是将新的 Goroutine 放入 P 的本地队列的函数。当 P 队列中有可运行的 Goroutine 时,它们会被调度执行:
func runqput(_p_ *p, gp *g) {// 如果 P 的本地队列不满,直接放入本地队列if _p_.runqtail-_p_.runqhead < uint32(len(_p_.runq)) {_p_.runq[_p_.runqtail%uint32(len(_p_.runq))] = guintptr(gp)_p_.runqtail++} else {// 否则将 Goroutine 放入全局队列globrunqput(gp)}
}
4.3.2 M 和 P 的绑定
每个 M 都会绑定一个 P,只有绑定了 P 的 M 才能执行 Goroutine。当 M 线程启动时,它会首先尝试获取一个 P 来绑定:
// 启动一个 M 并绑定一个 P
func startm(_p_ *p, spinning bool) {_g_ := getg() // 获取当前 M 的 g(调度栈上的 Goroutine)// 创建一个新的 M,或获取一个空闲的 Mmp := newm(_p_, spinning)// 绑定 P 和 Mif _p_ != nil {mp.p.set(_p_)}// 开始执行 Goroutineexecute(mp)
}
一旦 M 和 P 绑定成功,P 的本地队列中的 Goroutine 就会被分配给 M 执行。P 本地队列中的 Goroutine 通过 runqget
函数被取出,供 M 执行:
// 从 P 的本地运行队列中取出一个 Goroutine
func runqget(_p_ *p) *g {if _p_.runqhead == _p_.runqtail {return nil // 本地队列为空}gp := _p_.runq[_p_.runqhead%uint32(len(_p_.runq))]_p_.runqhead++return gp.ptr() // 返回可运行的 Goroutine
}
4.3.3 负载均衡与工作窃取
当一个 M 完成了当前的 Goroutine 执行时,它会尝试从 P 的本地队列中获取下一个 Goroutine。如果本地队列为空,M 会尝试从全局队列中或者其他 P 的本地队列中窃取任务。
工作窃取机制在 findRunnable
函数中实现:
func findRunnable() *g {//...// 工作窃取:从其他 P 的队列中窃取任务for i := 0; i < len(allp); i++ {steal := runqsteal(allp[i])if steal != nil {return steal // 成功窃取到任务}}//...
}
如果 P 的本地队列和全局队列都为空,M 会尝试通过工作窃取从其他 P 的队列中获取 Goroutine。这种机制确保了任务能够平衡分配到各个 M 上,提升了并发执行的效率。
4.4 P 的本地队列与全局队列的关系
P 维护着一个本地运行队列,用来存放等待执行的 Goroutine。本地队列的使用可以减少 M 之间的锁竞争,从而提高调度性能。如果本地队列已满,多余的 Goroutine 会被放入全局队列:
- 本地队列: P 的本地队列可以快速地为绑定的 M 提供任务,减少 M 与 P 之间的任务切换延迟。
- 全局队列: 全局队列用于存储那些无法放入 P 本地队列的 Goroutine。在极端情况下(例如所有 P 的本地队列都已满),新的 Goroutine 会被放入全局队列中。
全局队列的任务处理速度相比本地队列较慢,因为访问全局队列时需要加锁来保证线程安全。这就是为什么 Go 调度器优先使用 P 的本地队列,只有当本地队列为空时,M 才会尝试从全局队列中获取任务。
5. GMP 宏观调度流程
Go 的调度器通过 GMP 模型在多个操作系统线程上并行执行 Goroutine。在 Go 的调度器中,调度流程是 Goroutine 从创建到执行、等待、再到结束的关键过程。在这一部分中,我们将详细解析 Go 1.19 调度器的核心流程,并通过源码分析 schedule()
、findRunnable()
和 execute()
函数的实现,了解 Goroutine 是如何被高效调度的。
5.1 Go 调度器的总体流程
Go 调度器的主要目标是高效地管理 Goroutine,使其在多个操作系统线程(M)上并发执行。调度器的调度流程可以分为以下几个步骤:
-
创建 Goroutine: 当用户使用
go
关键字创建 Goroutine 时,新的 Goroutine 会被放入当前P
的本地运行队列或全局队列中。 -
选择可运行的 Goroutine: 当一个操作系统线程(M)需要执行任务时,它会从绑定的
P
的本地队列中取出一个可运行的 Goroutine。如果本地队列为空,它会从全局队列或者其他P
的队列中“窃取” Goroutine 来执行。 -
执行 Goroutine: 当 M 取到一个 Goroutine 时,它会调用
execute()
函数执行该 Goroutine 的具体逻辑,直到 Goroutine 完成、进入阻塞状态或者被抢占为止。 -
Goroutine 的状态转换: 当一个 Goroutine 进入等待状态(如等待 I/O 操作、锁、信号等),调度器会将其状态设置为
_Gwaiting
并从运行队列中移除,直到其等待条件满足,重新回到_Grunnable
状态。 -
完成 Goroutine 执行: 当 Goroutine 执行完成后,它会进入
_Gdead
状态,并释放所占用的资源。调度器会继续选择下一个待执行的 Goroutine。
5.2 核心函数解析
Go 调度器的工作依赖于几个核心函数:schedule()
、findRunnable()
和 execute()
。这些函数分别负责调度 Goroutine、找到可运行的 Goroutine、并执行 Goroutine 的具体任务。
5.2.1 schedule()
函数:调度循环
核心流程:
schedule()
函数是 Go 调度器的核心循环,它负责从 P 的本地队列或全局队列中选择一个可运行的 Goroutine 并交由 M 执行。当 M 空闲时,它会进入调度循环,调用 schedule()
查找新的任务。
源码解析:
func schedule() {_g_ := getg() // 获取当前的 Goroutinemp := _g_.m // 获取当前 Mfor {// 从 P 的本地队列或全局队列中查找一个可运行的 Goroutinegp, inheritTime, tryWakeP := findRunnable()// 执行找到的 Goroutineexecute(gp, inheritTime)}
}
关键步骤解析:
-
获取当前 Goroutine 和 M:
schedule()
开始时会获取当前的 Goroutine 和 M 线程。M 是操作系统的线程,负责执行具体的 Goroutine。 -
查找可运行的 Goroutine:
调度器通过调用findRunnable()
函数在 P 的本地队列、全局队列或者其他 P 的本地队列中查找可运行的 Goroutine。如果找到,返回 Goroutine 并准备执行。 -
执行 Goroutine:
一旦调度器找到可运行的 Goroutine,它会调用execute()
函数,启动该 Goroutine 的执行。
作用:
schedule()
是 Go 调度循环的核心,它不断尝试查找可运行的 Goroutine,并将其交给 M 执行。在没有任务时,M 会暂时进入空闲状态,等待新的任务。
5.2.2 findRunnable()
函数:寻找可运行的 Goroutine
核心流程:
findRunnable()
函数是 Go 调度器中至关重要的一部分,它的主要任务是为当前的 P
(Processor)找到一个可运行的 Goroutine。如果当前 P
没有可运行的 Goroutine,它还会尝试从全局队列、网络轮询,甚至其他 P
的本地队列中“窃取”任务。通过多层次的任务查找机制,findRunnable()
函数确保了所有 P
和 M
都有任务可执行,从而提高了并发的整体效率。
核心代码:
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {mp := getg().mpp := mp.p.ptr() // 当前的 P// 每隔一段时间检查一次全局队列,确保公平性。// 否则两个 Goroutine 可能会占用本地运行队列,不断互相生成彼此。if pp.schedtick%61 == 0 && sched.runqsize > 0 {lock(&sched.lock)gp = globrunqget(pp, 1) // 从全局队列中获取 Goroutineunlock(&sched.lock)if gp != nil {return gp, false, false // 如果找到,返回全局队列中的 Goroutine}}// 唤醒 finalizer Goroutineif fingStatus.Load()&(fingWait|fingWake) == fingWait|fingWake {if gp := wakefing(); gp != nil {ready(gp, 0, true) // 准备 finalizer Goroutine}}// 处理可能的 cgo 的调度让出操作if *cgo_yield != nil {asmcgocall(*cgo_yield, nil)}// 从 P 的本地队列获取 Goroutineif gp, inheritTime := runqget(pp); gp != nil {return gp, inheritTime, false}// 如果本地队列为空,尝试从全局队列获取 Goroutineif sched.runqsize != 0 {lock(&sched.lock)gp = globrunqget(pp, 0)unlock(&sched.lock)if gp != nil {return gp, false, false // 如果找到,从全局队列返回一个 Goroutine}}// 进行网络轮询(非阻塞方式)// 优化步骤:在尝试窃取任务之前,先进行网络轮询,尽可能多找到任务if netpollinited() && netpollWaiters.Load() > 0 && sched.lastpoll.Load() != 0 {if list := netpoll(0); !list.empty() {gp = list.pop() // 非阻塞方式从网络中获取任务injectglist(&list) // 将任务注入全局队列casgstatus(gp, _Gwaiting, _Grunnable) // 状态从 _Gwaiting 转为 _Grunnableif trace.enabled {traceGoUnpark(gp, 0)}return gp, false, false // 返回网络轮询中找到的 Goroutine}}// 工作窃取:如果当前没有任务可执行,尝试从其他 P 的队列中窃取任务if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {if !mp.spinning {mp.becomeSpinning() // 当前 M 开始进入自旋状态,尝试获取任务}// 窃取其他 P 的任务gp, inheritTime, tnow, w, newWork := stealWork(now)if gp != nil {return gp, inheritTime, false // 成功窃取任务}if newWork {// 可能有新的定时器或 GC 任务,重新开始调度goto top}// 更新时间,确保轮询下一次任务时可以考虑早期的定时器now = tnowif w != 0 && (pollUntil == 0 || w < pollUntil) {// 之前的定时器需要等待,设置新的等待时间pollUntil = w}}// 如果没有找到可运行的 Goroutine,则此 M 进入空闲状态return nil, false, false
}
关键逻辑解析:
-
全局队列检查(Global Queue Check):
每隔 61 次调度,调度器会检查全局队列,确保公平性。通常情况下,P 优先使用本地队列,但是全局队列中的任务也需要得到公平的调度,避免某些 Goroutine 被长期“饿死”。if pp.schedtick%61 == 0 && sched.runqsize > 0 {lock(&sched.lock)gp = globrunqget(pp, 1)unlock(&sched.lock)if gp != nil {return gp, false, false} }
-
本地队列获取(Local Queue Get):
调度器优先从当前P
的本地运行队列中获取 Goroutine。如果本地队列中有可运行的 Goroutine,直接返回给当前M
执行。if gp, inheritTime := runqget(pp); gp != nil {return gp, inheritTime, false }
-
全局队列获取(Global Queue Get):
调度器优先从当前P
的本地运行队列中获取 Goroutine。如果本地队列中没有可运行的 Goroutine,调度器会从全局队列中获取,获取到了,直接返回给当前M
执行。// 如果本地队列为空,尝试从全局队列获取 Goroutineif sched.runqsize != 0 {lock(&sched.lock)gp = globrunqget(pp, 0)unlock(&sched.lock)if gp != nil {return gp, false, false // 如果找到,从全局队列返回一个 Goroutine}}
-
网络轮询(Net Polling):
调度器还会对网络轮询进行优化,先进行一次非阻塞的网络任务轮询。如果网络任务队列中有可运行的任务,它会优先执行这些任务,这可以提升 I/O 密集型任务的响应速度。if netpollinited() && netpollWaiters.Load() > 0 && sched.lastpoll.Load() != 0 {if list := netpoll(0); !list.empty() {gp = list.pop()injectglist(&list)casgstatus(gp, _Gwaiting, _Grunnable)return gp, false, false} }
-
工作窃取(Work Stealing):
如果本地队列和全局队列都没有找到任务,调度器会通过工作窃取机制,从其他P
的本地队列中窃取任务。这种机制确保了负载的均衡分配,提高了并发执行的效率。gp, inheritTime, tnow, w, newWork := stealWork(now) if gp != nil {return gp, inheritTime, false }
5.2.3 execute()
函数:执行 Goroutine
核心流程:
execute()
函数负责实际执行找到的 Goroutine。它会将 Goroutine 的状态从 _Grunnable
切换到 _Grunning
,并调用该 Goroutine 的具体任务函数。
核心代码:
func execute(gp *g, inheritTime bool) {_g_ := getg()_g_.m.curg = gp // 设置当前正在运行的 Goroutine// 将 Goroutine 的状态设置为正在运行casgstatus(gp, _Grunnable, _Grunning)// 执行 Goroutinerun(gp)
}
execute()
函数中,M 会执行从调度器中获得的 Goroutine,直到其完成、阻塞或被抢占为止。该函数是 Goroutine 的核心执行入口,负责切换上下文并启动 Goroutine。
关键步骤解析:
-
设置当前 Goroutine:
execute()
首先将当前 M 的curg
设置为找到的 Goroutine,表示该 M 线程正在执行该 Goroutine。 -
状态切换:
调度器将 Goroutine 的状态从_Grunnable
切换为_Grunning
,表示该 Goroutine 已进入运行状态。 -
切换到 Goroutine 栈:
gogo()
函数用于切换到该 Goroutine 的栈,并恢复其执行上下文,从而开始执行用户代码。
作用:
execute()
函数是 Goroutine 执行的入口。它确保在调度器找到可运行 Goroutine 后,正确设置状态并切换到用户栈上,开始执行用户任务。
5.3 全局队列和本地队列之间的调度关系
Go 调度器通过将 Goroutine 分配给 P
的本地队列和全局队列实现负载均衡。调度器优先从 P 的本地队列获取 Goroutine,减少全局队列的访问频率。这样的设计可以有效避免多个 M 线程对全局队列的竞争,提高并发性能。
本地队列的优势:
- 本地队列减少了对全局锁的依赖,P 和 M 之间可以快速切换 Goroutine,减少上下文切换的开销。
全局队列的作用:
- 当 P 的本地队列已满,或者有新的 Goroutine 创建时,任务会被放入全局队列。全局队列充当一个缓冲,确保 Goroutine 不会被丢弃。
在高并发场景下,调度器通过 工作窃取 机制来保证任务的均衡分配。当某个 P 处理的任务较少时,它可以从其他 P 的本地队列中窃取任务执行,最大化 CPU 的利用率。
5.4 调度性能优化
Go 的调度器经过多次优化以提高并发执行的性能:
-
本地队列优先: 调度器优先从 P 的本地队列获取 Goroutine 执行,减少对全局队列的访问,降低了锁竞争。
-
工作窃取: 当一个 P 的本地队列空闲时,M 可以从其他 P 的本地队列中窃取任务执行,确保任务均衡分配,提高 CPU 利用率。
-
抢占调度: 调度器可以主动抢占长时间运行的 Goroutine,确保 Goroutine 不会占用过多的 CPU 资源,从而提升整体的任务响应速度。
6. G0 与 G 之间的调度切换
在 Go 的调度系统中,G0 和 G 是两个不同的 Goroutine 类型,它们负责不同的任务:
- G0:是每个操作系统线程(M)上的调度栈,它只用于处理调度器相关的任务,而不用于执行普通的 Goroutine。
- G:普通的 Goroutine,是 Go 中用于执行用户代码的并发任务单元。
调度切换的核心操作之一是 G0 和 G 之间的栈切换。G0(调度栈)负责执行调度相关操作,当 M 从调度任务切换到实际执行 Goroutine 时,会从 G0 切换到 G 的用户栈。在本节中,我们将深入解析 G0 与 G 之间的切换过程,以及其在调度系统中的实现细节。
6.1 G0 与 G 切换的内存管理与调度开销
-
用户栈与调度栈的管理:
- 每个
G
有一个用户栈,执行用户代码时会使用该栈。 - 每个
M
绑定一个G0
,G0
的栈用于调度任务,而不会执行用户代码。 - 当从
G
切换到G0
时,系统会保存当前 Goroutine 的上下文(如栈指针、程序计数器等),并在合适的时机恢复。
- 每个
-
切换的开销:
- G0 和 G 之间的切换是 Go 运行时调度的核心操作之一,切换操作的频率和开销直接影响系统的整体并发性能。
- Go 通过在本地队列中调度 Goroutine、减少全局队列和锁竞争,以及通过工作窃取等优化策略,尽量减少频繁的上下文切换,降低调度的开销。
6.2 G0 到 G 的切换过程
每个 M
(操作系统线程)都有一个与之绑定的 G0
,它是调度栈。在调度循环中,调度器会将 M
从 G0
切换到实际执行的 G
(即普通的 Goroutine)。切换的过程通常发生在调度器找到可运行的 Goroutine 后,将其分配给 M
执行时。
当调度器选定了一个可运行的 Goroutine 后,M
会从 G0
切换到 G
,开始在用户栈上执行代码。以下是 G0 到 G 切换的源码片段:
// 调度器决定要执行一个 Goroutine 时,调用 execute 切换到 G 的栈
func execute(gp *g, inheritTime bool) {_g_ := getg() // 获取当前的 Goroutine(即 G0)_g_.m.curg = gp // 将当前 M 的正在运行 Goroutine 设为 gp_g_.m.locks++ // 防止被抢占casgstatus(gp, _Grunnable, _Grunning) // 状态切换为 _Grunning,表示正在运行gogo(&gp.sched) // 切换到 gp 的用户栈,开始执行用户代码
}
getg()
:获取当前正在运行的 Goroutine,对于调度器来说,这通常是G0
。casgstatus()
:将 Goroutine 的状态从_Grunnable
切换为_Grunning
,表示该 Goroutine 正在被M
执行。gogo()
:通过gogo
指令切换到G
的栈并开始执行它的任务。
**gogo()
**的作用
gogo()
是 Go 运行时中用来执行栈切换的核心函数。它通过切换栈指针、程序计数器等上下文信息,实现从 G0
(调度栈)切换到 G
(用户栈)。
// gogo 函数用于恢复调度时保存的上下文(PC 和 SP)
func gogo(buf *gobuf) {// 从保存的上下文中恢复 PC(程序计数器)和 SP(栈指针)pc := buf.pcsp := buf.sp// 切换到目标 Goroutine 的栈,并继续其执行runtime_gogo(pc, sp)
}
gogo
通过恢复 Goroutine 在调度过程中保存的上下文(即程序计数器和栈指针),实现了从 G0 到 G 的无缝切换。
6.3 G 到 G0 的切换过程
-
当一个 Goroutine 需要被挂起时(例如,等待 I/O 操作、锁等事件),或者当调度器决定调度另一个 Goroutine 时,
M
会从当前的 GoroutineG
切换回G0
,让调度栈接管控制权。切换回G0
让调度器有机会选择下一个待执行的 Goroutine,或者继续处理其他任务。 -
当 Go 运行时决定将一个 Goroutine 挂起、抢占、或者它自己主动放弃 CPU 时,系统会将当前的 Goroutine 状态保存,并将控制权切换回到 M 的调度栈(G0)。在这个过程中,底层依赖的核心函数就是
mcall()
。
7. 调度过程中 Goroutine 状态的变化
在 Go 的调度系统中,Goroutine 的状态管理是调度器能够高效管理并发任务的核心。每个 Goroutine 在执行过程中会经历不同的状态切换,这些状态帮助调度器决定何时可以运行、何时需要挂起、何时等待资源或者何时被抢占。
在本节中,我们将深入解析 Goroutine 在调度过程中如何在不同状态之间转换,结合源码分析调度器如何管理这些状态切换。
7.1 Goroutine 的主要状态
如前文所述,Go 运行时为 Goroutine 定义了多个状态。以下是 Goroutine 调度过程中涉及的主要状态:
const (_Gidle = iota // 0 空闲状态_Grunnable // 1 可运行状态_Grunning // 2 正在运行状态_Gsyscall // 3 系统调用中_Gwaiting // 4 等待状态_Gdead // 6 已结束状态_Gcopystack // 8 复制栈中_Gpreempted // 9 被抢占状态
)
每个状态代表了 Goroutine 在其生命周期中的某个阶段,调度器通过这些状态来决定 Goroutine 何时被执行、何时被挂起,以及何时需要被销毁。
7.2 状态变化的典型流程
Goroutine 从创建到执行完成,其状态经历了多个转换过程。以下是 Goroutine 的典型状态变化流程:
-
创建 Goroutine:_Gidle -> _Grunnable
- 当创建一个新的 Goroutine 时,它首先处于
_Gidle
状态。初始化完成后,调度器将其状态设置为_Grunnable
,表示该 Goroutine 已经准备好可以被执行。
newg := new(g) // 创建新的 Goroutine newg.status = _Grunnable // 设置为可运行状态
- 当创建一个新的 Goroutine 时,它首先处于
-
调度执行:_Grunnable -> _Grunning
- 调度器从 P 的本地队列或全局队列中选择一个
_Grunnable
状态的 Goroutine 并开始执行。执行时,Goroutine 的状态会切换为_Grunning
,表示正在执行中。
casgstatus(gp, _Grunnable, _Grunning) // 状态从可运行变为运行中
- 调度器从 P 的本地队列或全局队列中选择一个
-
进入系统调用或等待:_Grunning -> _Gsyscall / _Gwaiting
- 当 Goroutine 进入系统调用(如 I/O 操作)或其他需要等待的操作(如锁、通道等)时,调度器会将其状态设置为
_Gsyscall
或_Gwaiting
,表示该 Goroutine 正在等待外部资源完成。
casgstatus(gp, _Grunning, _Gwaiting) // 切换为等待状态
- 当 Goroutine 进入系统调用(如 I/O 操作)或其他需要等待的操作(如锁、通道等)时,调度器会将其状态设置为
-
重新调度执行:_Gsyscall / _Gwaiting -> _Grunnable
- 当系统调用完成或者 Goroutine 等待的资源变得可用时,调度器会将其状态设置为
_Grunnable
,使其重新进入可运行状态,等待再次被调度执行。
ready(gp) // 将等待的 Goroutine 状态切换为 _Grunnable
- 当系统调用完成或者 Goroutine 等待的资源变得可用时,调度器会将其状态设置为
-
结束 Goroutine:_Grunning -> _Gdead
- 当 Goroutine 完成执行时,它的状态会被设置为
_Gdead
,表示该 Goroutine 已经结束,不再需要调度执行。接下来,垃圾回收器会回收该 Goroutine 占用的资源。
casgstatus(gp, _Grunning, _Gdead) // 设置为已结束状态
- 当 Goroutine 完成执行时,它的状态会被设置为
-
抢占 Goroutine:_Grunning -> _Gpreempted
- 如果一个 Goroutine 长时间占用 CPU,调度器可以主动抢占它,将其状态设置为
_Gpreempted
,以便让其他 Goroutine 有机会获得 CPU 资源。这种抢占是 Go 调度器防止 Goroutine 垄断资源的关键机制。
casgstatus(gp, _Grunning, _Gpreempted) // 将 Goroutine 状态设置为被抢占
- 如果一个 Goroutine 长时间占用 CPU,调度器可以主动抢占它,将其状态设置为
7.3 核心状态切换函数解析
Go 调度器依靠一些关键的函数来管理 Goroutine 的状态切换。以下是一些重要的状态切换函数及其作用:
7.3.1 casgstatus()
:原子性状态切换
casgstatus()
是 Go 运行时中用于原子性切换 Goroutine 状态的函数。它通过 Compare-and-Swap
(CAS) 操作确保状态切换的原子性,避免多线程并发访问时的竞态条件。
func casgstatus(gp *g, oldval, newval uint32) bool {return atomic.Cas(&gp.atomicstatus, oldval, newval)
}
atomic.Cas()
:使用原子操作比较并交换 Goroutine 的状态,确保只有一个线程能够成功修改 Goroutine 的状态。
7.3.2 ready()
:将等待中的 Goroutine 标记为可运行
ready()
函数负责将一个处于 _Gwaiting
或 _Gsyscall
状态的 Goroutine 恢复为 _Grunnable
,以便它能够重新被调度执行。
func ready(gp *g) {casgstatus(gp, _Gwaiting, _Grunnable) // 状态从等待变为可运行runqput(getg().m.p.ptr(), gp) // 将 Goroutine 放入 P 的本地队列
}
runqput()
:将该 Goroutine 放入当前 P 的本地运行队列,等待调度器选择它进行执行。
7.3.3 dropg()
:从 M 上移除 Goroutine
当一个 Goroutine 被挂起或让出 CPU 时,dropg()
函数会将其从当前 M 上移除,并允许 M 继续调度其他任务。
func dropg() {_g_ := getg()_g_.m.curg = nil // 当前 M 不再运行任何 Goroutine
}
7.3.4 preemptone()
:抢占 Goroutine
preemptone()
函数用于抢占正在运行的 Goroutine,调度器通过将其状态设置为 _Gpreempted
,并从 M 中移除它,随后由调度器重新选择其他 Goroutine 执行。
func preemptone(gp *g) {casgstatus(gp, _Grunning, _Gpreempted) // 将运行中的 Goroutine 设置为抢占状态dropg() // 从当前 M 移除该 Goroutine
}
7.4 Goroutine 状态变化图
从 Goroutine 的生命周期角度来看,它的状态可以通过以下图示展示:
- Goroutine 从
_Gidle
状态被创建为_Grunnable
,等待调度器调度。 - 当调度器选择执行该 Goroutine 时,它进入
_Grunning
状态。 - 执行过程中,Goroutine 可能因为系统调用或等待资源进入
_Gsyscall
或_Gwaiting
状态。 - 调度器可以在适当时机抢占长时间运行的 Goroutine,将其状态切换为
_Gpreempted
。 - 当 Goroutine 正常结束后,它的状态会变为
_Gdead
,等待垃圾回收器清理。
7.5 状态切换的优化
Go 调度器通过多个优化策略来减少不必要的状态切换开销:
-
本地队列优先调度:通过优先使用 P 的本地队列,减少频繁访问全局队列的锁竞争。
-
抢占机制:防止 Goroutine 长时间占用 CPU,调度器可以通过抢占机制强制中断并重新调度。
-
减少上下文切换:调度器会在适当时机将 Goroutine 从运行状态切换为等待或挂起状态,避免过多的上下文切换。
8. 主动、正常、抢占、被动调度详解
Go 的调度器使用了多种调度机制来确保 Goroutine 的高效执行和资源的公平利用。具体来说,调度器会根据 Goroutine 的状态和行为采取 主动调度、正常调度、抢占调度 和 被动调度。每种调度方式都有其特定的场景和目的,确保在高并发情况下任务能够及时得到处理,而不会出现某些 Goroutine 长时间占用 CPU 资源的问题。
本节中,我们将详细解释这四种调度方式,并通过源码分析它们在 Go 调度器中的实现。
8.1 主动调度:gosched_m()
主动调度发生在 Goroutine 自愿放弃 CPU 的情况下,常见的场景是通过 gosched()
函数,当前 Goroutine 主动让出 CPU 资源,并进入调度循环,让调度器去选择下一个要执行的任务。
场景:
- 某些 Goroutine 完成了当前的部分任务,但未结束整个生命周期,此时可以主动让出 CPU 给其他 Goroutine 执行。
- 使用
gosched()
明确指示调度器当前 Goroutine 不需要继续执行,调度器可以调度其他任务。
实现:
func gosched() {// 当前 Goroutine 主动让出 CPU,进入调度状态gosched_m(getg())
}func gosched_m(gp *g) {// 获取当前 Goroutine_g_ := getg()// 当前 M 不再执行任何 Goroutine_g_.m.curg = nil// 将 Goroutine 状态从 _Grunning 切换为 _Grunnablecasgstatus(gp, _Grunning, _Grunnable)dropg() // 将当前 Goroutine 放入 P 的本地队列中// 进入调度循环schedule()
}
gosched()
:当 Goroutine 调用gosched()
时,它会主动进入调度状态,不再占用 CPU 资源,并将自己标记为可运行(_Grunnable
)。schedule()
:此时调度器会选择下一个可运行的 Goroutine 并交由 M 执行。
优势:
- 提高调度灵活性:允许 Goroutine 在适当的时机主动让出 CPU,提高整体并发性能。
- 避免过度占用资源:防止某个 Goroutine 长时间占用 CPU 资源,给其他任务执行机会。
8.2 正常调度:goexit()
正常调度是指 Goroutine 完成其生命周期后自动进行的调度。Goroutine 执行结束后会调用 goexit()
,将当前 M 切换回 G0,进入调度循环并选择新的 Goroutine 来执行。
场景:
- Goroutine 执行完成并正常退出,不再需要调度。
实现:
func goexit() {// Goroutine 结束时调用 goexit0 释放资源并切换回 G0mcall(goexit0)
}func goexit0(gp *g) {// 当前 Goroutine 结束后,从 M 中移除gp.m.curg = nil// 将 Goroutine 状态设置为 _Gdead,表示它已结束casgstatus(gp, _Grunning, _Gdead)// 将当前 M 切换回 G0,继续调度其他任务schedule()
}
goexit()
:当 Goroutine 执行完成后,会调用goexit()
函数,该函数将当前 Goroutine 的状态从_Grunning
切换为_Gdead
,表示其生命周期结束。schedule()
:结束后,M 切换回 G0 栈,进入调度循环,调度下一个 Goroutine 执行。
优势:
- 清理资源:Goroutine 正常结束时能够及时清理其占用的资源,避免内存泄漏。
- 自动调度:Go 运行时通过
schedule()
自动调度下一个 Goroutine,确保调度流畅。
8.3 抢占调度:retake()
和 preemptone()
抢占调度是 Go 调度器在运行时强制对 Goroutine 进行抢占的一种方式。抢占调度的主要目的是防止某些长时间运行的 Goroutine 占用过多 CPU 资源,影响其他 Goroutine 的执行。Go 调度器通过定期触发抢占机制,确保每个 Goroutine 都有公平的机会使用 CPU。
场景:
- 一个 Goroutine 运行时间过长,占用了大量 CPU 时间,影响了系统的整体响应。
- Go 运行时会定期触发抢占,主动挂起这些 Goroutine 并调度其他任务。
实现:
Go 调度器通过 retake()
和 preemptone()
函数实现抢占调度。retake()
函数主要用于定期检查是否有长时间占用 CPU 的 Goroutine,并调用 preemptone()
函数抢占它们。
// retake 函数检查是否有 Goroutine 长时间运行并触发抢占
func retake(now int64) {for i := 0; i < len(allp); i++ {pp := allp[i]if pp.status != _Prunning {continue}preemptone(pp)}
}// preemptone 函数实际执行抢占
func preemptone(pp *p) {gp := pp.runnext.ptr()if gp != nil {// 将该 Goroutine 的状态设置为 _Gpreempted,表示被抢占casgstatus(gp, _Grunning, _Gpreempted)}
}
retake()
:调度器定期调用retake()
来检查各个 P 上的 Goroutine 是否有长时间占用 CPU 的情况。preemptone()
:通过preemptone()
函数,将运行时间过长的 Goroutine 状态设置为_Gpreempted
,强制中断其执行,并重新调度其他任务。
优势:
- 防止 CPU 垄断:抢占调度机制有效防止单个 Goroutine 长时间占用 CPU,影响系统的整体性能。
- 公平性:确保所有 Goroutine 都有公平的机会获得 CPU 资源,避免因长时间任务导致系统延迟增加。
8.4 被动调度:park_m()
被动调度是指 Goroutine 在执行过程中遇到阻塞操作(如 I/O、锁、通道操作等)时自动进入等待状态,直到阻塞条件解除后才会继续执行。被动调度依赖于 park_m()
函数来挂起当前 Goroutine。
场景:
- Goroutine 在等待某些外部条件(如 I/O、锁)时会自动进入等待状态,不消耗 CPU 资源。
- 被动调度通常发生在 Goroutine 执行到阻塞点时,例如网络请求或文件 I/O 操作。
实现:
// park_m 函数将当前 Goroutine 挂起,等待被唤醒
func park_m(gp *g) {// 将当前 Goroutine 从 M 上移除gp.m.curg = nil// 将 Goroutine 状态切换为 _Gwaitingcasgstatus(gp, _Grunning, _Gwaiting)// 调用 schedule 进行调度,选择其他任务执行schedule()
}
park_m()
:当 Goroutine 遇到阻塞操作时,它会调用park_m()
函数,将自己挂起,状态切换为_Gwaiting
,等待外部条件满足后再唤醒。schedule()
:在 Goroutine 挂起后,调度器会继续调度其他可运行的任务。
优势:
- 节省资源:被动调度能够有效减少阻塞操作期间的 CPU 占用,使系统资源能够合理分配给其他任务。
- 自动唤醒:当等待条件满足时,调度器会自动将 Goroutine 恢复到可运行状态,确保其继续执行。
8.5 调度方式对比
调度方式 | 触发条件 | 状态变化 | 适用场景 | 优势 |
---|---|---|---|---|
主动调度 | Goroutine 主动调用 gosched() | _Grunning -> _Grunnable | Goroutine 主动让出 CPU | 提高灵活性,防止长时间占用 |
正常调度 | Goroutine 执行结束 | _Grunning -> _Gdead | Goroutine 正常完成任务 | 自动调度,释放资源 |
抢占调度 | Goroutine 长时间占用 CPU | _Grunning -> _Gpreempted | 系统性能受阻时,强制抢占 Goroutine | 防止 CPU 垄断,确保公平性 |
被动调度 | 遇到阻塞操作 | _Grunning -> _Gwaiting | I/O、锁、通道等待等阻塞操作 | 减少 CPU 消耗,自动调度唤醒 |
通过上述四种调度方式,Go 调度器能够灵活、高效地管理 Goroutine 的执行,确保系统在高并发情况下仍然保持良好的性能和响应能力。每种调度方式都有其特定的应用场景,通过结合使用,调度器能够在不同负载下合理分配资源。
9. 总结与回顾
在本次关于 Golang GMP 模型和调度器 的深入解析中,我们详细探讨了 Go 调度器的核心原理和源码实现,帮助理解 Go 语言并发的高效性。通过对 G、M、P 模型的解析,以及调度核心函数的深入分析,我们揭示了 Go 调度器如何通过精细的管理和优化,确保 Goroutine 在多核环境中能够高效并发执行。
9.1 GMP 模型的优势
GMP 模型是 Go 并发编程的核心,通过将 Goroutine 与操作系统线程分离,Go 调度器能够以非常低的开销实现并发任务的高效调度。每个组件在模型中的职责各不相同:
- Goroutine (G):轻量级的并发执行单元,由调度器管理并分配到 M 执行。
- Machine (M):操作系统级的线程,实际负责执行 Goroutine。
- Processor §:虚拟处理器,管理本地的任务队列,协调 G 和 M 的分配。
这种分工明确的设计使得 Go 可以在多核环境下通过 工作窃取、本地队列优先 等机制,实现高度的并发性能和任务负载均衡。
9.2 核心调度机制的解析
在调度过程中,Goroutine 的状态会经历多个转换,调度器会根据 Goroutine 的当前状态决定何时调度、挂起或销毁 Goroutine。我们详细分析了调度过程中 Goroutine 的几种状态:
- _Grunnable:可运行状态,表示 Goroutine 已经准备好可以被调度执行。
- _Grunning:运行状态,Goroutine 正在 M 上执行。
- _Gwaiting:等待状态,Goroutine 正在等待某些外部条件(如 I/O、锁等)。
- _Gpreempted:被抢占状态,Goroutine 因长时间占用 CPU 资源被强制挂起。
- _Gdead:结束状态,Goroutine 执行完成并终止。
通过 casgstatus()
函数,Go 调度器实现了 Goroutine 状态的原子性切换,从而保证调度过程中的线程安全和高效性。
9.3 主动、正常、抢占、被动调度的高效协作
Go 调度器提供了四种主要的调度方式,每种调度方式针对不同场景进行优化:
- 主动调度:Goroutine 通过
gosched()
主动让出 CPU,进入调度状态,调度器选择其他任务执行。 - 正常调度:Goroutine 正常执行结束后,通过
goexit()
释放资源并结束生命周期,调度器自动选择下一个任务。 - 抢占调度:调度器通过
retake()
和preemptone()
函数强制抢占长时间运行的 Goroutine,确保系统的公平性和 CPU 利用率。 - 被动调度:当 Goroutine 遇到阻塞操作时,通过
park_m()
进入等待状态,调度器选择其他任务执行。
这些调度机制确保了 Go 的并发任务能够在各种复杂场景下高效执行,并避免了 CPU 垄断等问题。
9.4 调度核心函数的深入理解
我们深入解析了 Go 调度器的几个核心函数,如 schedule()
、findRunnable()
和 execute()
,了解了它们如何协同工作来管理 Goroutine 的生命周期。
schedule()
:调度循环的核心,负责不断从任务队列中查找可运行的 Goroutine,并将其分配给 M 执行。findRunnable()
:多层次任务查找机制,通过本地队列、全局队列、网络轮询和工作窃取,确保 M 始终有任务可执行。execute()
:负责执行找到的 Goroutine,将其状态切换为运行中,并切换到用户栈执行具体任务。
这些函数共同构成了 Go 调度器的高效调度机制,确保在多核系统上充分利用 CPU 资源,实现高效的并发处理。
9.5 Go 调度器的设计理念与性能优化
Go 的调度器通过多种性能优化策略,确保在处理大量并发任务时能够高效运行:
- 本地队列优先调度:通过优先使用 P 的本地队列,减少全局队列和锁竞争的开销,提高任务调度的灵活性。
- 工作窃取机制:当某个 P 的任务不足时,调度器会从其他 P 的本地队列中窃取任务,确保任务在多个 M 上均衡分配。
- 抢占式调度:防止某些 Goroutine 长时间占用 CPU,调度器会定期强制抢占,确保每个 Goroutine 都能公平地使用资源。
- 栈动态扩展:通过
_Gcopystack
状态,Go 调度器能够动态调整 Goroutine 的栈大小,避免资源浪费,提高内存利用率。
这些优化设计使得 Go 的并发模型具备了极高的扩展性和稳定性,特别适用于高并发、大规模任务的处理。
9.6 总结
Go 调度器是 Go 语言高效并发处理能力的核心,其设计充分考虑了并发任务的高效管理、任务负载均衡以及 CPU 资源的充分利用。通过 GMP 模型,Go 将 Goroutine、M 和 P 三者紧密结合,使得并发任务在多核系统中能够高效、灵活地调度和执行。调度器的抢占式调度和工作窃取机制等设计确保了系统的公平性和任务分配的合理性。
通过对源码的深入剖析,我们可以看到 Go 调度器在面对复杂的并发场景时的高效性和灵活性,使得它成为开发高并发、高性能应用程序的理想选择。