关于Go语言调度器实现细节的补充分析

在之前的一篇博文中,简单的介绍了一下Go调度器的原理。而进行了一番深入分析后,发现Go的调度器代码中存在许多值得玩味的细节,不仔细体会可能很难发觉作者的匠心。原本打算再写一篇文章系统的分析一下这些细节,但无意中发现了另一位爱好Go的朋友已经做类似的工作,并且结构非常清晰,内容也较准确(链接在这里),为了避免造成雷同就放弃了之前的计划,转而罗列一些个人认为的Go语言中比较有意思的点,分别展开介绍一下。文章结构随性,如果感觉缺少联系,请参考其他资料。

Goroutine 状态的演变

在讲解操作系统进程调度的部分时,几乎所有的书籍都会先列出一张进程的状态迁移图,通过状态图,能很清晰的把进程调度的每个环节串联起来,方便理解。

Go运行时的调度器其实可以看成OS调度器的某种简化版本,一个goroutine在其生命周期之中,同样包含了各种状态的变换。弄清了这些状态及状态间切换的原理,对搞清整个Go调度器会非常有帮助。

好了,以下是我总结的一张goroutine的状态迁移图,圆形框表示状态,箭头及文字信息表示切换的方向和条件:

下面来简单分析一下, 其中状态 Gidle 在Go调度器代码中并没有被真正被使用到,所以直接忽略。事实上,一旦runtime新建了一个goroutine结构,就会将其状态置为Grunnable并加入到任务队列中,因此我们以该状态作为起点进行介绍:

  • Grunnable: Go 语言中,包括用户入口函数main·main的执行goroutine在内的所有任务,都是通过runtime·newproc/runtime·newproc1 这两个函数创建的,前者其实就是对后者的一层封装,提供可变参数支持,Go语言的go关键字最终会被编译器映射为对runtime·newproc的调用。当runtime·newproc1完成了资源的分配及初始化后,新任务的状态会被置为Grunnable,然后被添加到当前 P 的私有任务队列中,等待调度执行。

    从图中我们可以看到,还有几条通向Grunnable的路径:当某个阻塞任务(状态为Gwaiting)的等待条件满足而被唤醒时——如一个任务G#1向某个channel写入数据将唤醒之前等待读取该channel数据的任务G#2——G#1通过调用runtime·readyG#2状态重新置为Grunnable并添加到任务队列中。关于任务阻塞,稍后还很详细介绍。另外的路径是从GrunningGsyscall状态变换到Grunnable,我们也都合并到后面介绍。

    总之,处于Grunnable的任务一定在某个任务队列中,随时等待被调度执行。

  • Grunning: 所有状态为Grunnable的任务都可能通过findrunnable函数被调度器(P&M)获取,进而通过execute函数将其状态切换到Grunning, 最后调用runtime·gogo加载其上下文并执行。

    前面讲过Go本质采用一种协作式调度方案,一个正在运行的任务,需要通过调用yield的方式显式让出处理器;在Go1.2之后,运行时也开始支持一定程度的任务抢占——当系统线程sysmon发现某个任务执行时间过长或者runtime判断需要进行垃圾收集时,会将任务置为”可被抢占“的,当该任务下一次函数调用时,就会让出处理器并重新切会到Grunnable状态。关于Go1.2中抢占机制的实现细节,后面又机会再做介绍。

  • Gsyscall: 这个状态其实在介绍调度器那篇文章中就已经提及了——Go运行时为了保证高的并发性能,当会在任务执行OS系统调用前,先调用runtime·entersyscall函数将自己的状态置为Gsyscall——如果系统调用是阻塞式的或者执行过久,则将当前MP分离——当系统调用返回后,执行线程调用runtime·exitsyscall尝试重新获取P,如果成功且当前任务没有被抢占,则将状态切回Grunning并继续执行;否则将状态置为Grunnable,等待再次被调度执行。

  • Gwaiting: 当一个任务需要的资源或运行条件不能被满足时,需要调用runtime·park函数进入该状态,之后除非等待条件满足,否则任务将一直处于等待状态不能执行。除了之前举过的channel的例子外,Go语言的定时器、网络IO操作都可能引起任务的阻塞。

    runtime·park函数包含3个参数,第一个是解锁函数指针,第二个是一个Lock指针,最后是一个字符串用以描述阻塞的原因。

    很明显,前两个参数是配对的结构——由于任务阻塞前可能获得了某些Lock,这些Lock必须在任务状态保存完成后才能释放,以避免数据竞争。我们知道channel必须通过Lock确保互斥访问,一个阻塞的任务G#1需要将自己放到channel的等待队列中,如果在完成上下文保存前就释放了Lock,则可能导致G#2将未知状态的G#1置为Grunnable,因此释放Lock必须在runtime·park内完成。

    由于阻塞时任务持有的Lock类型不尽相同——如Select操作的锁实际上是一组Lock的集合——因此需要特别指出Unlock的具体方式。

    最后一个参数主要是在gdb调试的时候方便发现任务阻塞的原因。

    顺便说一下,当所有的任务都处于Gwaiting状态时,也就表示当前程序进入了死锁态,不可能继续执行了,那么runtime会检测到这种情况,并输出所有Gwaiting任务的backtrace信息。

  • Gdead: 最后,当一个任务执行结束后,会调用runtime·goexit结束自己的生命——将状态置为Gdead,并将结构体链到一个属于当前P的空闲G链表中,以备后续使用。

    Go语言的并发模型基本上遵照了CSP模型,goroutine间完全靠channel通信,没有像Unix进程的waitwaitpid的等待机制,也没有类似“POSIX Thread”中的pthread_join的汇合机制,更没有像killsignal这类的中断机制。每个goroutine结束后就自行退出销毁,不留一丝痕迹。

