一、由来
1、单进程:一个运行完执行下一个
- 串行
- 进程阻塞浪费CPU
2、多进程、多线程:一个进程阻塞CPU可以立刻切换到其他进程中进行,保证在运行的进程都可以被分到时间片
- 频繁创建销毁切换成本大
- CPU很大程度都被用来进行进程调度了
3、如何提高CPU利用率
- 高CPU占用:进程虚拟内存占用4GB(32位),线程大约4MB
- 调度的高消耗:切换成本
内核态线程(线程thread) & 用户态线程(协程co-routine)
4、goroutine
- 线程是由CPU调度是抢占式的,协程是由用户态调度协作式的,一个协程让出CPU(P)后才执行下一个协程。
- 在go语言中,协程被称作go-routine。只占几KB,支持伸缩。切换成本低。
二、GMP调度模型
1、概念
Golang的调度模型主要有几个主要的实体:G、M、P、schedt。
- G:代表一个goroutine实体,它有自己的栈内存,instruction pointer和一些相关信息(比如等待的channel等等),是用于调度器调度的实体。
- M:代表一个真正的内核OS线程,和POSIX里的thread差不多,属于真正执行指令的人。
- P:代表M调度的上下文,可以把它看做一个局部的调度器,代表M运行G所需要的资源,调度协程go代码在一个内核线程上跑。P是实现协程与内核线程的N:M映射关系的关键。P的上限是通过系统变量runtime.GOMAXPROCS (numLogicalProcessors)来控制的。golang启动时更新这个值,一般不建议修改这个值。P的数量也代表了golang代码执行的并发度,即有多少goroutine可以并行的运行。默认等于cpu核心数,但可以通过环境变量GOMAXPROC修改,在实际运行时P跟cpu核心并无任何关联。
- schedt:runtime全局调度时使用的数据结构,这个实体其实只是一个壳,里面主要有M的全局idle队列,P的全局idle队列,一个全局的就绪的G队列以及一个runtime全局调度器级别的锁。当对M或P等做一些非局部调度器的操作时,一般需要先锁住全局调度器。
为了解释清楚这几个实体之间的关系,我们先抽象G、M、P、schedt的关系,主要的workflow如下图所示:
- 通过 go func()来创建一个goroutine;
- 有两个存储goroutine的队列,一个是局部调度器P的local queue、一个是全局调度器数据模型schedt的global queue。新创建的goroutine会先保存在local queue,如果local queue已经满了就会保存在全局的global queue;
- goroutine只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的local queue弹出一个Runable状态的goroutine来执行,如果P的local queue为空,就会执行work stealing;
- 一个M调度goroutine执行的过程是一个loop;
- 当M执行某一个goroutine时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
- 当M系统调用结束时候,这个goroutine会尝试获取一个空闲的P执行,并放入到这个P的local queue。如果获取不到P,那么这个线程M会park它自己(休眠), 加入到空闲线程中,然后这个goroutine会被放入schedt的global queue。
2、GM模型
缺点:
- 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
- M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。
3、GMP模型
- 全局队列(Global Queue):存放等待运行的 G。
- P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
- P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有
GOMAXPROCS
(可配置) 个。 - M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。
G的状态
- 空闲中(_Gidle): 表示G刚刚新建, 仍未初始化
- 待运行(_Grunnable): 表示G在运行队列中, 等待M取出并运行
- 运行中(_Grunning): 表示M正在运行这个G, 这时候M会拥有一个P
- 系统调用中(_Gsyscall): 表示M正在运行这个G发起的系统调用, 这时候M并不拥有P
- 等待中(_Gwaiting): 表示G在等待某些条件完成, 这时候G不在运行也不在运行队列中(可能在channel的等待队列中)
- 已中止(_Gdead): 表示G未被使用, 可能已执行完毕(并在freelist中等待下次复用)
- 栈复制中(_Gcopystack): 表示G正在获取一个新的栈空间并把原来的内容复制过去(用于防止GC扫描)
M的状态
M并没有像G和P一样的状态标记, 但可以认为一个M有以下的状态:
- 自旋中(spinning): M正在从运行队列获取G, 这时候M会拥有一个P
- 执行go代码中: M正在执行go代码, 这时候M会拥有一个P
- 执行原生代码中: M正在执行原生代码或者阻塞的syscall, 这时M并不拥有P
- 休眠中: M发现无待运行的G时会进入休眠, 并添加到空闲M链表中, 这时M并不拥有P
自旋中(spinning)这个状态非常重要, 是否需要唤醒或者创建新的M取决于当前自旋中的M的数量
P的状态
- 空闲中(_Pidle): 当M发现无待运行的G时会进入休眠, 这时M拥有的P会变为空闲并加到空闲P链表中
- 运行中(_Prunning): 当M拥有了一个P后, 这个P的状态就会变为运行中, M运行G会使用这个P中的资源
- 系统调用中(_Psyscall): 当go调用原生代码, 原生代码又反过来调用go代码时, 使用的P会变为此状态
- GC停止中(_Pgcstop): 当gc停止了整个世界(STW)时, P会变为此状态
- 已中止(_Pdead): 当P的数量在运行时改变, 且数量减少时多余的P会变为此状态
空闲M链表(重点)
当M发现无待运行的G时会进入休眠, 并添加到空闲M链表中, 空闲M链表保存在全局变量sched
.
进入休眠的M会等待一个信号量(m.park), 唤醒休眠的M会使用这个信号量.
go需要保证有足够的M可以运行G, 是通过这样的机制实现的:
- 入队待运行的G后, 如果当前无自旋的M但是有空闲的P, 就唤醒或者新建一个M
- 当M离开自旋状态并准备运行出队的G时, 如果当前无自旋的M但是有空闲的P, 就唤醒或者新建一个M
- 当M离开自旋状态并准备休眠时, 会在离开自旋状态后再次检查所有运行队列, 如果有待运行的G则重新进入自旋状态
因为"入队待运行的G"和"M离开自旋状态"会同时进行, go会使用这样的检查顺序:
- 入队待运行的G => 内存屏障 => 检查当前自旋的M数量 => 唤醒或者新建一个M
- 减少当前自旋的M数量 => 内存屏障 => 检查所有运行队列是否有待运行的G => 休眠
这样可以保证不会出现待运行的G入队了, 也有空闲的资源P, 但无M去执行的情况
协程是如何实现的(重点)
它和线程的原理是一样的,当a线程切换到b线程的时候,需要将a线程的相关执行进度压入,然后将b线程的执行进度出機,进入b线程的执行序列。协程只不过是在应用层实现这一点。
但是,协程并不是由操作系统调度的,而且应用程序也没有能力和权限执行cpu调度。怎么解决这个问题?
答案是,协程是基于线程的。内部实现上,维护了一组数据结构和n个线程,真正的执行还是线程,协程执行的代码被扔进一个待执行队列中,由这n个线程从队列中拉出来执行。这就解決了协程的执行问題。
那么协程是怎么切换的呢?
答案是:galang对各种io函数进行了封装,这些封装的函数提供给应用程序使用,而其内部调用了操作系统的异步0函数,当这些异步函数返回busy或 bloking时, galang利用这个时机将现有的执行序列压钱,让线程去拉另外一个协程的代码来执行,基本原理就是这样,利用并封装了操作系统的异步函数。包括 linux的 epoll、 select和 windows的iocp、 event等
4、有关 P 和 M 的个数问题
1)P 的数量:
- 由启动时环境变量
$GOMAXPROCS
或者是由runtime
的方法GOMAXPROCS()
决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS
个 goroutine 在同时运行。
2)M 的数量:
- go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
- runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
- 一个 M 阻塞了,会创建新的 M。
M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。
5、P 和 M 何时会被创建
1)P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。
2)M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。
6、GMP调度策略
Golang设计调度的理念,应该是类似并发的设计理念,GM模型类比锁的竞争,GPM模型类比channel的通道,希望共享通信,而非竞争!
三、Hello world
1、一个简单的Go程序如何启动执行?
什么是runtime----任何一种高级语言都提供给用户写程序的形式。调用用户写的main函数就是这种语言的runtime。
2、runtime 为啥有机会调用用户写的 main 函数呢?
因为高级语言编译器在把用户写的程序翻译成可执行文件的过程中,把 runtime 代码塞进了可执行文件。
Go 的 runtime 也需要初始化全局变量,还需要调用每个 module 里定义的 init 函数,还需要初始化 GC,以及初始化 Go scheduler,启动一个 goroutine,并且让这个 goroutine 执行用户定义的 main 函数 —— 是为 Go runtime 的初始化。
简单来说,用户启动 Go 程序;操作系统执行 runtime 里的入口函数;runtime 执行初始化过程,最后调用用户写的 main 函数 。
3、举个栗子
经典的hello world:
|
- runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
- 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
- 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ——runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
- 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
- G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
- M 运行 G
- G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。
1)osinit
在 Linux 系统上,这个函数唯 一做的事就是初始化 ncpu 变量,这个变量存储了当前系统的 CPU 的数量。这是通过一个系统调用来实现的。
2)M0
- 负责执行初始化和启动第一个G(编号为0的主线程,进程中唯一)
- 在全局变量runtime.m0中,不需要在head上分布
3)G0
- 每次启动M都会创建一个goroutine(线程唯一)
- G0仅用于负责调度的G,G0不指向任何可执行的函数,每个M都会有一个自己的G0。
- 在调度或系统调用时会使用G0的栈空间,全局变量的G0是M0的G0
4)schedinit(调度器初始化)
在这个函数内做了许多预操作,他会获取运行参数,环境变量,全局变量初始化。
Go语言内幕(6):启动和内存分配初始化 - Go语言中文网 - Golang中文社区
5)mstart(开启调度循环)
- 所有工作线程的入口
- 执行调度循环--schedule()
6)协程创建 newproc → newproc1
newproc()--切换到g0栈去调用newproc1函数
// Create a new g running fn with siz bytes of arguments.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
// Cannot split the stack because it assumes that the arguments
// are available sequentially after &fn; they would not be
// copied if a stack split occurred.
//go:nosplit 不支持栈增长
func newproc(siz int32, fn *funcval) { //size:传递给协程入口函数的参数占多少字节;fn:协程入口函数对应funcval指针
argp := add(unsafe.Pointer(&fn), sys.PtrSize) //argp:用来定位到协程入口函数的参数
gp := getg() //gp:当前协程的指针
pc := getcallerpc() //pc:调用newproc函数时,由call指令入栈的那个返回地址
systemstack(func() {
newproc1(fn, argp, siz, gp, pc)
})
}
newproc1() --创建一个协程
// Create a new g running fn with narg bytes of arguments starting
// at argp. callerpc is the address of the go statement that created
// this. The new g is put on the queue of g's waiting to run.
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) {
_g_ := getg()
if fn == nil {
_g_.m.throwing = -1 // do not dump full stacks
throw("go of nil func value")
}
acquirem() // disable preemption because it can be holding p in a local var。禁止当前M被抢占
siz := narg
siz = (siz + 7) &^ 7
// We could allocate a larger initial stack if necessary.
// Not worth it: this is almost always an error.
// 4*sizeof(uintreg): extra space added below
// sizeof(uintreg): caller's LR (arm) or return address (x86, in gostartcall).
if siz >= _StackMin-4*sys.RegSize-sys.RegSize {
throw("newproc: function arguments too large for new goroutine")
}
_p_ := _g_.m.p.ptr()
newg := gfget(_p_) //获取一个空闲的G,或创建一个并添加到allgs中,状态是_Gdead,拥有自己的协程栈
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
if newg.stack.hi == 0 {
throw("newproc1: newg missing stack")
}
if readgstatus(newg) != _Gdead {
throw("newproc1: new g is not Gdead")
}
totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize // extra space in case of reads slightly beyond frame
totalSize += -totalSize & (sys.SpAlign - 1) // align to spAlign
sp := newg.stack.hi - totalSize
spArg := sp
if usesLR {
// caller's LR
*(*uintptr)(unsafe.Pointer(sp)) = 0
prepGoExitFrame(sp)
spArg += sys.MinFrameSize
}
if narg > 0 { //校验函数是否有参数,有的话移动到自己的协程栈上
memmove(unsafe.Pointer(spArg), argp, uintptr(narg))
// This is a stack-to-stack copy. If write barriers
// are enabled and the source stack is grey (the
// destination is always black), then perform a
// barrier copy. We do this *after* the memmove
// because the destination stack may have garbage on
// it.
if writeBarrier.needed && !_g_.m.curg.gcscandone {
f := findfunc(fn.fn)
stkmap := (*stackmap)(funcdata(f, _FUNCDATA_ArgsPointerMaps))
if stkmap.nbit > 0 {
// We're in the prologue, so it's always stack map index 0.
bv := stackmapdata(stkmap, 0)
bulkBarrierBitmap(spArg, spArg, uintptr(bv.n)*sys.PtrSize, 0, bv.bytedata)
}
}
}
//sched用来保存现场(伪装执行现场,通过恢复sched现场,直接从startpc入口处 开始执行)
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
newg.sched.sp = sp //置为协程栈指针
newg.stktopsp = sp
newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function goexit地址+1压入栈,指向协程入口函数的起始地址
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
newg.gopc = callerpc //置为父协程调用newproc后的返回地址
newg.ancestors = saveAncestors(callergp)
newg.startpc = fn.fn //置为协程入口函数的起始地址
if _g_.m.curg != nil {
newg.labels = _g_.m.curg.labels
}
if isSystemGoroutine(newg, false) {
atomic.Xadd(&sched.ngsys, +1)
}
casgstatus(newg, _Gdead, _Grunnable)
if _p_.goidcache == _p_.goidcacheend {
// Sched.goidgen is the last allocated id,
// this batch must be [sched.goidgen+1, sched.goidgen+GoidCacheBatch].
// At startup sched.goidgen=0, so main goroutine receives goid=1.
_p_.goidcache = atomic.Xadd64(&sched.goidgen, _GoidCacheBatch)
_p_.goidcache -= _GoidCacheBatch - 1
_p_.goidcacheend = _p_.goidcache + _GoidCacheBatch
}
newg.goid = int64(_p_.goidcache) //唯一协程ID,状态置为_Gunnable(可以进入runq中)
_p_.goidcache++
if raceenabled {
newg.racectx = racegostart(callerpc)
}
if trace.enabled {
traceGoCreate(newg, newg.startpc)
}
runqput(_p_, newg, true) //把心创建的G放到当前P的本地队列中
if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted { //有空闲的P && 所有的M都在忙 && 主协程已经开始执行了
wakep() //启动一个新的M
}
releasem(_g_.m) //允许M被抢占
}
7)协程调度 schedule
1)复用线程:避免频繁创建、销毁线程。而是对线程的复用
- work stealing 偷取
- 无可运行G时,尝试从其他绑定线程的P偷取G
- hand off 分离
- 当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行
2)利用并行
- 利用GOMAXPROCS限定P的个数
3)抢占
-
主动让出
-
设置stackPreempt表示,通知让出
-
异步抢占(最多是10ms)
-
调度程序,系统调用前m-g绑定,解除p的关联
4)全局G队列
- 当M执行work stealing从其他P偷不到G时,可以从全局G队列获取G
8)协程监控 sysmon
- 不需要依赖P,也不是GPM模型
- 保障timer正常执行
- 按需主动轮询(netpoll >10ms)
- 对运行时间过长的P进行抢占(schedwhen上一次调用的时间+forcePreemptNS运行时间差值<now当前时间)
- 强制执行GC
Notice
- runtime/runtime2.go 主要是实体G、M、P、schedt的数据模型
- runtime/proc.go 主要是调度实现的逻辑部分
附
go tool trace 适合于找出程序在一段时间内正在做什么(不是总体上的开销)
GODEBUG=schedtrace=1000 ./可执行程序
[Golang三关-典藏版] Golang 调度器 GMP 原理与调度全分析 | Go 技术论坛
go tool pprof 如果您想跟踪运行缓慢的函数,或者找到大部分CPU时间花费在哪里
golang系列—性能评测之pprof+火焰图+trace - 知乎
Golang合辑