GMP | 豆包MarsCode AI刷题

48 阅读33分钟

一、协程初探

1.1 进程与线程

协程与操作系统中的线程、进程具有紧密的联系。为了深入理解 协程,必须对进程、线程及上下文切换等概念有所了解。在计算机科学中,线程是可以由调度程序(通常是操作系统的一部分)独立管理 的最小程序指令集,而进程是程序运行的实例。

在大多数情况下,线程是进程的组成部分。一个进程中可以存在多个线程,这些线程并发执行并共享进程的内存 (例如全局变量)等资源。而进程之间相对独立,不同进程具有不同的内存地址空间、代表程序运行的机器码、进程状态、操作系统资源描述符等。

在一个进程内部,可能有多个线程被同时处理。追求高并发处理、高性能的程序或者库一般都会设计为多线程。

那为什么程序通常 不采取多进程,而采取多线程的方式进行设计呢?

这是因为开启一个新进程的开销要比开启一个新线程大得多,而且进程具有独立的内存空间,这使得多进程之间的共享通信更加困难。

操作系统调度到CPU中执行的最小单位是线程。在传统的单核 (Core)CPU上运行的多线程应用程序必须交织线程,交替抢占CPU的时间片,如下图所示。但是,现代计算机系统普遍拥有多核处理器。在多核CPU上,线程可以分布在多个CPU核心上,从而实现真正的并行处理。

1.2 线程上下文切换

虽然多核处理器可以保证并行计算,但是实际中程序的数量以及 实际运行的线程数量会比CPU核心数多得多。因此,为了平衡每个线程 能够被CPU处理的时间并最大化利用CPU资源,操作系统需要在适当的 时间通过定时器中断(Timer Interrupt)、I/O设备中断、系统调用 时执行上下文切换(Context Switch)

如下图所示,当发生线程上下文切换时,需要从操作系统用户态转移到内核态,记录上一个线程的重要寄存器值(例如栈寄存器 SP)、进程状态等信息,这些信息存储在操作系统线程控制块 (Thread Control Block)中。当切换到下一个要执行的线程时,需要加载重要的CPU寄存器值,并从内核态转移到操作系统用户态。如果线程在上下文切换时属于不同的进程,那么需要更新额外的状态信息 及内存地址空间,同时将新的页表(Page Tables)导入内存。

进程之间的上下文切换最大的问题在于内存地址空间的切换导致的缓存失效(例如CPU中用于缓存虚拟地址与物理地址之间映射的TLB 表),所以不同进程的切换要显著慢于同一进程中线程的切换。现代的CPU使用了快速上下文切换(Rapid Context Switch)技术来解决不 同进程切换带来的缓存失效问题。

1.3 线程与协程

在Go语言中,协程被认为是轻量级的线程。和线程不同的是,操作系统内核感知不到协程的存在,协程的管理依赖Go语言运行时自身提供的调度器。同时,Go语言中的协程是从属于某一个线程的。为什 么Go语言需要在线程的基础上抽象出协程的概念,而不是直接操作线程?要回答这个问题,就需要深入地理解线程与协程的区别。

1.3.1 调度方式

协程是用户态的。协程的管理依赖Go语言运行时的调度器。同时,Go语言中的协程是从属于某一个线程的,协程与线程的对应关系为M:N,即多对多,如下图所示。Go语言调度器可以将多个协程调度到一个线程中,一个协程也可能切换到多个线程中执行。

1.3.2 上下文切换的速度

协程的速度要快于线程,其原因在于协程切换不用经过操作系统用户态与内核态的切换,并且Go语言中的协程切换只需要保留极少的 状态和寄存器变量值(SP/BP/PC),而线程切换会保留额外的寄存器变量值(例如浮点寄存器)。上下文切换的速度受到诸多因素的影响,这里列出一些值得参考的量化指标:线程切换的速度大约为1~2 微秒,Go语言中协程切换的速度比它快数倍,为0.2微秒左右。

1.3.3 调度策略

线程的调度在大部分时间是抢占式的,操作系统调度器为了均衡每个线程的执行周期,会定时发出中断信号强制执行线程上下文切换。而Go语言中的协程在一般情况下是协作式调度的,当一个协程处理完自己的任务后,可以主动将执行权限让渡给其他协程。这意味着协程可以更好地在规定时间内完成自己的工作,而不会轻易被抢占。 当一个协程运行了过长时间时,Go语言调度器才会强制抢占其执行。

1.3.4 栈的大小

