目录


线程实现模型

       在操作系统提供的内核线程之上,Go 搭建了一个特有的用户级线程模型。传统的函数调用是将函数指针存储在内存的栈空间上,但是栈空间只允许操作系统进行操作,我们能拿到的只限于堆内存,所以 Go 开发者在堆上申请一块内存,将寄存器 %rsp 以及寄存器 %rbp 指过去,从而将这块内存伪装成用户栈,从而巧妙地实现了并行运行用户级线程 goroutine。要聊 Go 的线程实现模型,必须要知道以下3个核心元素:

  • M:machine 缩写,一个 M 代表一个内核线程。
  • P:processer 缩写,一个 P 代表执行一个 Go 代码片段所需的必要资源。
  • G:goroutine 缩写,一个 G 代表一个 Go 代码片段。

关于协程内存和寄存器指令参考:https://segmentfault.com/a/1190000038626134

   简单来说,一个 G 的执行需要 P 和 M 的支持,一个 M(内核线程)在与一个 P(上下文环境)关联之后,就形成了一个有效的 G 运行环境。

        可以看到,M和系统线程之间总是一对一关系,一个 M 能且仅能代表一个系统线程,Go 用 M 代表一个内核调度实体。M 和 P、P 和 G 之间的关系会在实际调度过程中改变,其中 M 和 P 之间是一对一关系,而 P 和 G 之间是一对多关系( G 队列)。

M(machine)

关于 M 的结构体就不在这里贴了,可以自行到源码 runtime/runtime2.go 中搜索 type m struct,这里只说几个跟本文相关的关键字段:

  • g0:在进程启动之初创建,用于执行调度和一些运行时任务。
  • mstartfn:goroutine 关联函数。
  • curg:当前 M 正在运行的 G 指针。
  • p:当前 M 关联的 P 指针。
  • nextp:调度器将某个 P 赋给某个 M 的当前字段,实现 M 和 P 的预联,运行时可能会把重新启用的M和已经预联的 P 关联在一起。
  • spinning:用于表示当前 M 是否正在寻找可运行的 G,在寻找过程中 M 处于自旋状态。
  • lockedg:表示与当前 M 锁定的 G 。

可以使用runtime中的 LockOSThread 和 UnlockOSThread 操作将当前G和M锁定和解除锁定。

M在创建之初会被加入全局的M列表(runtime.allm)中,设置起始函数(监控任务等)和预联的P,最后,运行时系统会为这个M专门创建一个新的内核线程并与之相关联。运行时,runtime.allm 中的M有时会被停止,比如在运行时系统执行GC的过程中。停止M时,会把它放入调度器的空闲M列表(runtime.sched.midle)中。

在需要一个未被使用的M时,会先尝试从 runtime.sched.midle 列表中获取。

P(processer)

       P 是 G 能够在 M 中运行的关键,Go 在运行时会适时地让 P 和不同的 M 建立或断开关联,以使 P 中的哪些可运行的 G 能够及时获得运行时机,这与操作系统内核在 CPU 上进行进程切换类似。一个 G 在被创建后,会先被追加到某个 P 的可运行 G 队列中,以等待运行时机。当某个 M 上的 G 因系统调用而阻塞时,调度器会把该 M 和与之关联的 P 分离开,让出 M 给其他 P 运行。如果这个 P 的可运行 G 队列中还有未被运行的 G,那么调度器就会找到一个空闲的 M,如果找不到就会创建一个新的 M,并与该 P 关联以满足这些 P 的运行需要。

       可以通过 runtime.GOMAXPROCS 函数对 P 的数量进行限制,在修改 P 的最大数量后,调度器会根据这个数值重制全局的 P 列表(runtime.allp),与全局 M 列表(runtime.allm)类似,该列表中包含了当前运行时创建的所有 P,运行时会把这些 P 中可运行 G 全部取出,并放入调度器的可运行 G 队列中,并在后续调度再次放入某个 P 的可运行队列中。

