Go语言学习 - GMP/并发调度

904 阅读21分钟

Introduction

之前我们简单介绍过GMP大概是怎么回事. 但是我们对它仍然缺少一个深刻的认识, 这篇文章通过一次go func() 来分别描述这三个人日常的工作.

按照我的习惯, 我不会说的特别深, 特别细节, 不会讨论一些极端情况, 也不会讨论GC下的工作模式. 我会尽量避免使用"书面/官方用语"(我尤其讨厌官方用语), 尽量使用更直白更浅显的语言, 让所有人都能在脑海里想象出这三个人平时都在干嘛. 重点是这个想象, 你要能想象出你每次写下go func以后实际都发生了什么

整体流程过一遍

  1. 准备好你要执行的函数 -------- 跳转
  2. 搞个G来承载这个函数 --------- 跳转
  3. 把G放到P的任务队列里去 ------ 跳转
  4. 搞个M - 唤醒或创建 ------------ 跳转
  5. M的调度循环 -------------------跳转
  6. M开始执行G ------------------- 跳转

涉及到的背景知识

  1. 什么是G栈,为什么需要G栈 ------ 跳转
  2. 系统调用-M&P的剥离 ----------- 跳转
  3. 几种常见的暂停操作 ------------ 跳转

循环调度的整体流程

在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, 下一步应该是放到待运行队列里去了, 现在有一个问题, 放到哪? 选择的流程, 与优先级是这样的:

  1. 约50%的概率是会直接放到P的run_next的, 是的, 并不是一定直接下一个就运行, 一半概率会直接运行, 在源码中表现是fastrand()%2==0命中就行, 这种情况下原先的run_next就会被直接踢走, 替换成这个
  2. 如果没能直接next, 那么看看本地队列满了没有, 如果本地队列没满, 放到本地队列尾部.
  3. 如果最后本地队列也满了, 那就放到全局队列里.

newg

如果到了第三步, 那么现在的情况是: 新G无法添加, 同时P任务过多. 因此这种情况下我们会转移一半G到全局队列里去, 我们上面也说了, 因为全局队列因为共享涉及上锁操作, 因此这个函数的名字也叫runq_put_slow, 上锁嘛, 慢嘛. 具体操作手法不详聊了. 在上面写的, 全局&私有G队列, 本质上都是链表, 玩的也就是链表贴来贴去的操作.

搞一个M-> 线程thread

其实到这个位置, 我们刚刚创建的G已经有所归属, 并且已经在等待运行了. 不错, 这个G已经有M来运行它了, 我们现在准备搞M过来, 本质上也确实跟这个G没有产生任何直接的关联. 那为什么要多此一举呢? 而且是每一次go调用都会找一个M? 那是不是我go一千次你也要搞一千个线程出来呢? 如果真是这样那真的是很恐怖的

你想, 在go程序里, 我们设置了一个变量叫GOMAXPROCS, 这个东西代表P的数量, 进而代表了真正并发在执行中G的数量, 我们现在考虑两件事情:

  • 如果P没有被充分利用上, 也就是说有一些P不在使用中, 那么是不是代表实际并发数量没到达预期值?
  • 如果G消耗速度过慢, 是不是也不太行, 也不太好对吧?

wakem

这里面最大的障碍是问题-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又不会执行完, 答案如下

  1. 因为系统调用,任务时间过长,M&P剥离
  2. MP没有剥离, 但M实在也找不到任务了

M的调度循环

我们上面说了我们会通过调用startm()函数来搞一个M来用, 这个M可以是通过getm()唤醒一个闲置的, 也可以是通过newm()创建一个新的. 但无论是从哪儿来的, 在我们拿到M, 贴上P并初始化M的g0栈以后的最后一步永远都是schedule()进入调度循环. 目的: 找一些G任务来, 形成GMP组合

以下就是M找G的全过程, 也就是我们常说的"调度循环", M会想尽一切办法来给自己找点任务做, 好让自己身上的P不要闲着. 由上到下表示优先级, 但凡其中任何一步找到了G, M就会直接执行这个G并退出循环.

  1. 检查自己的任务计数器, 每执行n个任务就应该去全局队列拉一批任务来做
  2. 检查身上这个P有没有任务, 如果自己身上就有任务就不用去别的地方找了
  3. 如果到这一步看来是真的没什么任务可做, 这回不管计数器了, 直接去全局队列拉一批来
  4. 如果到这一步说明本地全局都没有任何G任务可做, 检查一下有没有netpoll任务可做
  5. 还是没找到? 直接偷, 传说中的work-stealing就发生在这里
  6. GG, M进入临沉睡前准备, 最后再按顺序, 把/全局/本地/netpoll再最后检查一遍, 如果还是什么都没有, M沉睡, 等待下一次唤醒


从自己身上找任务 我们会先尝试看看这个P有没有run_next, 如果有就直接返回run_next去给M执行. 如果没有run_next的情况下, 就去p的G任务队列头部, 提取一个出来给M执行


从全局拉任务 本地没有, 从全局拉一批来, 但是拉多少来? 理论上是想拉总数/GOMAXPROCS来, 但是会调整成不要超过P任务队列容量的一半, 如果拉的太多的话, M自己任务执行下去, 就没法再创建新的G了.

从全局拉来的任务被存到P里等着用, 同时返回链表头部任务给M现在拿去执行. 这就是本步骤的任务


work-stealing直接偷 一直让我不能理解的是, 明明这种方法权重最低, 但是却是大家最喜欢聊的, 为什么呢? 或许是"偷"这个字眼比较有话题性..?(起名很重要)

  1. 偷的一步是先找受害者, 先生成一个随机数, 再模一下P的总数: fastrand()%GOMAXPROCS, 生成一个index, 去全局记录p的数组里就能取到一个p受害者来.
  2. 从受害者的队列里, 偷后面一半的任务, 然后返回最后一个任务拿去给M执行

以上步骤会重复尝试4*GOMAXPROCS遍(你是真的很想要任务), 如果偷到第2*GOMAXPROCS遍还是没偷到(fuck), 那就..连他的run_next都偷! 耸人听闻!

M开始执行任务

在M调度一圈以后, 我们假设这个M真的找到一个任务了, 在开始之前先想个问题, M的调度循环发生在什么时候?

  1. go调用之后, 唤醒M
  2. 抢占调度, 必须换一个G执行了
  3. 这个G任务执行完了, 必须换一个G执行

其中场景-1我们已经见过了,场景-2会在后面的G栈内存里介绍, 可是我们没说过G执行完了以后会发生什么? 是怎么进入再调度循环的.

m_tour

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