线程的栈大小一般是在创建时指定的,为了避免出现栈溢出 (Stack Overflow),默认的栈会相对较大(例如2MB) ,这意味着每 创建1000个线程就需要消耗2GB的虚拟内存,大大限制了线程创建的数量(64位的虚拟内存地址空间已经让这种限制变得不太严重)。而Go 语言中的协程栈默认为2KB,在实践中,经常会看到成千上万的协程存在。 同时,线程的栈在运行时不能更改,但是Go语言中的协程栈在Go 运行时的帮助下会动态检测栈的大小,并动态地进行扩容。因此,在实践中,可以将协程看作轻量的资源。

1.4 并行与并发

在Go语言的程序设计中,有两个非常重要但容易被误解的概念, 分别是并发(concurrency)与并行(parallelism)。通俗来讲,并发指同时处理多个任务的能力,这些任务是独立的执行单元。 并发并不意味着同一时刻所有任务都在执行,而是在一个时间段内,所有的任务都能执行完毕。因此,开发者对任意时刻具体执行的是哪一个任务并不关心。如下图所示,在单核处理器中,任意一个时刻只能执行一个具体的线程,而在一个时间段内,线程可能通过上 下文切换交替执行。多核处理器是真正的并行执行,因为在任意时刻,可以同时有多个线程在执行。 在实际的多核处理场景中,并发与并行常常是同时存在的,即多核在并行地处理多个线程,而单核中的多个线程又在上下文切换中交替执行。 由于Go语言中的协程依托于线程,所以即便处理器运行的是同一个线程,在线程内Go语言调度器也会切换多个协程执行,这时协程是并发的。如果多个协程被分配给了不同的线程,而这些线程同时被不同的CPU核心处理,那么这些协程就是并行处理的。因此在多核处理场 景下,Go语言的协程是并发与并行同时存在的。

但是,协程的并发是一种更加常见的现象,因为处理器的核心是有限的,而一个程序中的协程数量可以成千上万,这就需要依赖Go语言调度器合理公平地调度。

1.5 GMP模型

Go语言中经典的GMP的概念模型生动地概括了线程与协程的关系: Go进程中的众多协程其实依托于线程,借助操作系统将线程调度到CPU 执行,从而最终执行协程。在GMP模型中,G代表的是Go语言中的协程 (Goroutine),M代表的是实际的线程,而P代表的是Go逻辑处理器 (Process),Go语言为了方便协程调度与缓存,抽象出了逻辑处理器。G、M、P之间的对应关系如下图所示。在任一时刻,一个P可能在其本地包含多个G,同时,一个P在任一时刻只能绑定一个M。下图中没有涵盖的信息是:一个G并不是固定绑定同一个P的,有很多情况 (例如P在运行时被销毁)会导致一个P中的G转移到其他的P中。同样的,一个P只能对应一个M,但是具体对应的是哪一个M也是不固定的。 一个M可能在某些时候转移到其他的P中执行。

二、深入协程设计与调度原理

2.1 协程的生命周期与状态转移

协程并不只有创建和死亡两种状态。为了便于对协程进行管理, Go语言的调度器将协程分为多种状态,协程的状态与转移如下图所示

状态含义
_Gidle_Gidle为协程刚开始创建时的状态,当新创建的协程初始化后,会变为_Gdead状态,_Gdead状态也是协程被销毁时的状态
_Grunnable_Grunnable表示当前协程在运行队列中,正在等待运行
_Grunning_Grunning代表当前协程正在被运行,已经被分配给了逻辑处理器和线程
_Gwaiting_Gwaiting表示当前协程在运行时被锁定,不能执行用户代码。 在垃圾回收及channel通信时经常会遇到这种情况
_Gsyscall_Gsyscall代表当前协程正在执行系统调用
_Gpreempted_Gpreempted是Go 1.14新加的状态,代表协程G被强制抢占后的状态
_Gcopystack_Gcopystack代表在进行协程栈扫描时发现需要扩容或缩小协程 栈空间,将协程中的栈转移到新栈时的状态。

还有几个状态(_Gscan、_Gscanrunnable、_Gscanrunning等)涉及垃圾回收阶段

2.2 特殊协程g0与协程切换

之前介绍过,一般的协程有main协程与子协程,main协程在整个程序中只有一个。深入Go语言运行时会发现,每个线程中都有一个特殊的协程g0

type m struct{
    g0 *g //grountine with scheduling stack
    ...
}