runtime.GOMAXPROCS 会执行 stopTheWorld("GOMAXPROCS"), 让所有的 P 都脱离运行状态,并阻止任何 G 的运行,同时重置 P 和 可运行 G 队列,带来巨大的性能损耗,所以一般只会在 main 最开始进行调用,大多数情况也无需手动改变。

       与空闲 M 列表类似,运行时也存在一个调度器的空闲 P 列表(runtime.sched.pidle),当一个 P 不再与任何 M 关联时,就会把它放入该列表,而当需要一个空闲的 P 关联某个 M 时,会从该列表中取出一个。

注意,P 进入 runtime.sched.pidle 列表的前提时它的可运行 G 队列必须为空。

与 M 不同,P 本身时是有状态的:

  • Pidle:未与任何 M 关联。
  • Pruning:正在与某个 M 关联。
  • Psyscall:当前 P 中运行的 G 正在进行系统调用。
  • Pgcstop:被调度器停止调度,比如垃圾回收前会将所有 P 置于此状态。
  • Pdead:此状态表明当前 P 已经不会再被使用,比如通过 runtime.GOMAXPROCS 减少 P 的最大数量。

P 的状态流转如下图:

在图中大部分应该可以直观地看懂,其中有几个点需要说明下:

  • 每个 P 在重启调度时,并不会被恢复为原来状态,而是会被统一地转换为 Pidle 状态,等待调度。
  • 在 P 被转换为 Pdead 状态前,其可运行队列中的 G 都会被转移到调度器的可运行 G 队列,它的自由 G 列表中的 G 也都会被转移到调度器的自由 G 列表中。
  • 在任何一个状态下,都有可能因为缩小 P 数量,导致 P 被转换为 Pdead 状态。
  • 在任何一个状态下,都有可能因为调度,导致 P 被转换为 Pgcstop 状态。

G重用:每个 P 的自由 G 列表包含一些运行完的 G,增长到一定程度就会把部分 G 装一到调度器的自由 G 列表中。当使用 go 语句想要启动一个 G 时,会先从相应的自由 G 列表中获取一个现成的 G,只有在获取不到时才创建一个新的 G。同时调度器会在发现某个 P 中自由 G 太少时,尝试从调度器的自由 G 列表中转移一些 G。

既然 M 和 G 绑定就能运行,为什么还要引入 P,主要是下面几个原因:

  • 每个 P 都有自己的本地队列,在对 G 的操作上降低对全局队列的依赖,缩小锁粒度,从而降低锁冲突对并发性能的损耗
  • 如果去掉 P,那么 G 只能保存在 M 的本地队列中,而 G 执行阻塞时最多可以创建 10000 个 M,会使 Stealing 变得复杂导致性能大幅下降
  • 在进行 syscalls 时,可以将 M 和 P 解除绑定,空闲下来的 P 可以继续服务其它任务,待系统调用结束后分配给空闲的 P,降低 CPU 空转

G(goroutine)

       一个 G 就代表一个 goroutine,也与 go 函数对应。我们使用 go 语句时,实际上是向 Go 调度器提交了一个并发任务。Go 的编译器会把 go 语句变成内部函数 newproc 的调用,并把 go 函数以及其参数部分传递给这个函数。整个过程经历以下步骤:

  1. 检查 go 函数和参数的合法性
  2. 试图从本地 P 的自由列表和调度器的自由 G 列表获取可用的 G;如果没有获取到则新建一个 G,并在第一时间将其加入全局 G 列表(runtine.allgs)
  3. 进行初始化,包括关联 go 函数、设置 G 的状态和 ID 等
  4. G 被立即存储到本地 P 的 runnext 字段中,该字段用于存放最新的 G,以求更早地运行它;如果 runnext 字段已经存在一个 G,则已有的 G 会被踢到该 P 的可运行 G 队列末尾,如果队列已满则追加到调度器的可运行 G 队列

Go 调度器会优先运行新启动的 G

每一个 G 都会由运行时调度器根据其实际状况设置不同的状态,其主要状态如下:

  • Gidle:当前 G 刚被分配,还未初始化
  • Grunable:正在可运行队列等待运行
  • Gruning:正在运行
  • Gsyscall:正在执行系统调用
  • Gwaiting:正在被阻塞       
  • Gdead:正在闲置
  • Gcopystack:表示当前 G 的栈正在被移动,可能是因为栈的收缩或扩容

