Goroutine = Golang + Coroutine
- 启动代价小:以很小栈空间启动(2Kb左右),线程栈大小默认为8M
- 栈可动态伸缩:最大可支持GB级别
- 用户态切换成本低
- 与线程n:m:可以在n个系统线程上多工调度m个Goroutine
GPM模型:对Goroutine分配、负载、调度
G:Goroutine
type g struct {
stack stack // g自己的栈
m *m // 隶属于哪个M
sched gobuf // 保存了g的现场,goroutine切换时通过它来恢复
atomicstatus uint32 // G的运行状态
goid int64
schedlink guintptr // 下一个g, g链表
preempt bool //抢占标记
lockedm muintptr // 锁定的M,g中断恢复指定M执行
gopc uintptr // 创建该goroutine的指令地址
startpc uintptr // goroutine 函数的指令地址
}
状态 | 值 | 含义 |
_Gidle | 0 | 刚刚被分配,还没有进行初始化。 |
_Grunnable | 1 | 已经在运行队列中,还没有执行用户代码。 |
_Grunning | 2 | 不在运行队列里中,已经可以执行用户代码,此时已经分配了 M 和 P。 |
_Gsyscall | 3 | 正在执行系统调用,此时分配了 M。 |
_Gwaiting | 4 | 在运行时被阻止,没有执行用户代码,也不在运行队列中,此时它正在某处阻塞等待中。Groutine wait的原因有哪些。参见代码 |
_Gmoribund_unused | 5 | 尚未使用,但是在 gdb 中进行了硬编码。 |
_Gdead | 6 | 尚未使用,这个状态可能是刚退出或是刚被初始化,此时它并没有执行用户代码,有可能有也有可能没有分配堆栈。 |
_Genqueue_unused | 7 | 尚未使用。 |
_Gcopystack | 8 | 正在复制堆栈,并没有执行用户代码,也不在运行队列中。 |
M:Machine
P:Processor
逻辑处理器,P关联了的本地可运行G的队列(也称为LRQ),最多可存放256个G。
type p struct {
id int32
status uint32 // P的状态
link puintptr // 下一个P, P链表
m muintptr // 拥有这个P的M
mcache *mcache
// P本地runnable状态的G队列,无锁访问
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr // 一个比runq优先级更高的runnable G
// 状态为dead的G链表,在获取G时会从这里面获取
gFree struct {
gList
n int32
}
gcBgMarkWorker guintptr // (atomic)
gcw gcWork
}
状态 | 值 | 含义 |
_Pidle | 0 | 刚刚被分配,还没有进行进行初始化。 |
_Prunning | 1 | 当 M 与 P 绑定调用 acquirep 时,P 的状态会改变为 _Prunning。 |
_Psyscall | 2 | 正在执行系统调用。 |
_Pgcstop | 3 | 暂停运行,此时系统正在进行 GC,直至 GC 结束后才会转变到下一个状态阶段。 |
_Pdead | 4 | 废弃,不再使用。 |
GMP调度流程大致如下:
- 线程M想运行任务就需得获取 P,即与P关联。
- 然从 P 的本地队列(LRQ)获取 G
- 若LRQ中没有可运行的G,M 会尝试从全局队列(GRQ)拿一批G放到P的本地队列,
- 若全局队列也未找到可运行的G时候,M会随机从其他 P 的本地队列偷一半放到自己 P 的本地队列。
- 拿到可运行的G之后,M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
G-M-P的数量
- G 的数量:理论上没有数量上限限制的。查看当前G的数量可以使用
runtime. NumGoroutine()
- P 的数量:由启动时环境变量
$GOMAXPROCS
或者是由runtime.GOMAXPROCS()
决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。 - M 的数量:
- go 语言本身的限制,go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
- runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
- 一个 M 阻塞了,会创建新的 M。
M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。
P和M何时会被创建
- P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。
- M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。
调度的流程状态
从上图我们可以看出来:
- 每个P有个局部队列,局部队列保存待执行的goroutine(流程2),当M绑定的P的的局部队列已经满了之后就会把goroutine放到全局队列(流程2-1)
- 每个P和一个M绑定,M是真正的执行P中goroutine的实体(流程3),M从绑定的P中的局部队列获取G来执行
- 当M绑定的P的局部队列为空时,M会从全局队列获取到本地队列来执行G(流程3.1),当从全局队列中没有获取到可执行的G时候,M会从其他P的局部队列中偷取G来执行(流程3.2),这种从其他P偷的方式称为work stealing
- 当G因系统调用(syscall)阻塞时会阻塞M,此时P会和M解绑即hand off,并寻找新的idle的M,若没有idle的M就会新建一个M(流程5.1)。
- 当G因channel或者network I/O阻塞时,不会阻塞M,M会寻找其他runnable的G;当阻塞的G恢复后会重新进入runnable进入P队列等待执行(流程5.3)
用户态阻塞
当goroutine因为channel操作或者network I/O而阻塞时(golang已经用netpoller实现了goroutine网络I/O阻塞不会导致M被阻塞,仅阻塞G),对应的G会被放置到某个wait队列,该G的状态由_Grunning变成_Gwaitting,而M会跳过该G尝试获取并执行下一个G,如果此时没有runnable的G供M运行,那么M将解绑P,并进入sleep状态;当阻塞的G被另一端的G2唤醒时(比如channel的可读/写通知),G被标记为runnable,尝试加入G2所在P的runnext,然后再是P的Local队列和Global队列。
系统调用阻塞
当G被阻塞在某个系统调用上时,此时G会阻塞在_Gsyscall状态,M也处于 block on syscall 状态,此时的M可被抢占调度:
- 执行该G的M会与P解绑,而P则尝试与其它idle的M绑定,继续执行其它G。
- 如果没有其它idle的M,但P的Local队列中仍然有G需要执行,则创建一个新的M;
- 当系统调用完成后,G会重新尝试获取一个idle的P进入它的Local队列恢复执行,如果没有idle的P,G会被标记为runnable加入到Global队列。
调度器的设计策略
复用线程:避免频繁的创建、销毁线程,而是对线程的复用;
- work stealing机制
- hand off机制
当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。
当本线程因为G进行系统调用阻塞时,释放绑定的P,把P转移给其他空闲的线程执行。当本线程阻塞结束时,会尝试获取空闲P,并将G放入P的本地队列,如果获取不到P,M会变成休眠状态,加入到空闲线程中,G会放入全局队列中
利用并行:GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。
抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。
全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。
抢占式调度
从一个bug说起
Go在设计之初并没考虑将goroutine设计成抢占式的。用户负责让各个goroutine交互合作完成任务。一个goroutine只有在涉及到加锁,读写通道或者主动让出CPU等操作时才会触发切换。
垃圾回收器是需要stop the world的。如果垃圾回收器想要运行了,那么它必须先通知其它的goroutine合作停下来,这会造成较长时间的等待时间。考虑一种很极端的情况,所有的goroutine都停下来了,只有其中一个没有停,那么垃圾回收就会一直等待着没有停的那一个。
抢占式调度可以解决这种问题,在抢占式情况下,如果一个goroutine运行时间过长,它就会被剥夺运行权。
总体思路
引入抢占式调度,会对最初的设计产生比较大的影响,Go还只是引入了一些很初级的抢占,并没有像操作系统调度那么复杂,没有对goroutine分时间片,设置优先级等。
只有长时间阻塞于系统调用,或者运行了较长时间才会被抢占。runtime会在后台有一个检测线程,它会检测这些情况,并通知goroutine执行调度。
目前并没有直接在后台的检测线程中做处理调度器相关逻辑,只是相当于给goroutine加了一个“标记”,然后在它进入函数时才会触发调度。这么做应该是出于对现有代码的修改最小的考虑。
sysmon
前面讲Go程序的初始化过程中有提到过,runtime开了一条后台线程,运行一个sysmon函数。这个函数会周期性地做epoll操作,同时它还会检测每个P是否运行了较长时间。
如果检测到某个P状态处于Psyscall超过了一个sysmon的时间周期(20us),并且还有其它可运行的任务,则切换P。
如果检测到某个P的状态为Prunning,并且它已经运行了超过10ms,则会将P的当前的G的stackguard设置为StackPreempt。这个操作其实是相当于加上一个标记,通知这个G在合适时机进行调度。
目前这里只是尽最大努力送达,但并不保证收到消息的goroutine一定会执行调度让出运行权。
morestack的修改
前面说的,将stackguard设置为StackPreempt实际上是一个比较trick的代码。我们知道Go会在每个函数入口处比较当前的栈寄存器值和stackguard值来决定是否触发morestack函数。
将stackguard设置为StackPreempt作用是进入函数时必定触发morestack,然后在morestack中再引发调度。
看一下StackPreempt的定义,它是大于任何实际的栈寄存器的值的:
// 0xfffffade in hex.
#define StackPreempt ((uint64)-1314)
然后在morestack中加了一小段代码,如果发现stackguard为StackPreempt,则相当于调用runtime.Gosched。
所以,到目前为止Go的抢占式调度还是很初级的,比如一个goroutine运行了很久,但是它并没有调用另一个函数,则它不会被抢占。当然,一个运行很久却不调用函数的代码并不是多数情况。
异步抢占
Go 1.13及以前的版本的抢占是”协作式“的,只在有函数调用的地方才能插入“抢占”代码(埋点),而deadloop没有给编译器插入抢占代码的机会。这会导致GC在等待所有goroutine停止时等待时间过长,从而导致GC延迟;甚至在一些特殊情况下,导致在STW(stop the world)时死锁。
Go 1.14采用了基于系统信号的异步抢占调度,由于系统信号可能在代码执行到任意地方发生,在Go runtime能cover到的地方,Go runtime自然会处理好这些系统信号。
但是如果你是通过syscall
包或golang.org/x/sys/unix
在Unix/Linux/Mac上直接进行系统调用,那么一旦在系统调用执行过程中进程收到系统中断信号,这些系统调用就会失败,并以EINTR错误返回,尤其是低速系统调用,包括:读写特定类型文件(管道、终端设备、网络设备)、进程间通信等。
在这样的情况下,我们就需要自己处理EINTR错误。一个最常见的错误处理方式就是重试。对于可重入的系统调用来说,在收到EINTR信号后的重试是安全的。如果你没有自己调用syscall包,那么异步抢占调度对你已有的代码几乎无影响。
Go 1.14的异步抢占调度在windows/arm, darwin/arm, js/wasm, and plan9/*上依然尚未支持,Go团队计划在Go 1.15中解决掉这些问题。