协程g0运行在操作系统线程栈上,其作用主要是执行协程调度的一系列运行时代码,而一般的协程无差别地用于执行用户代码。很显然,执行用户代码的任何协程都不适合进行全局调度。 在用户协程退出或者被抢占时,意味着需要重新执行协程调度, 这时需要从用户协程g切换到协程g0,协程g与协程g0的对应关系如下图所示。要注意的是,每个线程的内部都在完成这样的切换与调度循环。

协程经历g→g0→g的过程,完成了一次调度循环。和线程类似, 协程切换的过程叫作协程的上下文切换。当某一个协程g执行上下文切换时需要保存当前协程的执行现场,才能够在后续切换回g协程时正常执行。协程的执行现场存储在g.gobuf结构体中,g.gobuf结构体主要保存CPU中几个重要的寄存器值,分别是rsp、rip、rbp。

rsp寄存器始终指向函数调用栈栈顶,rip寄存器指向程序要执行 的下一条指令的地址,rbp存储了函数栈帧的起始位置

type g struct{
    sched gobuf
    ...
}

type gobuf struct{
    //保存CPU的rsp寄存器的值
    sp uniptr
    //保存CPU的rip寄存器的值
    pc uniptr
    //记录当前这个gobuf对象属于哪个goroutine
    g guniptr
    //保存系统调用的返回值
    ret sys.Uintreq
    //保存CPU的rbp寄存器的值
    bp uniptr
    ...
}

特殊的协程g0与执行用户代码的协程g有显著不同,g0作为特殊的调度协程,其执行的函数和流程相对固定(这涉及调度循环的流程, 在后续小节会详细介绍),并且,为了避免栈溢出,协程g0的栈会重复使用。而每个执行用户代码的协程,可能都有不同的执行流程。每次上下文切换回去后,会继续执行之前的流程。

2.3 线程本地存储与线程绑定

线程本地存储是一种计算机编程方法,它使用线程本地的静态或全局内存。和普通的全局变量对程序中的所有线程可见不同,线程本地存储中的变量只对当前线程可见。因此,这种类型的变量可以看作 是线程“私有”的。一般地,操作系统使用FS/GS段寄存器存储线程本地变量。

在Go语言中,并没有直接暴露线程本地存储的编程方式,但是Go 语言运行时的调度器使用线程本地存储将具体操作系统的线程与运行 时代表线程的m结构体绑定在一起。如下所示,线程本地存储的实际是结构体m中m.tls的地址,同时m.tls[0]会存储当前线程正在运行的协 程g的地址,因此在任意一个线程内部,通过线程本地存储,都可以在任意时刻获取绑定到当前线程上的协程g、结构体m、逻辑处理器P、特殊协程g0等信息。

type m struct {
   ...
   tls [6]uniptr
}

2.4 调度循环

调度循环指从调度协程g0开始,找到接下来将要运行的协程g、再从协程g切换到协程g0开始新一轮调度的过程。它和上下文切换类似, 但是上下文切换关注的是具体切换的状态,而调度循环关注的是调度的流程

下图所示为调度循环的整个流程。从协程g0调度到协程g,经历了从schedule函数到execute函数再到gogo函数的过程。其中,schedule函数处理具体的调度策略,选择下一个要执行的协程; execute函数执行一些具体的状态转移、协程g与结构体m之间的绑定等操作;gogo函数是与操作系统有关的函数,用于完成栈的切换及CPU寄 存器的恢复。

执行完毕后,切换到协程g执行。当协程g主动让渡、被抢占或退出后,又会切换到协程g0进入第二轮调度。在从协程g切换回协程g0 时,mcall函数用于保存当前协程的执行现场,并切换到协程g0继续执行,mcall函数仍然是和平台有关的汇编指令。切换到协程g0后会根据切换原因的不同执行不同的函数,例如,如果是用户调用Gosched函数 则主动让渡执行权,执行gosched_m函数,如果协程已经退出,则执行 goexit函数,将协程g放入p的freeg队列,方便下次重用。 执行完毕后,再次调用schedule函数开始新一轮的调度循环,从而形成一个完整的闭环,循环往复。

2.5 调度策略

调度的核心策略位于schedule函数中。

//runtime/proc.go
func schedule(){
    ...
}

在schedule函数中,首先会检测程序是否处于垃圾回收阶段,如果是,则检测是否需要执行后台标记协程

之前介绍过,程序中不可能同时执行成千上万个协程,那些等待被调度执行的协程存储在运行队列中。Go语言调度器将运行队列分为局部运行队列全局运行队列。局部运行队列是每个P特有的长度为 256的数组,该数组模拟了一个循环队列,其中runqhead标识了循环队 列的开头,runqtail标识了循环队列的末尾。每次将G放入本地队列时,都从循环队列的末尾插入,而获取时从循环队列的头部获取。