除了上述状态,还有一个 Gscan 状态,它不能独立存在,属于组合状态的一部分:

  • Gscan 和 Grunable 组合成 Gscanrunable 状态,代表当前 G 正等待运行,同时栈正被 GC 扫描
  • Gscan 和 Gscanrunning 状态,表示正处于 Grunning状态,同时栈在被 GC 扫描

下面是 G 的状态转换图,便于大家理解:

图上有几点需要说明:

  • 当前 G 等待从 channel 接收值或发送值、发生网络 I/O、定时器 time.Timer、time.Sleep 等,就会进入 Gwaiting 状态,在等待的事件到来后,G 会被唤醒并转换至 Grunable 状态,等待调度。
  • 进入 Ddead 状态的 G 就会被放入到前面提到的 本地 P 调度器的自由 G  列表。
核心元素容器

       上面介绍了 Go 的线程实现模型中的3个核心元素—— M、P 和 G,我也多次提到了保存这些元素实例的容器,现在将这些容器归纳一下:

名称源码变量作用域简介
全局M列表runtime.allm运行时系统存放所有M的一个单向链表
全局P列表runtime.allp运行时系统存放所有P的一个数组
全局G列表runtime.allgs运行时系统存放所有G的一个切片
调度器的空闲M列表runtime.sched.midle调度器存放空闲M的一个单向链表
调度器的空闲P列表runtime.sched.pidle调度器存放空闲P的一个单向链表
调度器的可运行G队列

runtime.sched.runqhead

runtime.sched.runqtail

调度器存放可运行G的一个队列
调度器的自由G列表

runtime.sched.gfreeStack

runtime.sched.gfreeNoStack

调度器存放自由G的两个单向链表
P的可运行G队列runtime.p.runq本地P存放当前P中的可运行G的一个队列
P的自由G队列runtime.p.gfree本地P存放当前P中的自由G的一个单向链表

关于上表有以下几点说明:

  • 任何 G 都会存在于全局 G 列表中,其余4个容器只存放当前作用域内具有某个状态的 G
  • 从 Gsyscall 状态转出的 G 都会被放到调度器的可运行 G 队列
  • 刚被运行时系统初始化的 G 都会被放入本地 P 的可运行 G 队列
  • 从 Gwaiting 状态转出的 G,有的会被放入本地 P 的可运行 G 队列,有的会被放到调度器的可运行 G 队列,还有的会被直接运行(比如刚完成网络 I/O)
  • 如果本地 P 的可运行队列 G 已满,其中的一半 G 会被转移到调度器的可运行 G 队列
  • 调度器可运行 G 队列遵循 FIFO(先进先出)
调度器

基本结构

        一个 Go 程序中只会存在一个调度器实例,调度的主要对象是上面提到的 M、P 和 G 的实例,调度的辅助设施包括刚刚上面的各种容器,还包括以下几个重要字段

字段名数据类型用途简述
gcwaitinguint32标记是否需要因一些任务而停止调度
stopwaitint32表示需要停止但仍未停止的P的数量
stopnotenote用于实现与stopwait相关的事件通知机制
sysmonwaituint32标记在停止调度期间系统监控任务是否在等待
sysmonnotenote用于实现与sysmonwait相关的事件通知机制

       字段 gcwaiting、stopwait 和 stopnote 都是串行运行时任务执行前后的辅助协调手段,gcwaiting 字段的值用于表示是否需要停止调度,目的是在一些调度任务执行时只要发现 gcwaiting 值为1,就会把当前 P 的状态置为 Pgcstop,然后自减 stopwait 的值,如果发现自减后的值为0,就说明所有 P 的状态都已经是 Pgcstop,此时就可以使用 stopnote 字段,唤醒因等待调度而暂停的串行运行的任务了。字段 sysmonwait 和 sysmonnote 作用也类似,只不过它们针对的是系统监测任务,在串行运行时执行前,系统监测任务也要暂停,系统监测任务循环运行,每次迭代开始会先根据 gcwaiting 字段判断调度情况,一旦调度停止,就会把 sysmonwait 置为1,并利用 sysmonnote 暂停自身,在恢复调度之前,调度器若发现 sysmonwait 值不为0,就会把它置为0,并用 sysmonnote 字段恢复系统监测任务的执行。