深入任务切换 —— m->g0和runtime·mcall的妙用

通过上面的分析,相信大家已经基本理清了goroutine执行的线索。 现在让我们再仔细观察一下状态切换的过程,首先,以Grunning状态为中心来看,把状态切换先粗略的分为两大类——

  1. Grunning变为其他状态,即goroutine退出(“用户态”)执行;
  2. 由其他状态变为Grunning状态,即被调度执行

第一类是由当前活跃的goroutine主动调用runtime相关函数完成的,是主动的;而第二类则是由runtime或其他goroutine完成的,是被动的。

对于第一类,其实还可以细分 —— 到Gsyscall的情况我们以后讨论,先来看其他几种情况,分别是:

  • 调用runtime·sched主动让出处理器,其实就对应于Go语言的yield关键字,状态切换到Grunnable
  • 调用runtime.park阻塞,状态切换到Gwaiting
  • 调用runtime·goexit结束,状态切换到Gdead

看一下这几个函数的实现,发现它们不过是一层封装,以runtime·park为例,最终会调用runtime·mcall(park0)完成真正的任务,代码如下:

void
runtime·park(void(*unlockf)(Lock*), Lock *lock, int8 reason)
{
  m->waitlock = lock;
  m->waitunlockf = unlockf;
  g->waitreason = reason;
  runtime·mcall(park0);
}

其他几个也类似,分别调用了runtime·mcall(sched0)runtime·mcall(goexit0)

先来看看这个runtime·mcall函数的功能。它具体定义在src/pkg/runtime/asm_[arm|amd64|386].s中,是用汇编语言实现的。以下是截取该文件中对mcall函数的注释:

// void mcall(void (*fn)(G*))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched) to keep running g.

这段注释意思简明清楚 —— runtime.mcall函数接受一个以G*为参数的函数指针fn,执行时会先将当前任务g的上下文保存到g->sched结构中,然后切换到m->g0的栈空间,再调用fn,参数就是当前任务指针g。对runtime.mcall函数的调用是不会返回的,除非调用gogo(&g->sched)

也就是说,在执行runtime.mcall(park0)之后,会进入park0(g)继续执行。我们再看看park0的实现代码:

static void
park0(G *gp)
{
  gp->status = Gwaitng;
  gp->m = nil;
  m->curg = nil;
  if (m->waitunlockf) {
      m->waitunlockf(m->waitlock);
      m->waitunlockf = nil;
      m->waitlock = nil;
  }
  if (m->lockedg) {
      stoplckedm();
      execute(gp); // Never return
  }
  schedule();
}