除此之外,在每个P内部还有一个特殊的runnext字段标识下一个要执行的协程。如果runnext不为空,则会直接执行当前runnext指向的协程,而不会去runq数组中寻找。

type p struct {
    //使用数组实现循环队列
    runq [256]guintptr
    runnext guintptr
    ...
}

被所有P共享的全局运行队列存储在schedt.runq中。

type schedt struct{
    ...
    runq qQueue    
}    

因此,之前的GMP模型可以改进为下图。

一般的思路是先查找每个P局部的运行队列,当获取不到局部运行队列时,再从全局队列中获取。但是这种方法可能存在一个问题,如果只是循环往复地执行局部运行队列中的G,那么全局队列中的G可能完全不会执行。为了避免这种情况,Go语言调度器使用了一种策略:P 中每执行61次调度,就需要优先从全局队列中获取一个G到当前P中, 并执行下一个要执行的G。

if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
        lock(&sched.lock)
        gp = globrunqget(_p_, 1)
        unlock(&sched.lock)
        if gp != nil {
            return gp, false, false
        }
    }

调度协程的优先级与顺序如下图所示。排除从全局队列中获取这种情况,每个P在执行调度时,都会先尝试从runnext中获取下一个执行的G如果runnext为空,则继续从当前P中的局部运行队列runq中 获取需要执行的G如果局部运行队列为空,则尝试从全局运行队列中获取需要执行的G如果全局队列也没有找到要执行的G,则会尝试从其他的P中窃取可用的协程。到这一步,正常的程序基本都能获取到要运行的G,如果窃取不到任务,那么当前的P会解除与M的绑定,P会被放入空闲P队列中,而与P绑定的M没有任务可做,进入休眠状态。

下面详细介绍调度的具体过程。

2.5.1 获取本地运行队列

调度器首先查看runnext成员是否为空,如果不为空则返回对应的 G,如果为空则继续从局部运行队列中寻找。当循环队列的头 (runqhead)和尾(runqtail)相同时,意味着循环队列中没有任何要运行的协程。否则,意味着存在可用的协程,从循环队列头部获取 一个协程返回。需要注意的是,虽然在大部分情况下只有当前G访问局部运行队列,但是可能存在其他P窃取任务造成同时访问的情况,因此,在这里访问时需要加锁

func runqget(_p_ *p) (gp *g, inheritTime bool) {
   for{
       next := _p_.runnext
       if next==0{
           break
       }
       if _p_.runnext.cas(next,0) {
           return  next.ptr(),true
       }
   
   }
    for {
        h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers
        t := _p_.runqtail
        if t == h {
            return nil, false
        }
        gp := _p_.runq[h%uint32(len(_p_.runq))].ptr()
        if atomic.CasRel(&_p_.runqhead, h, h+1) { // cas-release, commits consume
            return gp, false
        }
    }
  }  

2.5.2 获取全局运行队列

当P每执行61次调度,或者局部运行队列中不存在可用的协程时, 都需要从全局运行队列中查找一批协程分配给本地运行队列,如下图所示。

全局运行队列的数据结构是一根链表。由于每个P都共享了全局运行队列,因此为了保证公平,先根据P的数量平分全局运行队列中的 G,同时,要转移的数量不能超过局部队列容量的一半(当前是 256/2=128个),再通过循环调用runqput将全局队列中的G放入P的局部运行队列中

func globrunqget(_p_ *p, max int32) *g {
    if sched.runqsize == 0 {
        return nil
    }
    n := sched.runqsize/gomaxprocs + 1
    if n > sched.runqsize {
        n = sched.runqsize
    }
    if max > 0 && n > max {
        n = max
    }
    if n > int32(len(_p_.runq))/2 {
        n = int32(len(_p_.runq)) / 2
    }


    sched.runqsize -= n


    gp := sched.runq.pop()
    n--
    for ; n > 0; n-- {
        gp1 := sched.runq.pop()
        runqput(_p_, gp1, false)
    }
    return gp
 }

细心的人会有疑问,如果本地运行队列已经满了,那么无法从全局运行队列调用并放入怎么办?如下图所示,如果本地运行队列满了,那么调度器会将本地运行队列的一半放入全局运行队列。这保证了当程序中有很多协程时,每个协程都有执行的机会。

2.5.3 获取准备就绪的网络协程