启动流程

 从 Go 进程启动开始,直到调度循环 schedule 

src/runtime/rt0_linux_amd64.s -> _rt0_amd64_linux(SB)
src/runtime/asm_amd64.s -> _rt0_amd64(SB)
src/runtime/asm_amd64.s -> runtime·rt0_go(SB)
    src/runtime/sys_linux_amd64.s -> runtime·settls(SB)
    src/runtime/runtime1.go -> func check
    src/runtime/runtime1.go -> func args
        src/runtime/os_linux.go -> func sysargs
    src/runtime/os_linux.go -> func osinit
        src/runtime/os_linux.go -> func getproccount
            src/runtime/os_linux.go -> func sched_getaffinity
        src/runtime/os_linux.go -> func getproccount
        src/runtime/os_linux.go -> func getHugePageSize
        src/runtime/os_linux_x86.go -> func osArchInit
    src/runtime/proc.go -> func schedinit
        src/runtime/traceback.go -> func tracebackinit
        src/runtime/symtab.go -> func moduledataverify
        src/runtime/stack.go -> func stackinit
        src/runtime/malloc.go -> func mallocinit
        src/runtime/proc.go -> func fastrandinit
        src/runtime/proc.go -> func mcommoninit
        src/runtime/proc.go -> func cpuinit
        src/runtime/alg.go -> func alginit
        src/runtime/symtab.go -> func modulesinit
        src/runtime/type.go -> func typelinksinit
        src/runtime/iface.go -> func itabsinit
        src/runtime/signal_unix.go -> func msigsave
        src/runtime/runtime1.go -> func goargs
        src/runtime/os_linux.go -> func goenvs
        src/runtime/runtime1.go -> func parsedebugvars
        src/runtime/mgc.go -> func gcinit
        src/runtime/proc.go -> func procresize
    src/runtime/proc.go -> func newproc
        src/runtime/stubs.go -> func add
        src/runtime/stubs.go -> func getg
        src/runtime/stubs.go -> func getcallerpc
        src/runtime/stubs.go -> func systemstack
            src/runtime/proc.go -> func newproc1
                src/runtime/stubs.go -> func getg
                src/runtime/runtime1.go -> func acquirem
                src/runtime/proc.go -> func gfget 
                [if no gfree,malg、casgstatus、allgadd]
                [if 参数 > 0,memmove 拷贝参数]
                src/runtime/stack.go -> func gostartcallfn
                    src/runtime/sys_x86.go -> func gostartcall
                src/runtime/proc.go -> func casgstatus
                src/runtime/proc.go -> func runqput
                [if npidle!=0 && nmspinning == 0,wakep -> nmspinning+1 -> startm]
                src/runtime/runtime1.go -> func releasem
    src/runtime/proc.go -> func mstart
        src/runtime/proc.go -> func mstart1
            src/runtime/proc.go -> func getg
            src/runtime/proc.go - func save(getcallerpc(), getcallersp())
            src/runtime/stubs.go -> func asminit
            src/runtime/stubs.go -> func minit
            [if m0,mstartm0]
            [if _g_.m == &m0,mstartm0]
            [if _g_.m.mstartfn != nil,mstartm0]
            [if _g_.m. != &m0,acquirep -> _g_.m.nextp = 0]
            src/runtime/proc.go -> func schedule (调度循环)

调度流程

整体流程

关于调度流程的说明:

  • 调度器检查是否有运行时串行任务正在等待执行(判断 gcwaiting),如果有,则需要停止 Go 调度器,这种停止操作称为 STW(stop the world)
  • 开始时会判断当前 M 是否与 G 锁定,如果锁定则唤醒并运行锁定的 G
  • 找到可运行 G 后,需要判断是否和 M 锁定,如果未锁定则在当前 M 执行,否则唤醒与该 G 锁定的 M 来运行该 G,并停止当前 M

