Introduction
之前我们简单介绍过GMP大概是怎么回事. 但是我们对它仍然缺少一个深刻的认识, 这篇文章通过一次go func() 来分别描述这三个人日常的工作.
按照我的习惯, 我不会说的特别深, 特别细节, 不会讨论一些极端情况, 也不会讨论GC下的工作模式. 我会尽量避免使用"书面/官方用语"(我尤其讨厌官方用语), 尽量使用更直白更浅显的语言, 让所有人都能在脑海里想象出这三个人平时都在干嘛. 重点是这个想象, 你要能想象出你每次写下go func以后实际都发生了什么
整体流程过一遍
- 准备好你要执行的函数 -------- 跳转
- 搞个G来承载这个函数 --------- 跳转
- 把G放到P的任务队列里去 ------ 跳转
- 搞个M - 唤醒或创建 ------------ 跳转
- M的调度循环 -------------------跳转
- M开始执行G ------------------- 跳转
涉及到的背景知识
循环调度的整体流程
在G创建之前-准备好你的函数
func add(a,b int) int {
return a+b
}
所以你已经确定你要执行什么函数了对吗? 现在假设上面那个函数就是你要并发去执行的函数, 你决定去go add(1,1)它, 来执行一下1+1等于几. 想象一下, 我们正在另一个线程里执行一个函数, 那么我们需要知道哪些信息?
- 函数本体是什么 -> 函数地址入栈
- 函数参数是什么 -> 参数+参数长度入栈
以上这些内容我们称之为"一个函数的细节", 在源码里这种细节被打包成一个名为funcval的结构体, 因为拥有了这些细节我们就能完整的执行一个函数了. 细节准备就绪以后, go func() 中的func就准备好了, 接下来我们就可以 > [搞] < 一个G来执行它了.
搞个G来用用
gfget()函数能为你搞来一个G用, 这可以是临时创建, 同样也可以是之前已经死掉的G, 找个地方缓存起来, 然后现在找来复用的. 我们先说说缓存的事: 每个G执行完以后我们就认为是死掉了, 相比较于直接扔掉, 我们会把它缓存起来, 然后等有需要的时候(比如现在), 拿来直接用.
缓存在哪儿呢? 我们知道每个G都是挂在P上运行的, P本身有一个私有缓存G链, 同时多个P之间还有一个全局缓存G链:
type p struct { gfree *g } // p私有链
type schedt struct { gfree *g } // 共享链
每次当我们需要一个空G的时候:
- 我们会先去P的缓存链中碰碰运气, 看看P本地有没有可用的空G
- 如果本地没有, 那么就尝试去全局链中转移一部分过来.
- 如果全局也没有空G, 那么这个时候我们就会新建一个空G. 新建G的过程包含新建一个G对象, 同时为这个G对象分配一定内存
拿到G对象以后, 我们终于可以把上面准备好的funcval放进去, 这个时候我们要将上面定义好的那些函数里的一些参数拷贝到G的栈里面去了 , 现在我们已经是一个"成熟的"/"准备就绪的"G了.
把G放到任务队列里去
一个全局G任务队列, 以及一个P私有任务队列如👇所示
每个人都持有一个g链表, 每一个都是准备好可以直接运行的. 区别是什么呢?
- P有一个对象叫
run_next代表下一个要执行的G - 从P的队列中取G是无锁的, 因为P的队列只有它自己能访问, 但是全局队列是所有P都可以访问的, 因此需要上锁
type schedt struct {
runq_head *g
runq_tail *g
}
type p struct {
runq_head *g
runq_tail *g
runq []g
run_next g
}
ok接着上面的说, 我们刚刚已经准备好了一个G, 下一步应该是放到待运行队列里去了, 现在有一个问题, 放到哪? 选择的流程, 与优先级是这样的:
- 约50%的概率是会直接放到P的
run_next的, 是的, 并不是一定直接下一个就运行, 一半概率会直接运行, 在源码中表现是fastrand()%2==0命中就行, 这种情况下原先的run_next就会被直接踢走, 替换成这个 - 如果没能直接next, 那么看看本地队列满了没有, 如果本地队列没满, 放到本地队列尾部.
- 如果最后本地队列也满了, 那就放到全局队列里.
如果到了第三步, 那么现在的情况是: 新G无法添加, 同时P任务过多. 因此这种情况下我们会转移一半G到全局队列里去, 我们上面也说了, 因为全局队列因为共享涉及上锁操作, 因此这个函数的名字也叫runq_put_slow, 上锁嘛, 慢嘛. 具体操作手法不详聊了. 在上面写的, 全局&私有G队列, 本质上都是链表, 玩的也就是链表贴来贴去的操作.
搞一个M-> 线程thread
其实到这个位置, 我们刚刚创建的G已经有所归属, 并且已经在等待运行了. 不错, 这个G已经有M来运行它了, 我们现在准备搞M过来, 本质上也确实跟这个G没有产生任何直接的关联. 那为什么要多此一举呢? 而且是每一次go调用都会找一个M? 那是不是我go一千次你也要搞一千个线程出来呢? 如果真是这样那真的是很恐怖的
你想, 在go程序里, 我们设置了一个变量叫GOMAXPROCS, 这个东西代表P的数量, 进而代表了真正并发在执行中G的数量, 我们现在考虑两件事情:
- 如果P没有被充分利用上, 也就是说有一些P不在使用中, 那么是不是代表实际并发数量没到达预期值?
- 如果G消耗速度过慢, 是不是也不太行, 也不太好对吧?
这里面最大的障碍是问题-1, 我们不能允许实际并发数没有达到预期这种场景出现, 为了解决问题-1, 我们都会立刻检查有没有沉睡中的P? 好立刻拿来使用恢复预期并发数
- 如果没有, 直接退出, 并发数已经达到了, 不需要额外操作了
- 如果有, 进入M唤醒或创建的环节, 总之不管什么手段先搞来一个M把P用上, 然后我们检查一下P的任务数量, 太少或太多, G分配严重不均了, 这样也不行对吧, 我们需要balance(就是搞调度)一下
👆以上就是我们为什么还要画蛇添足多一步唤醒M的动作, 如果已经知道为什么了, 谈谈怎么做的把, 还是那一套, 一个全局缓存, 一个新建, 我们先说新建
M, Machine, 真正的意思就是把系统线程包装了一下, 但核心还是那个线程, 不是协程routine, 而是线程thread.
type m struct {
g0 *g // 搞管理用的的G
current_g *g // M上正在运行的G
p *p // 这个就是刚刚唤醒的P, 拿来贴上
}
系统线程新建的过程如下, 如果你碰巧大概了解过docker容器创建的过程, 或者你简单了解过LinuxAPI, 下面的flag你就很眼熟了. 没见过也没关系, 简单来说就是系统给你的API, 让你创建一个系统线程用的, 调用完了你就有一个系统线程了
clone(
_CLONE_VM | _CLONE_FS | _CLONE_THREAD,
m.stack,
...
)
比创建M优先级更高的是找一个闲置的M来用, 闲置的M还是会存在这个schedt这个全局缓存中心里
type schedt struct {
midle *m
}
但是很奇怪的是, 你有没有想过, M怎么会出现闲置的情况, 如果说G会因为执行完进入闲置的状态, M怎么会闲置, M又不会执行完, 答案如下
- 因为系统调用,任务时间过长,M&P剥离
- MP没有剥离, 但M实在也找不到任务了
M的调度循环
我们上面说了我们会通过调用startm()函数来搞一个M来用, 这个M可以是通过getm()唤醒一个闲置的, 也可以是通过newm()创建一个新的. 但无论是从哪儿来的, 在我们拿到M, 贴上P并初始化M的g0栈以后的最后一步永远都是schedule()进入调度循环. 目的: 找一些G任务来, 形成GMP组合
以下就是M找G的全过程, 也就是我们常说的"调度循环", M会想尽一切办法来给自己找点任务做, 好让自己身上的P不要闲着. 由上到下表示优先级, 但凡其中任何一步找到了G, M就会直接执行这个G并退出循环.
- 检查自己的任务计数器, 每执行n个任务就应该去全局队列拉一批任务来做
- 检查身上这个P有没有任务, 如果自己身上就有任务就不用去别的地方找了
- 如果到这一步看来是真的没什么任务可做, 这回不管计数器了, 直接去全局队列拉一批来
- 如果到这一步说明本地全局都没有任何G任务可做, 检查一下有没有netpoll任务可做
- 还是没找到? 直接偷, 传说中的work-stealing就发生在这里
- GG, M进入临沉睡前准备, 最后再按顺序, 把/全局/本地/netpoll再最后检查一遍, 如果还是什么都没有, M沉睡, 等待下一次唤醒
从自己身上找任务
我们会先尝试看看这个P有没有run_next, 如果有就直接返回run_next去给M执行. 如果没有run_next的情况下, 就去p的G任务队列头部, 提取一个出来给M执行
从全局拉任务
本地没有, 从全局拉一批来, 但是拉多少来? 理论上是想拉总数/GOMAXPROCS来, 但是会调整成不要超过P任务队列容量的一半, 如果拉的太多的话, M自己任务执行下去, 就没法再创建新的G了.
从全局拉来的任务被存到P里等着用, 同时返回链表头部任务给M现在拿去执行. 这就是本步骤的任务
work-stealing直接偷
一直让我不能理解的是, 明明这种方法权重最低, 但是却是大家最喜欢聊的, 为什么呢? 或许是"偷"这个字眼比较有话题性..?(起名很重要)
- 偷的一步是先找受害者, 先生成一个随机数, 再模一下P的总数:
fastrand()%GOMAXPROCS, 生成一个index, 去全局记录p的数组里就能取到一个p受害者来. - 从受害者的队列里, 偷后面一半的任务, 然后返回最后一个任务拿去给M执行
以上步骤会重复尝试4*GOMAXPROCS遍(你是真的很想要任务), 如果偷到第2*GOMAXPROCS遍还是没偷到(fuck), 那就..连他的run_next都偷! 耸人听闻!
M开始执行任务
在M调度一圈以后, 我们假设这个M真的找到一个任务了, 在开始之前先想个问题, M的调度循环发生在什么时候?
- go调用之后, 唤醒M
- 抢占调度, 必须换一个G执行了
- 这个G任务执行完了, 必须换一个G执行
其中场景-1我们已经见过了,场景-2会在后面的G栈内存里介绍, 可是我们没说过G执行完了以后会发生什么? 是怎么进入再调度循环的.
ok开始介绍gogo函数, 这个函数紧接着schedule之后, 负责执行一个已经准备就绪的G. 正常情况下你想调用函数f, "函数调用"这个动作被翻译成汇编, 会是CALL f(), 然后就是PC/IP入栈, 等你f()执行完了回到这个位置. 但是gogo在调用G中函数的时候用的是JMP, 我们会JMP到G中函数代码区, 去一句一句执行.于是G中的函数就这样开始执行了.
但是JMP没有保存PC/IP的动作啊... 这就代表: gogo调用完了根本就不会回到这里. 鬼畜! 那等G中函数执行完了去哪儿!? 答案隐藏在G创建的时候, 有一个小步骤叫做gostartcallfn. 这个函数会悄悄的把goexit函数压入G栈顶, 造成的结果就是等G函数执行完了, 我们会自动的执行goexit, 这个函数负责将自己的状态设置成"死掉的G". 并将自己加入P中的缓存空G链, 等待下一次go func()调用的时候再拿出来用. 同时再次调用schedule()让M进入调度循环.
我们跟开头接上了... 我们从go func()开始找"死掉的G"开始, 到现在函数执行完了, 又变成一个"死掉的G"了, M也从一轮循环进入了下一轮循环, 我觉得我把这个问题给说明白了...
总而言之我们是绕了一大圈了, 造成的结果是
schedule()负责找个G运行, 而且不会返回gogo()负责执行找到的这个G, 而且不会返回
造成"函数不会返回"这种奇观真的值得吗? 我认为是值得的, 因为从需求上说, 的确不需要返回. 我找个G, 我执行, 到下次我再需要调度的时候我大不了再执行一次schedule函数就是了, 这样做还更直观一点, 你需要你就去调度, 你找到你就执行
G栈内存
为什么G需要自己的栈
在参数被拷到G栈上之前, G栈是空的, 拷上去以后了现在就是非空. G栈上有一段等待执行的代码, 以及一些参数. 我们拿着这些参数, 去执行这段代码, 这就是我们要求G要做的事情对吧?
func add(a,b int) {
c := 123
fmt.Println(c)
}
在执行你这段函数的时候, 你完全还会定义一些变量之类的东西,
然后你还会去调用一些别的函数对吧? 比如上面的函数add, 它就定义了一个局部变量c, 同时又调用了fmt.Println这个函数, 我们又要搞一遍函数入栈, 参数入栈这一套了.
现在问题来了, 你认为c这个变量是存在哪儿的? fmt.Println 参数入栈又是入到哪儿去的? 这是一个非常简单非常直观的问题, 能直接用来回答: "为什么每个G都需要一个自己的栈了"
G0(零)是什么毛?
这个名字很鬼畜, 很中二. "零", 一种零式战机的感觉. ok, 不吐槽名字了, 我们要回答一下G0是干什么的, 以及为什么存在
回到协程创建之初, 到底是谁执行的"创建"这个动作? 某个G, 联合P与M, 形成GMP, 在运行某段代码的时候, 执行的创建这个动作, 对吧? 当时的场景是这样的对吧. Go语言中所有要执行的代码分成
- 用户定义的代码 -> 执行你的add(1,1)代码
- 管理工作所需要的代码 -> 创建一个G所需要执行的代码
G0就是被安排来做这样的管理工作的, 所有创建过程中用到的临时变量, 包括打包成一个funcval 都是在G0栈上完成的.
systemstack(func(){
newproc(*fn,args,arg_size)
})
像上面你看到的那样, 通过调用system_stack函数, 这个函数内所有要执行的动作, 全都要切换到G0栈上去做的. 后面的你都知道了, 我们会将fn, args等等全都打包成funcval, 然后把包拷贝到G栈上去.
G栈的定义
之前已经提过了为什么会有G栈, G栈用来存放什么的, 其实人家的官方名字叫"连续栈", 后面我们会详细描述, 为什么它连续, 2K的默认内存是如何扩张的, 有了这些基础, 就非常容易理解抢占调度的发生了
G是通过一个叫做malg()的函数创建的, 理解这种协程一样的东西本身跟系统没关系, 是你自己搞的自己定义的结构体, 我们只是为它分配了一段内存, 作为它的栈. 这就是在创建G的时候实际发生的事情, 现在我们来解释上面的内容, 首先一个栈:
- 就是: 一段内存, 而一段内存
- 就是: 一枚栈顶指针+ 一枚栈底指针, 而你的栈
- 就是: 两枚指针所囊括的区域
type stack struct {
lo uintptr // 指向[栈顶]的指针
hi uintptr // 指向[栈底]的指针
}
type g struct {
stk stack
stack_guard0 uintptr
}
这里出现了第三枚指针stackguard,名字很中二, 栈卫士, 一种王国卫兵的感觉. 这是一枚非常重要的指针, 栈扩张/抢占调度都是以这枚指针作为起点的. 我们先看看创建出一个新栈是怎么样的.
👆如上图所示, 我们申请了一段长度为2k的内存, 作为新G的栈, 并在两头分别设置上lo/hi两枚指针,SP指针代表目前已经用了多少内存了, 被设置在hi位, 代表目前还没使用任何内存. 在离栈顶640字节的位置设置上stackguard指针, ok这就是我们的卫兵了.
G栈的扩容morestack
ok现在假设你的G一直运行运行, 栈里的内存也一直消耗着. 现在你想调用函数f, 在调用之前我们要确保栈里的内存还够f使用, 不要等f运行到一半发现内存不够, 临时再去扩容. 所以在函数调用之前搞内存检查是最合适的, 编译器会插入一段负责检查内存的代码
在上面我们提过SP起于hi点, 越用越往lo点走, 内存地址越小, 期间会经过stackguard点, 等走到lo点了就代表内存用完了. 检查逻辑如下
- 如果已经低于stackguard0, 可以确定已经溢出, 直接扩容
- 如果是高于stackguard0, 但是高的不多, 不够这个函数用怎么办? [lo,stackguard0]之间还有一点空间可以用, 适当的溢出还是可以接受的
扩容就是通过调用newstack()函数, 重新分配一个两倍大小的内存, 然后将现在的栈拷贝过去, 重新分配地址与内存以后, 调整这个G栈的相关参数. 最后执行一下gogo调度一下, 结束扩容.
抢占调度的原理
这部分其实是跟sysmon相结合的部分, sysmon决定应该给这家伙来个抢占调度了, 但是实际上真正发挥作用的还是stackguard0指针
我们知道在函数调用的时候会检查stackguard0与SP之间的关系, 如果sysmon决定了要抢占这个家伙, 那么会把它的stackguard0设置成stackpreempt, 到了下次函数调用做内存检查的时候一定是不通过的, 进而进入newstack()函数
然而newstack()函数也不傻, 它发现虽然内存检测没过, 但是stackguard被设置在了stackpreempt位置, 它就发现了你其实只是想搞抢占调度而已, 是不会给你扩容的. 它会把你从M上摘下来放到全局队列里去, 最后执行一下schedule()调度, 让M重新找个任务来做, 结束抢占调度
系统调用与M&P剥离
常见的一些系统调用函数如syscall.Chown()本质上是调用syscall()的函数完成的, 在go语言中执行一次系统调用包含准备工作+调用+收尾工作三步.
准备工作是指保存现场(详细的说就是pc/sp指针), 同时将P+G的状态从RUNNING调整成SYSCALL的状态, 然后最重要的是确保监控协程sysmon正在运行("什么是sysmon呀?")
Ok, 整理一下现场, M被派去执行G的系统调用任务, P在旁边围观. 我们都知道系统调用时间可能很长, 太长了以至于把P放在那边看都有点不合适了, 这就相当于P无所事事. 但是问题是系统调用时间要多长你知不知道?
- 如果事先知道就耗时很短
- 调用结束直接回来当做什么都没发生过
- 如果事先就知道耗时很长
- 不能把P放在这里就这么等着, 于是经典场面再放送: P_handoff! 我们会在系统调用一开始的时候就直接把P摘下来
- 如果P中有任务, 唤醒一个M来执行这个任务. 如果P中无任务, 放到P空闲池内
- 抱歉, 不知道, 以为很短没想到花了这么长时间
- sysmon上场了, sysmon是一个监控线程, 如果它发现你这个调用怎么花了这么长时间, 会帮你把P摘下来
- 所以sysmon很重要, 如果你一个系统调用花了这么久, 然后你P里的任务都没执行, 那就糟了
所以等你回来的时候, (时间很短的话)可能M手上还有P, (时间很久的话)M手上就没有P了, 因为自己主动交走了, 或者被sysmon强制搞走了.
- 找找看之前关联的P, 看看这个能不能用, 也许被摘走了但是没被别的M占用
- 如果之前的用不了, 尝试找找看空闲池里有没有能用的
如果这个M真倒霉到最后也没能找到一个P, 准备休眠吧, 但是自己手上还有一个刚刚系统调用回来的G, 这时候会把这个G放到全局队列里, 然后自己休眠去了.
几种常见的暂停操作
- GoSched - 见于抢占调度
- 这种操作会把当前G状态从running改回runnable,再放回全局队列里, 自己则是通过一次Schedule再找个任务做
- GoPark - 见于WaitGroup
- 与GoSched差不多, 都是要把状态改成runable以后自己通过sched找个任务做, 区别是被拿下来的G并不会被被放到队列里, 如果你不主动去再重启它的话它永远也不会再运行了
- 运行它的办法是
GoReady函数, 这个函数会直接让G去往P的run_next位置
- Notesleep - 见于休眠的M:
stopm()- 之前两种都是围绕G展开的, 这一种则是针对线程的, 整个线程直接被冻结, 直到被notewakeup函数叫醒
- 被认为是"很灵活", 使用FUTEX技术实现
- StopTheWorld - 见于GC
- 名气太大不用介绍, 关于stw的代码其实在调度过程里穿插的到处都是, 目的是让调度过程尽早响应GC停止一切的命令. 所有的G该停就停,M改休眠的休眠, 这种操作是怎么做到的呢?
- 停G: 通过设置抢占位(stackpreempt/复习G栈内容), 使得G在下一次函数调用的时候进入
morestack开始状态检查, 进而GC沉睡 - 停M: 设置全局变量
gcwaiting=1这个变量会在M下一次执行schedule()的时候发挥作用, 让M进入沉睡状态 - 停P: 将所有的P,包含空闲的,以及正在SYSCALL的P全都暂停, 遍历全局P列表,schedt.allp[i],逐一发送抢占信号
- 最后会通过StartTheWorld再将所有P叫起来工作, 原则是找到这个P之前所关联的M, 将这个沉睡的M叫起来, 如果这个P之前没关联M, 那么就创建一个新M来接纳P