虽然很少见,但是局部运行队列和全局运行队列都找不到可用协程的情况仍有可能发生。这时,调度器会寻找当前是否有已经准备好运行的网络协程。Go语言中的网络模型其实是对不同平台上I/O多路复用技术(epoll/kqueue/iocp)的封装,本文不会对其进行详细介绍。 runtime.netpoll函数获取当前可运行的协程列表,返回第一个可运行的协程。并通过injectglist函数将其余协程放入全局运行队列等待被调度

if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Load64(&sched.lastpoll) != 0 {
        if list := netpoll(0); !list.empty() { // non-blocking
            gp := list.pop()
            injectglist(&list)
            casgstatus(gp, _Gwaiting, _Grunnable)
            return gp, false
        }
  }

2.5.4 协程窃取

当局部运行队列、全局运行队列以及准备就绪的网络列表中都找不到可用协程时,需要从其他P的本地队列中窃取可用的协程执行。所有的P都存储在全局的allp []*p中,一种可以想到的简单方法是循环遍历allp,找到可用的协程,但是这种方法缺少公平性。为了既保证随机性,又保证allp数组中的每个P都能被依次遍历,Go语言采取了一种独特的方式,其代码位于findrunnable函数中

    const stealTries = 4
    for i := 0; i < stealTries; i++ {
        stealTimersOrRunNextG := i == stealTries-1


        for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
            // ...
        }
    }

第2层for循环表示随机遍历allp数组,找到可窃取的P就立即窃取并返回。当遍历了一次没有找到时,再遍历一次,第1层的4个循环表 示这个操作会重复四次,第2层的循环操作涉及数学上的一些特性。我们用一个例子来说明,假设一共有8个P,第1步,fastrand函数选择一个随机数并对8取模,算法选择了一个0~8之间的随机数,假设为6;第2步,找到一个比8小且与8互质的数。 比8小且与8互质的数有4 个:coprimes=[1,3,5,7],代码中取coprimes[6%4]=5,这4个数中 任取一个都有相同的数学特性。计算过程为

(6+5)%8 = 3
(3+5)%8 = 0
(0+5)%8 = 5
(5+5)%8 = 2
(2+5)%8 = 7
(7+5)%8 = 4
(4+5)%8 = 1
(1+5)%8 = 6

可以看到,这里将上一个计算的结果作为下一个计算的条件,这 样的计算过程保证了一定会遍历到allp中的所有元素

找到要窃取的P之后就正式开始窃取了,其核心代码位于runqgrab 函数。窃取的核心逻辑比较简单,如下图所示,将要窃取的P本地运 行队列中Goroutine个数的一半放入自己的运行队列中。

func runqgrab(_p_ *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
    for {
        h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers
        t := atomic.LoadAcq(&_p_.runqtail) // load-acquire, synchronize with the producer
        //计算队列中有多少个goroutine
        n := t - h
        //窃取队列中goroutine的一半
        n = n - n/2
        ...
        for i := uint32(0); i < n; i++ {
            g := _p_.runq[(h+i)%uint32(len(_p_.runq))]
            batch[(batchHead+i)%uint32(len(batch))] = g
        }
        if atomic.CasRel(&_p_.runqhead, h, h+n) { // cas-release, commits consume
            return n
        }
    }
}

2.5.5 调度时机

上一节介绍了调度器调度时的策略,这里还有一个重要的问题: 什么时候会发生调度?可以根据调度方式的不同,将调度时机分为主动、被动和抢占调度。

2.5.5.1 主动调度

协程可以选择主动让渡自己的执行权利,这主要是通过用户在代码中执行runtime.Gosched函数实现的。在大多数情况下,用户并不需要执行此函数,因为Go语言编译器会在调用函数之前插入检查代码, 判断该协程是否需要被抢占。但是有一些特殊的情况,例如一个密集计算,无限for循环的场景,这种场景由于没有抢占的时机,在Go 1.14版本之前是无法被抢占的。Go 1.14之后的版本对于长时间执行的协程使用了操作系统的信号机制进行强制抢占。这种方式需要进入操作系统的内核,速度比不上用户直接调度的runtime.Gosched函数。后面的小节会详细介绍强制抢占。

for{
    //do something
    ...
}

主动调度的原理比较简单,需要先从当前协程切换到协程g0,取消G与M之间的绑定关系,将G放入全局运行队列,并调用schedule函数开始新一轮的循环。