调度器触发时机:G运行的阻塞、结束、退出系统调用,栈的增长,对 runtime.Gosched(暂停当前 G) 和 runtime.Goexit(结束当前 G) 等函数的调用。

全力查找

看到这你一定有个疑问,全力查找可运行的 G 是个什么鬼?概括地说,这个子流程会多次尝试从各处搜索可以运行的 G,包括本地 P、netpoller 等, 甚至还会从别的 P 那里偷取可以运行的 G,看下流程图就明白了:

其中,左边5个属于第一阶段查找,右边5个属于第二阶段查找,其中任意一次查找到符合条件的 G,都会返回找到的 G ,10次查找都没有的话,则停止当前 M,等待下一次唤醒。下面是对每一步查找的详细说明:

  • (步骤1)获取执行终结器的 G:一个终结器可以与一个对象关联,通过调用 runtime.SetFinalIzer 产生关联,当一个对象变为不可达,即未被其他任何对象饮用时,垃圾回收器就会执行与之关联的终结函数,所有终结函数的执行都由一个专门的 G 负责,如果它已完成任务,会把它置为 Grunnable 状态并放入本地的可运行 G 队列。
  • (步骤2)从本地 P 的可运行 G 队列获取 G:调度器尝试从本地 P 的可运行 G 队列获取 G,并把它作为结果返回。
  • (步骤3)从调度器的可运行 G 队列获取 G:调度器尝试从该处获取一个 G,并把它作为结果返回。
  • (步骤4)从网络 I/O 轮训器(netpoller)获取 G:如果 netpoller(epoll模型) 已被初始化且已有过网络 I/O 操作,那么调度器会从 netpoller 获取一个 G 列表,并把表头的 G 当作结果返回,同时把剩余 G 都放入调度器的可运行 G 队列。
  • (步骤5)从其他 P 的可运行 G 队列获取 G:在条件允许的情况下,调度器会使用一种伪随机算法在全局 P 列表中选取 P,然后从它们的可运行 G 队列中盗取一半的 G 到本地 P 的可运行 G 队列,如果成功,会把盗取的第一个 G 返回。
  • (步骤6)获取本地执行 GC 标记任务的 G:判断是否正在处于 GC 标记阶段,以及本地的 P 是否可用于 GC 标记任务,如果两者都满足,调度器就会把本地 P 持有的 GC 标记专用 G 置为 Grunnable  状态并返回。
  • (步骤7)再次从调度器的可运行 G 队列获取 G:调度器再次尝试从该处获取一个 G,并把它作为结果返回。如果依然找不到可运行的 G,就会解除本地 P 与当前 M 的关联,并把该 P 放入调度器的空闲 P 列表。
  • (步骤8)从全局 P 列表中每个 P 的可运行 G 队列获取 G:遍历全局列表中的 P,并检查它们的可运行队列,只要发现某个 P 的可运行 G 队列不是空的,就从调度器的空闲 P 列表中取出一个 P,并在判断其可用后与当前 M 关联在一起,然后再返回第一阶段(步骤1)重新搜索可运行的 G。
  • (步骤9)获取全局执行 GC 标记任务的 G:调度器判断是否正处于 GC 标记阶段,以及与 GC 标记任务相关的全局资源是否可用,如果答案都是 true,调度器就会从其空闲的 P 列表拿出一个 P,如果这个 P 持有一个 GC 标记专用 G,就关联该 P 与当前 M,然后再次执行第二阶段(步骤6)

启动或停止M

上面多次提到, 调度器有时会停止当前 M,在 Go 标准库代码包 runtime 中,有下面几个函数负责 M  的启动或停止:

  • stopm():停止当前 M 的执行,直到因有新的 G 变得可运行而被唤醒。
  • gcstopm():为串行运行任务的执行让路,停止当前 M 的执行,串行运行时任务执行完毕后会被唤醒。
  • stoplockedm:停止已与某个 G 锁定的当前 M 的执行,直到因这个 G 变得可运行而被唤醒。
  • startlockedm():唤醒与 gp 锁定的那个 M,并让该 M 去执行 gp。
  • startm():唤醒或创建一个 M 去关联参数的 _p_ 并开始执行。

