1 调度模型
Linux操作系统中的资源调度是基于进程的,同一进程中的线程共享这个进程中的所有资源,所以linux中的线程本质上是一种轻量级进程,同样被操作系统进行统一调度。而linux又将线程的实现分为两种:
用户级线程
线程切换不需要转换到内核空间,节省了宝贵的内核空间
调度算法可以是进程专用,由用户程序进行指定
用户级线程实现和操作系统无关
内核级线程
在多处理器上,内核可以调用同一进程中的多个线程同时工作
如果一个进程中的一个线程阻塞了,其他线程仍然可以得到运行
线程的切换代价太大,需要进程进入到内核态并且由内核切换
1.1 一对一
该模型实现简单,所有用户线程由系统调用,导致上下文切换成本高,用户线程的增加会给操作系统内核带来巨大压力
1.2 一对多
该模型虽然减少了内核线程的数量,但是用户线程无法参与到系统的CPU调度中,且与固定的内核线程绑定,对于用一个内核线程下的用户线程等于是串行,一旦一个用户线程阻塞,其他用户线程会无法调度。
1.3 多对多
改模型中用户线程和内核线程非绑定,应用程序和系统共同进行CPU资源的调度,解决了之前模型的缺点,但是实现逻辑复杂。Golang使用的就是基于该模型的调度方案
2 GPM模型
(G)oroutine
每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。
(P)rocessor
表示逻辑处理器, 对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P 的数量由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。
(M)achine
OS 线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。
调度流程
每个 P 维护一个 G 的本地队列;
当一个 G 被创建出来,或者变为可执行状态时,就把他放到 P 的本地可执行队列中,如果满了则放入Global;
当一个 G 在 M 里执行结束后,P 会从队列中把该 G 取出;如果此时 P 的队列为空,即没有其他 G 可以执行, M 就随机选择另外一个 P,从其可执行的 G 队列中取走一半(work stealing)。
为了避免G中出现系统调用、阻塞操作导致P&M被阻塞,Go中还引入了抢占式调度机制,每个函数的入口方法出都会注入一段代码,用于判断runtime此时是否可以进行抢占式调度,判定可以抢占后会由空闲M去抢占。
2.2 Goroutine
goroutines 意味着并行(或者可以以并行的方式部署),coroutines 一般来说不是这样的,期运行机制属于协作式任务处理,coroutines在不需要使用 CPU 时,会主动交出 CPU 使用权,主要以并发方式部署。
goroutine用法如下:
//go 关键字放在方法调用前新建一个 goroutine 并执行方法体
go GetThingDone(param1, param2);
//新建一个匿名方法并执行
go func(param1, param2) {
}(val1, val2)
//直接新建一个 goroutine 并在 goroutine 中执行代码块
go {
//do someting...
}
代码中的go关键字会被编译器翻译成newproc操作
G的创建
G结构体会复用,对可复用的G管理类似于待运行的G管理,也有Local队列(p.gfree)和Global队列(sched.gfree)之分,获取算法差不多,优先从p.gfree中获取(无锁操作),否则从sched.gfree中获取并批量转移一部分(有锁操作),源代码参考src/runtime/proc.go:gfget
函数。
从Goroutine的角度来看,通过go func()
创建时,会从当前闲置的G队列取得可复用的G,如果没有则通过malg新建一个G,然后:
如果不是gc协程或者从全局队列挪进来的G,都会将G添加到当前P的runnext中,作为下一个执行的G,然后runnext的旧值会被添加到runq队列中去
否则放到Local队列runq中(无锁)
如果以上操作都失败,则添加到Global队列sched.runq中(有锁操作,因此也会顺便将当P.runq中一半的G转移到sched.runq)
2.3 Processor
为什么要有P?
如果没有P:
- 存在单一的全局 mutex(Sched.Lock)和集中状态管理:
** mutex 需要保护所有与 goroutine 相关的操作(创建、完成、重排等),导致锁竞争严重。
- Goroutine 传递的问题:
-- goroutine(G)交接(G.nextg):工作者线程(M's)之间会经常交接可运行的 goroutine。
-- 上述可能会导致延迟增加和额外的开销。每个 M 必须能够执行任何可运行的 G,特别是刚刚创建 G 的 M。
- 每个 M 都需要做内存缓存(M.mcache):
-- 会导致资源消耗过大(每个 mcache 可以吸纳到 2M 的内存缓存和其他缓存),数据局部性差。
- 频繁的线程阻塞/解阻塞:
-- 在存在 syscalls 的情况下,线程经常被阻塞和解阻塞。这增加了很多额外的性能开销。
有P之后:
每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
每个 P 相对的平衡上,在 GMP 模型中也实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。
为什么不在M上实现
一般来讲,M 的数量都会多于 P。像在 Go 中,M 的数量默认是 10000,P 的默认数量的 CPU 核数。另外由于 M 的属性,也就是如果存在系统阻塞调用,阻塞了 M,又不够用的情况下,M 会不断增加。
M 不断增加的话,如果本地队列挂载在 M 上,那就意味着本地队列也会随之增加。这显然是不合理的,因为本地队列的管理会变得复杂,且 Work Stealing 性能会大幅度下降。
M 被系统调用阻塞后,我们是期望把他既有未执行的任务分配给其他继续运行的,而不是一阻塞就导致全部停止。
golang中的三种系统线程
主线程:golang程序启动加载的时候就运行在主线程上,代码中由一个全局的m0表示
运行sysmon的线程
普通用户线程,用来与p绑定,运行g中的任务的线程
主线程和运行sysmon都是单实例,单独一个线程。而用户线程会有很多事例,他会根据调度器的需求新建,休眠和唤醒。
2.4 Machine
M和G、P一样都是复用的,一旦创建,就不会被销毁,M的状态转化是最简单,除了M0之外,都是通过newm()函数创建,然后通过mstart,进入调度循环,之后可以通过stopm进入阻塞状态,再通过startm唤醒,状态图如下:
为什么M数量会比P多?
因为M是golang中真正被操作系统管理并执行逻辑的单元,G和P都是golang内部的抽象概念
M承接了golang中所有的调度逻辑和协程逻辑,如果一个M由于syscall或其他原因被阻塞,这时golang就需要启动一个新的M去继续进行其他G的调度,最坏的情况下每个G都会阻塞掉一个P。
runtime包提供了LockOSThread操作,会使得每个G与M一对一绑定
P实质上映射的是计算机的物理CPU,G实质上是M在golang中的映射
2.5 启动
通过 Entry point 的调试,可看到真正的程序入口在 runtime 包中,不同的计算机架构指向不同。例如:
MacOS 在 src/runtime/rt0_darwin_amd64.s
Linux 在 src/runtime/rt0_linux_amd64.s
rt0 代表 runtime0 的缩写,指代运行时的创世Runtime
所有的goroutine的调用函数都是goexit,这样当其执行结束时,可以返回到goexit中,继续调度循环,goexit的地址保存在gobuf.pc中,g0的栈是在主线程上分配的
2.6 调度
mstart中绑定好P之后,M拥有了可分配cache和执行队列,进入核心调度循环,核心调度从schedule函数开始,调度完一次之后会引导重新执行schedule,实现循环调度:
schedule方法的主要功能是尽可能给M找到可以运行的G,然后执行:
获取当前调度的g,也就是g0,g0在执行调度逻辑;
如果当前GC需要停止整个世界(STW), 则调用gcstopm休眠当前的M;
如果M拥有的P中指定了需要在安全点运行的函数(P.runSafePointFn), 则运行它;
快速获取待运行的G, 以下处理如果有一个获取成功后面就不会继续获取:
-- 如果当前GC正在标记阶段, 则查找有没有待运行的GC Worker, GC Worker也是一个G;
-- 为了公平起见, 每61次调度从全局运行队列获取一次G, (一直从本地获取可能导致全局运行队列中的G不被运行);
-- 从P的本地运行队列中获取G, 调用runqget函数。
- 快速获取失败时, 调用findrunnable函数获取待运行的G, 会阻塞到获取成功为止:
-- 如果当前GC需要停止整个世界(STW), 则调用stopm休眠当前的M;
-- 如果M拥有的P中指定了需要在安全点运行的函数(P.runSafePointFn), 则运行它;
-- 如果有析构器待运行则使用"运行析构器的G";
-- 从P的本地运行队列中获取G, 调用runqget函数,如果获取到就返回;
-- 从全局运行队列获取G, 调用globrunqget函数, 需要上锁,获取到就返回。;
-- 从网络事件反应器获取G, 函数netpoll会获取哪些fd可读可写或已关闭, 然后返回等待fd相关事件的G;
-- 如果从local 和 global 都获取不到G, 则执行Work Stealing:
--- 调用runqsteal尝试从其他P的本地运行队列盗取一半的G。
-- 如果还是获取不到G, 就需要休眠M了, 接下来是休眠的步骤:
--- 再次检查当前GC是否在标记阶段, 在则查找有没有待运行的GC Worker, GC Worker也是一个G;
--- 再次检查如果当前GC需要停止整个世界, 或者P指定了需要再安全点运行的函数, 则跳到findrunnable的顶部重试;
--- 再次检查全局运行队列中是否有G, 有则获取并返回;
--- 释放M拥有的P, P会变为空闲(_Pidle)状态;
--- 把P添加到"空闲P链表"中;
--- 让M离开自旋状态, 这里的处理非常重要, 参考上面的"空闲M链表";
--- 首先减少表示当前自旋中的M的数量的全局变量nmspinning;
--- 再次检查所有P的本地运行队列, 如果不为空则让M重新进入自旋状态, 并跳findrunnable的顶部重试;
--- 再次检查有没有待运行的GC Worker, 有则让M重新进入自旋状态, 并跳到findrunnable的顶部重试
--- 再次检查网络事件反应器是否有待运行的G, 这里对netpoll的调用会阻塞, 直到某个fd收到了事件;
--- 如果最终还是获取不到G, 调用stopm休眠当前的M;
--- 唤醒后跳到findrunnable的顶部重试。
成功获取到一个待运行的G;
让M离开自旋状态, 调用resetspinning, 这里的处理和上面的不一样:
-- 如果当前有空闲的P, 但是无自旋的M(nmspinning等于0), 则唤醒或新建一个M;
-- 上面离开自旋状态是为了休眠M, 所以会再次检查所有队列然后休眠;
-- 这里离开自选状态是为了执行G, 所以会检查是否有空闲的P, 有则表示可以再开新的M执行G。
- 如果G要求回到指定的M(例如上面的runtime.main):
-- 调用startlockedm函数把G和P交给该M, 自己进入休眠;
-- 从休眠唤醒后跳到schedule的顶部重试
- 调用execute函数在当前M上执行G。
在schedule方法中,m在以下情况会休眠:
当m.lockedg != 0(m有绑定固定执行的g),m会在stoplockedm()解绑p并休眠,等待被绑定的g被其他m调度的时候来唤醒该m,直接被绑定的g
sched.gcwaiting != 0(gc STW)m会休眠
findrunnable()中想尽一切办法都没有获取到可执行的g的时候,m会休眠
获取到g的时候,g绑定了其他的m(gp.lockedm != 0),当前m会解绑p,休眠,然后唤醒g绑定的m,执行该g
当m休眠被唤醒的时候,并不会从固定的位置开始执行,会直接从休眠的位置开始执行。
2.6.1 findrunnable
这里逻辑较为复杂,下面将以代码中的两个标签top和stop将流程分开:
2.6.2 sysmon
系统调用和抢占调度都离不开这个函数,sysmon独立的运行在一个特殊的m上,它定期执行一次,每次会做以下事情:
释放闲置超过5分钟的span物理内存
如果超过2分钟没有垃圾回收,强制执行
将长时间未处理的netpoll结果添加到任务队列
向长时间运行的G任务发出抢占调度
收回因syscall长时间阻塞的P
2.6.3 抢占式调度
在Go1.3版本以前,调度器还不支持抢占式调度,只能依靠 goroutine 主动让出 CPU 资源,存在非常严重的调度问题:
单独的 goroutine 可以一直占用线程运行,不会切换到其他的 goroutine,造成饥饿问题
垃圾回收需要暂停整个程序(Stop-the-world,STW),如果没有抢占可能需要等待几分钟的时间,导致整个程序无法工作
在Go1.3版本中,当某个goroutine执行超过10ms,sysmon会向其发起抢占调度请求,由于Go调度不像OS调度那样有时间片的概念,因此实际抢占机制要弱很多: Go中的抢占实际上是为G设置抢占标记(g.stackguard0),当G调用某函数时(更确切说,在通过newstack分配函数栈时),被编译器安插的指令会检查这个标记,并且将当前G以runtime.Goched的方式暂停,并加入到全局队列。但是这种需要函数调用主动配合的调度方式存在一些边缘情况,就比如说下面的例子:
package main
import (
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
go func() {
for {
}
}()
time.Sleep(time.Millisecond)
println("OK")
}
其中创建一个goroutine并挂起, main goroutine 优先调用了 休眠,此时唯一的 P 会转去执行 for 循环所创建的 goroutine,进而 main goroutine 永远不会再被调度。换一句话说在Go1.14之前,上边的代码永远不会输出OK,因为这种协作式的抢占式调度是不会使一个没有主动放弃执行权、且不参与任何函数调用的goroutine被抢占。
Go1.14 实现了基于信号的真抢占式调度解决了上述问题。Go1.14程序启动时,在 runtime.sighandler 函数中注册了 SIGURG 信号的处理函数 runtime.doSigPreempt,在触发垃圾回收的栈扫描时,调用函数挂起goroutine,并向M发送信号,M收到信号后,会让当前goroutine陷入休眠继续执行其他的goroutine。
SIGURG表示I/O紧急信号,属于高优先级信号,在进程忙时仍然会被优先处理,默认在Unix系统进程中是被忽略的,因此被Golang用于处理抢占式调度。