func goschedImpl(gp *g){
    ...
    casgstatus(gp,_Grunning,_Grunnable)
    //取消G与M之间的绑定关系
    dropg()
    lock(&sched.lock)
    //把G放入全局运行队列
    globrunqput(gp)
    unlock(&sched.lock)
    //进入新一轮调度
    schedule()
}       
2.5.5.2 被动调度

被动调度指协程在休眠、channel通道堵塞、网络I/O堵塞、执行 垃圾回收而暂停时,被动让渡自己执行权利的过程。被动调度具有重要的意义,可以保证最大化利用CPU的资源。根据被动调度的原因不同,调度器可能执行一些特殊的操作。由于被动调度仍然是协程发起的操作,因此其调度的时机相对明确。和主动调度类似的是,被动调 度需要先从当前协程切换到协程g0,更新协程的状态并解绑与M的关 系,重新调度。和主动调度不同的是,被动调度不会将G放入全局运行队列,因为当前G的状态不是_Grunnable而是_Gwaiting,所以,被动调度需要一个额外的唤醒机制。 下面以通道的堵塞为例说明被动调度的过程。在该例中,通道c一 直会等待通道中的消息。

func (c chan int){
    <-c
}    

当通道中暂时没有数据时,会调用gopark函数完成被动调度, gopark函数是被动调度的核心逻辑

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    // ...
    mcall(park_m)
}

gopark函数最后会调用park_m,该函数会解除G和M之间的关系, 根据执行被动调度的原因不同,执行不同的waitunlockf函数,并开始新一轮调度

func park_m(gp *g) {

    ...
    //接触G和M之间的关系
    casgstatus(gp, _Grunning, _Gwaiting)
    dropg()
    if fn := _g_.m.waitunlockf;fn!=nil {
        ok := fn(gp,_g_.m.waitlock)
        _g_.m.waitunlockf = nil
        _g_.m.waitlock = nil
     ...
     }
    schedule()
}    

如果当前协程需要被唤醒,那么会先将协程的状态从_Gwaiting转 换为_Grunnable,并添加到当前P的局部运行队列中

func ready(gp *g,tracekip int,next bool){
    ...
    casgstatus(gp,_Gwaiting,_Grunnable)
    //G放入运行队列
    runqput(_g_m.p.ptr(),gp.next)
    ...
}    
2.5.5.3 抢占调度

为了让每个协程都有执行的机会,并且最大化利用CPU资源,Go语言在初始化时会启动一个特殊的线程来执行系统监控任务。系统监控在一个独立的M上运行,不用绑定逻辑处理器P,系统监控每隔10ms会检测是否有准备就绪的网络协程,并放置到全局队列中。和抢占调度相关的是,系统监控服务会判断当前协程是否运行时间过长,或者处于系统调用阶段,如果是,则会抢占当前G的执行。其核心逻辑位于 runtime.retake函数中