为了直观地看清 Go 调度器对这些函数的运用,看下图:

下面按序号介绍每一步操作都做了什么:

  1. 检查当前 M 是否已与某个 G 锁定,如果锁定存在,调度器就会调用 stoplockedm 函数停止当前 M。stoplockedm 函数会先解除当前 M 与本地 P 之间的关联,并通过调用一个名为 handoffp 的函数把这个 P 转手给其他 M,在这个转手 P 的过程中会简介调用 startm 函数。一旦这个 P 被转手, stoplockedm 函数就会停止当前 M 的执行,并等待唤醒。
  2. 如果调度程序为当前 M 找到了一个可运行的 G,却发现该 G 已经与某个 M 锁定了,那么就会调用 startlockedm 函数并把这个 G 作为参数传入。 startlockedm 函数会通过参数 gp 的 lockedm 字段找到与之锁定的那个 M,并把当前 M 的本地 P 转手给它。这里的转手 P 的过程要比 1 中的简单很多, startlockedm 函数会先解除当前 M 与本地 P 之间的关联,然后把这个 P 赋给已锁 M 的 nextp 字段,即预联。
  3. startlockedm 函数的执行会使与其参数 gp 锁定的那个 M 被唤醒,通过 gp 的 lockedm 字段可以找到已锁 M。一旦已锁 M 被唤醒,就会与和它预联的 P 产生正式的关联,并去执行与之关联的G。
  4. startlockedm 函数在最后会调用 stopm 函数,先把当前 M 放入调度器的空闲 M 列表,然后停止当前 M。这里被停止的 M ,可能会在之后因有 P 需要转手,或者有 G 需要执行而被唤醒。
  5. 调度器在执行调度时,也会检查思否有串行运行任务正在等待执行。如果有,调度器就会钓调用 gcstopm 函数停止当前 M。gcstopm 函数会先通过当前 M 的 spinning 字段检查它的自旋状态,如果其值为 true,就把 false 值赋给他,然后把调度器中用于记录自旋 M 数量的 nmspinning 字段的值减1。在这之后,gcstopm函数会释放本地 P,并将其状态啊设置为 Pgcstop,然后再去自减并检查调度器的 stopwait 字段,并在发现 stopwait 字段为0时,通过调度器的 stopnote 字段唤醒等待执行的串行运行时任务。
  6. gcstopm 函数在最后会调用 stopm 函数,当前 M 会被放入调度器的空闲 M 列表并停止。
  7. 调度总有不成功的时候,如果经过一轮调度之后,仍找不到一个可运行的 G 给当前 M 执行,那么调度程序就会通过调用 stopm 函数停止当前的 M,等待被唤醒。
  8. 所有经过由调用 stopm 函数停止的 M,都可以通过调用 startm 函数唤醒,比如有了新的 P 或者有了新的 M。

通过上面这8步操作,我们可以得出如下结论:

  • 调度器因 M 和 G 的锁定而执行的分支流程,这里存在两个 M,一个是因等待执行与之锁定的 G 而停止的 M,另一个是获取到一个已锁定的 G 却不能执行的 M。
  • 一旦 M 要停止就会把它本地 P 转手给别的 M,一旦 M 被唤醒,就会先找到一个 P 与之关联,并且这个 P 一定是在该 M 被唤醒之前由别的 M 预留给它的。

抢占调度和定时GC

由 runtime.sysmon 方法完成

我们可以看到,监测任务主要做了如下几件事:

  • 在需要时抢夺符合条件的 P 和 G(retake方法)
  • 在需要时进行强制 GC
  • 在需要时清扫堆
  • 在需要时打调度器跟踪信息

监测变量:

  • idle:最近已连续由多少次监测任务执行但未能成功夺取 P,一旦某次成功夺取,其值就会被清零。
  • delay:睡眠的具体时间。

参考: