目录
一.Golang调度器由来
存在问题:
3种协程和线程的关系
二.Golang对协程的处理
协程和goroutine关系
Go的GMP调度模型
P 和 M 何时会被创建
P和M的个数
调度器的设计策略
(一)复用线程
(二)利用并行
(三)抢占
(四)全局G队列
go func()调度流程
调度器的生命周期
三.Go 调度器调度场景过程全解析
场景1.G1创建G2
场景2.G1执行完毕
场景3.G2开辟过多的G
场景4.G2本地满再创建G7
场景5.G2本地未满创建G8
场景6.唤醒正在休眠的M
场景7.被唤醒的M2从全局队列取批量G
场景8.M2从M1偷取G
场景9.自旋线程的最大限制
场景10.G发生系统调用/阻塞
场景11.G发生系统调用/非阻塞
多线程/多进程操作系统
并发执行进程/线程时
- 进程/线程数量越多,切换成本就越大
- 多线程伴随着同步竞争(锁、资源冲突等)
存在问题:
1.高内存占用
2.调度的高消耗CPU(上下文切换)
后来人们发现一个线程可被分为用户态和内核态线程
用户态线程对CPU透明
一个用户态线程绑定内核态线程(Linux 的 PCB 进程控制块),这样用户态任务可以切换,而CPU执行的线程不变,减少了切换线程带来的开销
约定:
内核线程 = 线程
用户线程 = 协程
线程由CPU调度,是抢占式的
协程由用户态调度,是协作式的(一个协程让出CPU后,才执行下一个协程)
goroutine由runtime(go协程调度器)进行管理,而不是操作系统
一个/多个协程可绑定在一个/多个线程上
3种协程和线程的关系
1. N:1关系——N个协程绑定1个线程
优点:
协程在用户态线程即完成切换,不会陷入到内核态,切换轻量快速
缺点:
一个进程的所有协程都绑定在一个线程上,一旦某个协程阻塞,协程调度器没有切换协程的能力时会造成线程阻塞,本进程的其他协程都无法执行了,失去并发能力
2. 1:1关系——1个协程绑定1个线程
优点:
实现简单,协程的调度由CPU完成,不存在N:1的缺点
缺点:
协程的创建、删除和切换的代价都由CPU完成,略显昂贵
3. M:N关系——M个协程绑定1个线程
优点:
克服了前两种的缺点
缺点:
实现起来最复杂
协程和goroutine关系
Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。
goroutine
来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度
Go的GMP调度模型
GMP是Go运行时调度层面的实现,负责在适当实际将合适的协程分配到合适的位置,保证公平和效率
包含4个重要结构,分别是G、M、P、Sched
全局队列
存放等待运行的G
P的本地队列
存放等待运行的G
数量限制,不能超过256G
优先将新创建的G放在P的本地队列中
G 协程
是Goroutine的缩写
相当于操作系统的进程控制块(process control block)。
它包含:函数执行的指令和参数,任务对象,线程上下文切换,字段保护,和字段的寄存器。
M 线程
每个M都有一个线程的栈。如果没有给线程的栈分配内存,操作系统会给线程的栈分配默认的内存。当线程的栈制定,M.stack->G.stack, M的PC寄存器会执行G提供的函数。
P (处理器,Processor)
包含运行goroutine的资源
如果线程想运行goroutine,必须先获取P。P中还包含了可运行的G队列。
优先将新创建的G放在P的本地队列中,如果满了会放在全局队列中
P 和 M 何时会被创建
P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。
M 何时创建:
没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。
P和M的个数
P的个数——在同一时刻的P个数,而不是宏观的并发
程序启动时创建
最多有GOMAXPROCS个
配置方法:
1.环境变量$GOMAXPROCS个(可配置)
2.在程序中通过runtime.GOMAXPROCS()来设置
M的个数——动态数量
动态的,一旦有一个M阻塞,就会创建一个新的M
如果有M空闲,就会回收/睡眠
Go语言本身,限定了M的最大量是10000(忽略)
通过runtime/debug包中的SetMaxThreads函数来设置
调度器的设计策略
(一)复用线程
避免频繁的创建、销毁线程,而是对线程的复用
1.work stealing机制
当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程
2.hand off机制
当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行
(二)利用并行
eg.GOMAXPROCS限定P的个数,最多有GOMAXPROCS个线程分布在多个CPU上同时运行
(三)抢占
之前:coroutine,要等待另一个协程主动释放CPU才执行下一个协程
现在:一个goroutine最多占用CPU10ms时间片轮询),防止其他goroutine被饿死(抢占)
(四)全局G队列
基于work stealing机制的补充,当M从其他P偷不到G时,他可以从全局G队列中获取G
go func()调度流程
go func()
1.创建一个G
2.1G优先放到当前线程持有的P的本地队列中;
2.2如果已经满了,则放入全局队列中
3.1M通过P获取G;(一个M必须持有一个P——1:1)
3.2如果M的本地队列为空,从全局队列获取G;
3.3(work stealing机制)如果也为空,则从其他的MP组合偷取G
4.调度
5.执行func()函数
5.1 超出时间片后返回P的本地队列
5.2 若G.func()发生systemCall/阻塞
5.2.1 runtime(即调度器)会把这个M从P中摘除,(hand off机制)创建一个M或从休眠队列中取一个空闲的M,接管正在被阻塞中的P
5.2.2 M系统调用(阻塞)结束时,G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入全局队列中
6.销毁G
7.返回
调度器的生命周期
M0和G0
M0(进程唯一)
启动程序后的编号为0的主线程
在全局变量runtime.m0中,不需要在heap上分配
负责执行初始化操作和启动第一个G
启动第一个G后,M0就和其他的一样了
G0(线程唯一)
每启动一个M,都会第一个创建G0,每个M都会有一个自己的G0
G0不指向任何可执行的函数
G0仅用于负责调度G,在调度/系统调用时,M会切换到G0,调度其他G
M0的G0会放在全局空间
package main import "fmt" func main() { fmt.Println("Hello world") }
1. runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
2. 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
3. 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ——runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
4. 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
5. G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
6. M 运行 G
7.G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。
调度器的生命周期几乎占满了一个 Go 程序的一生,runtime.main 的 goroutine 执行之前都是为调度器做准备工作,runtime.main 的 goroutine 运行,才是调度器的真正开始,直到 runtime.main 结束而结束。1
1
1
场景1.G1创建G2
P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用
go func()
创建了 G2局部性:G2 优先加入到 P1 的本地队列
场景2.G1执行完毕
G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换(函数:schedule)。从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。
场景3.G2开辟过多的G
场景4.G2本地满再创建G7
本地队列的前一半转移到全局队列(防止饥饿)
这些 G 被转移到全局队列时,会被打乱顺序。所以 G3,G4,G7 被转移到全局队列。
场景5.G2本地未满创建G8
G8 加入到 P1 点本地队列的原因还是因为 P1 此时在与 M1 绑定,而 G2 此时是 M1 在执行。所以 G2 创建的新的 G 会优先放置到自己的 M 绑定的 P 上。
场景6.唤醒正在休眠的M
在创建 G 时,运行的 G 会尝试唤醒其他空闲的 P 和 M 组合去执行。
假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)。
场景7.被唤醒的M2从全局队列取批量G
自旋线程M2,优先从全局队列获取一批G放到P2的本地队列(函数:findrunnable()).M2从全局队列取的G数量符合下面的公式
n=min(len(GQ)/GOMAXPROCS+1,len(GQ/2))
场景8.M2从M1偷取G
取后半部分
场景9.自旋线程的最大限制
自旋线程+执行线程 ≤ GOMAXPROCS
超过的线程将加入休眠线程队列
场景10.G发生系统调用/阻塞
假定当前除了 M3 和 M4 为自旋线程,还有 M5 和 M6 为空闲的线程 (没有得到 P 的绑定,注意我们这里最多就只能够存在 4 个 P,所以 P 的数量应该永远是 M>=P, 大部分都是 M 在抢占需要运行的 P),G8 创建了 G9,G8 进行了阻塞的系统调用,M2 和 P2 立即解绑,P2 会执行以下判断:如果 P2 本地队列有 G、全局队列有 G 或有空闲的 M,P2 都会立马唤醒 1 个 M 和它绑定,否则 P2 则会加入到空闲 P 列表,等待 M 来获取可用的 p。本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。
场景11.G发生系统调用/非阻塞
G8由阻塞变为非阻塞
M2 和 P2 会解绑,但 M2 会记住 P2,然后 G8 和 M2 进入系统调用状态。当 G8 和 M2 退出系统调用时,会尝试获取 P2,如果无法获取,则获取空闲的 P,如果依然没有,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态 (长时间休眠等待 GC 回收销毁)。
参考 [Golang三关-典藏版] Golang 调度器 GMP 原理与调度全分析 | Go 技术论坛 (learnku.com)