func retake(now int64) uint32 {

    for i := 0; i < len(allp); i++ {
        _p_ := allp[i]
        pd := &_p_.sysmontick
        s := _p_.status
        sysretake := false
        // ...
        if s == _Psyscall || s == _Prunning {            
            // ...
            
            //如果G运行时间过长则抢占
            t := int64(_p_.schedtick)
            if int64(pd.shcedtick)!=t {
                pd.schedtick = uint32(t)
                pd.schedwhen = now
            }else if pd.schedwhen+forcePreemptNS <= now {
                //连续运行超过10ms,设置抢占请求
                preemptone(p)
                //In case of syscall,preemptone doesn't
                //work,beacuse there is no M wired to P
                sysretake = true
            }
        }
        if s == _Psyscall {
        //如果超过了一个系统监控的tick(20 us),则从系统调用中抢占p
        ...
        }    
    

在Go1.14中,如果当前协程的执行时间超过了10ms,则需要执行 抢占。如果一个协程在系统调用中超过了20微秒,则仍然需要抢占调度。接下来,我们分别分析这两种不同的情况。

2.5.5.4 执行时间过长的抢占调度

在Go 1.14之前,虽然仍然有系统监控抢占时间过长的G,调用 preemptone函数,但是抢占的时机却不太一样。preemptone函数会将 当前的preempt字段设置为true,并将stackguard0设置为 stackPreempt。stackPreempt常量0xfffffffffffffade是一个非常大 的数,设置stackguard0使调度器能够处理抢占请求

func preemptone(_p_ *p) bool {
    mp := _p_.m.ptr()
    gp := mp.curg
    //设置抢占标志
    gp.preempt = true
    gp.stackguard0 = stackPreempt
    return true
}    

调度发生的时机主要在执行函数调用阶段。函数调用是一个比较安全的检查点,Go语言编译器会在函数调用前判断stackguard0的大 小,从而选择是否调用runtime.morestack_noctxt函数。 morestack_noctxt为汇编函数,函数的执行流程如下: morestack_noctxt()→morestack()→newstack()。

newstack函数中的一般核心逻辑是判断G中stackguard0字段的大 小,并调用gopreempt_m函数切换到g0,取消G与M之间的绑定关系,将G的状态转换为_Grunnable,将G放入全局运行队列,并调用schedule 函数开始新一轮调度循环。

func newstack(){
    preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
    if preempt {
        ...
        gopreempt_m(gp)
    }
}        

这种抢占的方式面临着一定的问题,当执行过程中没有函数调用,而只有类似如下代码时,协程将没有被抢占的机会。

for {
 i++
}

为了解决这一问题,Go 1.14之后引入了信号强制抢占的机制。 这需要借助下图中的类UNIX操作系统信号处理机制,信号是发送给进程的各种通知,以便将各种重要的事件通知给进程。最常见的是用户发送给进程的信号,例如时常使用的CTRL+C键,或者在命令行 中输入的kill-指令。通过信号,借助操作系统中断当前程序,保存程序的执行状态和寄存器值,并切换到内核态处理信号。在内核态处理完信号后,还会返回到用户态执行程序注册的信号 处理函数,之后再回到内核,恢复程序原始的栈和寄存器值,并切换到用户态继续执行程序

Go语言借助用户态在信号处理时完成协程的上下文切换的操作, 需要借助进程对特定的信号进行处理。并不是所有的信号都可以被处理,例如SIGKILL与SIGSTOP信号用于终止或暂停程序,不能被程序捕获处理。Go程序在初始化时会初始化信号表,并注册信号处理函数。 在这里,我们关注抢占时的信号处理。 在抢占时,调度器通过向线程中发送sigPreempt信号,触发信号处理。在UNIX操作系统中,sigPreempt为_SIGURG信号,由于该信号不 会被用户程序和调试器使用,因此Go语言使用它作为安全的抢占信号,关于信号具体的选择过程,可以参考Go源码中对_SIGURG信号的注释。

在抢占时,调度器通过向线程中发送sigPreempt信号,触发信号 处理。在UNIX操作系统中,sigPreempt为_SIGURG信号,由于该信号不会被用户程序和调试器使用,因此Go语言使用它作为安全的抢占信号,关于信号具体的选择过程,可以参考Go源码中对_SIGURG信号的注释

func preemptM(mp *m){
     ...
     if atomic.Cas(&mp.signalPending,0,1) {
         signalM(mp,sigPreempt)
     }

进程进行信号处理的核心逻辑位于sighandler函数中,在进行信号处理时,当遇到sigPreempt抢占信号时,触发运行时的异步抢占机制

func sighandler(sig uint32,info *siginfo,ctxt unsafe.Pointer,gp *p){
    ...
    if sig==sigPreempt {
        doSigPreempt(gp,c)
    }    

doSigPreempt函数是平台相关的汇编函数。其中的重要一步是修改了原程序中rsp、rip寄存器中的值,从而在从内核态返回后,执行新的函数路径。在Go语言中,内核返回后执行新的asyncPreempt函 数。asyncPreempt函数会保存当前程序的寄存器值,并调用 asyncPreempt2函数。当调用asyncPreempt2函数时,根据preemptPark 函数或者gopreempt_m函数重新切换回调度循环,从而打断密集循环的继续执行

func asyncPreempt2(){
    gp := getg()
    gp.asyncSafePoint = true
    if gp.preemptStop {
         mcall(preemptPark)
    } else {
         mcall(gopreempt_m)
    }
    gp.asyncSafePoint = false     
}    

抢占调度的执行流程如下图所示

当发生系统调用时,当前正在工作的线程会陷入等待状态,等待 内核完成系统调用并返回。当发生下面3种情况之一时,需要抢占调度

1、当前局部运行队列中有等待运行的G。在这种情况下,抢占调 度只是为了让局部运行队列中的协程有执行的机会,因为其一般是当 前P私有的。

2、当前没有空闲的P和自旋的M。如果有空闲的P和自旋的M,说明 当前比较空闲,那么释放当前的P也没有太大意义。

3、当前系统调用的时间已经超过了10ms,这和执行时间过长一 样,需要立即抢占

func retake(now int64) uint32 {
    //遍历所有的p
    for i := 0; i < len(allp); i++ {
        ...
        //P处于系统调用之中,检查是否需要抢占
        if s == _Psyscall {            
            // ...
            //如果已经超过了一个系统监控的tick(20微秒),则从系统调用中抢占P
            if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
                continue
            }
           ...
        }   

系统调用时的抢占原理主要是将P的状态转化为_Pidle,这仅仅是 完成了第1步。我们的目的是让M接管P的执行,主要的逻辑位于 handoffp函数中,该函数需要判断是否需要找到一个新的M来接管当前 的P。当发生如下条件之一时,需要启动一个M来接管:

1.本地运行队列中有等待运行的G。

2.需要处理一些垃圾回收的后台任务。

3.所有其他P都在运行G,并且没有自旋的M。

4.全局运行队列不为空。

5.需要处理网络socket读写等事件

当这些条件都不满足时,才会将当前的P放入空闲队列中。 当寻找可用的M时,需要先在M的空闲列表中查找是否有闲置的M, 如果没有,则向操作系统申请一个新的M,即线程。不管是唤醒闲置的线程还是新启动一个线程,都会开始新一轮调度

这里有一个重要的问题——工作线程的P被抢占,系统调用的工作 线程从内核返回后会怎么办呢?这涉及系统调用之前和之后执行的一 系列逻辑。在执行实际操作系统调用之前,运行时调用了 reentersyscall函数。该函数会保存当前G的执行环境,并解除P与M之 间的绑定,将P放置到oldp中。解除绑定是为了系统调用返回后,当前的线程能够绑定不同的P,但是会优先选择oldp(如果oldp可以被绑定)

func reentersyscall(pc, sp uintptr) {
    // ...
    save(pc, sp)
    _g_.syscallsp = sp
    _g_.syscallpc = pc
    casgstatus(_g_, _Grunning, _Gsyscall)
    // ...


    pp := _g_.m.p.ptr()
    pp.m = 0     //P解除与M之间的绑定
    _g_.m.oldp.set(pp)
    _g_.m.p = 0  //M解除与P之间的绑定
    atomic.Store(&pp.status, _Psyscall)
    // ...
    _g_m.locks--
 }

当操作系统内核返回系统调用后,被堵塞的协程继续执行,调用 exitsyscall函数以便协程重新执行

func exitsyscall() {
    g := getg()
    
    // ...
    //尝试绑定P
    if exitsyscallfast(oldp) {
        // ...
        casgstatus(_g_, _Gsyscall, _Grunning)
        // ...
        return
    }


    // ...
    //绑定P失败,执行exitsyscall0函数
    mcall(exitsyscall0)
    // ...
}

由于在系统调用前,M与P解除了绑定关系,因此现在exitsyscall 函数希望能够重新绑定P。寻找P的过程分为三个步骤:

1、尝试能否使用之前的oldp,如果当前的P处于_Psyscall状态, 则说明可以安全地绑定此P。

2、当P不可使用时,说明其已经被系统监控线程分配给了其他的 M,此时加锁从全局空闲队列中寻找空闲的P。 3、如果空闲队列中没有空闲的P,则需要将当前的G放入全局运行 队列,当前工作线程进入睡眠状态。当休眠被唤醒后,才能继续开始 调度循环。

三、总结

运行时的协程调度器是Go语言能够并发执行成千上万个协程的核 心,贯穿Go程序运行的整个生命周期。调度器的工作是在适当的时机 将合适的协程分配到合适的位置,在调度过程中需要保证公平和效 率。在探究Go语言调度器的过程中,两个核心的问题是何时发生调度 以及调度的策略是什么。

协程可以主动让渡自己的执行权利,也可以在发生锁或者通道堵 塞时被动让渡自己的执行权利。除此之外,为了让每个协程都有执行 的机会,并且最大化利用CPU资源,在Go语言初始化时会启动一个特殊 的线程来执行系统监控服务。系统监控会判断协程是否需要执行垃圾 回收或者当前协程是否运行时间过长或处于系统调用阶段,在这些情 况下,调度器将借助操作系统信号机制或者抢占逻辑处理器实现抢占 调度。当触发协程调度后,当前线程将切换到g0栈进入调度循环,依 靠调度器获取最佳的协程继续执行。通过协调本地协程运行队列与全 局协程运行队列,调度器实现了协程之间公平并高效地执行。

Go语言调度器的设计为复杂场景下公平高效的资源调度提供了很 好的示范,也是深入理解Go程序运行的关键组件,是非常值得学习的专题。