这个函数的功能也很简单:

  1. gp状态切换到Gwaitng
  2. gpm分离
  3. 释放在用户态时持有的锁(如果有的话)
  4. 最后,如果mgp是强制绑定的,那么m线程会等待gp状态变为Grunnable后再将其调度执行;否则直接调用schedule重新选择可执行的任务

其他几个函数实现也是类似的结构,只需将步骤3替换成对应的操作 —— 对于sched0就是将gp加到全局任务队列里;对于goexit0就是释放、回收gp的资源。

到这里,我们似乎搞清了前因后果,但好像还有什么地方有点模糊 —— 对了,所有这些xxxx0函数为什么都要先切换到m->g0的栈上执行呢?为什么不可以直接在当前g的栈上执行?那样不是省去了若干次上下文保存、恢复的麻烦吗?

要搞清这个问题,先要看看m->g0这个结构。我们之前说过,Go中的M对应OS线程,每个M分配时会首先创建一个g0任务,并分配大约8KB大小的栈空间。在线程创建时刻 —— 比如在Linux中,通过clone系统调用 —— 会将g0的栈绑定给对应的OS线程。

前面讲了,每个用户goroutine创建后,都会分配独立的栈(初始大小稍小一些,因为Go的栈是动态可扩展的),执行用户任务就会切换到用户任务的栈上。这样,g0的栈空间实际上就是独立于任何用户任务的,因此可以执行一些不适合在用户栈上执行的程序。这个有点类似OS中用户栈和内核栈的关系。

sched0为例说明为什么执行goroutine的切换不能在当前g的栈上完成。

假设我们这么做,那么首先要保存g的上下文状态到g->sched结构体中,然后从任务队列中选择另一个状态为Grunnable的任务g1,在将上下文切换到g1之前,我们需要先将g放回任务队列中,以便它未来还能被调度执行,然后M#1切换到g1 —— 注意,问题来了!由于Go的runtime是多线程的,因此可能同时存在多个执行线程,一旦g进入队列,那么它完全可能被另一个线程M#2调度执行。这时,M#1M#2实际上都运行在g的栈上(goroutine切换不是“原子操作”!),就可能出现数据竞争从而导致错误!

而利用m->g0的栈进行sched0这样的操作,由于不同的线程有各自独立的g0及栈空间,因而不会发生数据竞争问题。

runtime·mcall 的另一个用途

除了进行goroutine切换外,runtime·mcall还有一个功能,就是可以委托处理栈空间分配。具体来说,当一个任务通过go关键字新建任务时 —— 我们知道该操作最终会映射到runtime·newproc1函数 —— 那么就会涉及调用runtime·mstackalloc对新建任务分配栈空间的操作。

Go 1.2 的用户任务采用了“分段式栈”的实现方案,其栈空间是根据需要动态扩展的,每个函数调用点都会判断当前栈空间是否满足需要,如果不够就要追加分配。要确保调用runtime·mstackalloc时不会再出现栈分配的情形,就不能直接在用户空间上运行该函数。现在,我们很容易想到的就是利用runtime·mcall切换到g0上执行栈分配!因为g0的初始栈空间比较大,可以认为能够满足调用需要。

与之前xxxx0函数情况略有不同,在分配完栈空间后,我们希望马上切换会刚才的g,而不是触发新的调度,因此,必须直接调用runtime·gogo(&g->sched)返回——这点在runtime·mcall的注释中也说得很明白。

待续…

今天初步把Go语言任务状态变化串讲了一下,更重要的是把m->g0runtime·mcall这两个结构分析了一下。本来还想继续介绍一下有关Gsyscall状态的一些细节,不过看看时间已经接近凌晨2点了,再不休息估计明天上班会很辛苦。所以就把这个话题,连同之前讲channel时没有深入分析的“定时器”机制一起放在后面完成。


限于个人水平,本文内容不免谬误之处,欢迎大家致信我的邮箱amalcaowei@gmail.com或在讨论版留言!

Comments