文章目录

    • 一、调度器简介
    • 二、调度器的发展
      • 1、单线程调度器
      • 2、多线程调度器
      • 3、任务窃取调度器
      • 4、抢占式调度器
        • a.基于协作的抢占式调度
        • b.基于信号的抢占式调度
      • 5、非均匀内存访问调度器
      • 6、小结
    • 三、调度器流程
      • 1、调度器启动
      • 2、调度循环
      • 3、触发调度
        • ① 主动挂起
        • ② 系统调用
          • a.准备工作
          • b.恢复工作
        • ③ 协作式调度
        • ④ 运行时监控—— sysmon
          • a.作用范围
          • b.监控频率

相关链接:
Go 语言设计与实现: https://draveness.me/golang/
Go GMP模型: https://blog.csdn/qq_41822345/article/details/123015441
Go 笔记整合: https://blog.csdn/qq_41822345/article/details/125309485

一、调度器简介

Go的并发特性的离不开调度器。在Go中,调度器的功能是把可运行的goroutine分配到工作线程M上。【Goroutine 和调度器是 Go 语言能够高效地处理任务并且最大化利用资源的基础】

Go调度器很轻量也很简单,足以撑起goroutine的调度工作,并且让Go具有了原生(强大)并发的能力。Go调度本质是把大量的goroutine分配到少量线程上去执行,并利用多核并行,实现更强大的并发。

Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。

二、调度器的发展

Go的调度器经过几个大版本的迭代才有今天的优异性能,历史上几个不同版本的调度器引入了不同的改进,也存在着不同的缺陷:

  • 单线程调度器。版本0.x

    • 只包含 40 多行代码;
    • 程序中只能存在一个活跃线程,由G-M 模型组成;
  • 多线程调度器。版本1.0

    • 允许运行多线程的程序;
    • 全局锁导致竞争严重;
  • 任务窃取调度器 。版本1.1

    • 引入了处理器 P,构成了目前的G-M-P模型
    • 在处理器 P 的基础上实现了基于工作窃取的调度器;
    • 在某些情况下,Goroutine 不会让出线程,进而造成饥饿问题;
    • 时间过长的垃圾回收(Stop-the-world,STW)会导致程序长时间无法工作;
  • 抢占式调度器。版本1.2~至今。

    • 基于协作的抢占式调度器:版本1.2 ~1.13
      • 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
      • Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
    • 基于信号的抢占式调度器:版本1.14~至今
      • 实现基于信号的真抢占式调度
      • 垃圾回收在扫描栈时会触发抢占调度;
      • 抢占的时间点不够多,还不能覆盖全部的边缘情况;
  • 非均匀存储访问调度器 (Non-uniform memory access,NUMA)

    • 对运行时的各种资源进行分区;
    • 实现非常复杂,目前还没有提上日程。

1、单线程调度器

0.x 版本调度器模型只包含表示 Goroutine 的 G 和表示线程的 M 两种结构,全局也只有一个线程。我们可以在 clean up scheduler 提交中找到单线程调度器的源代码,在这时 Go 语言的调度器还是由 C 语言实现的,调度函数 runtime.scheduler:9682400 也只包含 40 多行代码 :

static void scheduler(void) {
	G* gp;
	lock(&sched);

	if(gosave(&m->sched)){
		lock(&sched);
		gp = m->curg;
		switch(gp->status){
		case Grunnable:
		case Grunning:
			gp->status = Grunnable;
			gput(gp);
			break;
		...
		}
		notewakeup(&gp->stopped);
	}

	gp = nextgandunlock();
	noteclear(&gp->stopped);
	gp->status = Grunning;
	m->curg = gp;
	g = gp;
	gogo(&gp->sched);
}

该函数会遵循如下的过程调度 Goroutine:

  1. 获取调度器的全局锁;
  2. 调用 runtime.gosave:9682400 保存栈寄存器和程序计数器;
  3. 调用 runtime.nextgandunlock:9682400 获取下一个需要运行的 Goroutine 并解锁调度器;
  4. 修改全局线程 m 上要执行的 Goroutine;
  5. 调用 runtime.gogo:9682400 函数运行最新的 Goroutine;

这个单线程调度器的优点就是简单、能运行但不可用,但是已经包含了 G 和 M 两个重要的数据结构,也建立了 Go 语言调度器的框架。

2、多线程调度器

Go 语言在 1.0 版本正式发布时就支持了多线程的调度器,与上一个版本几乎不可用的调度器相比,Go 语言团队在这一阶段实现了从不可用到可用的跨越。可以在 pkg/runtime/proc.c 文件中找到 1.0.1 版本的调度器,多线程版本的调度函数 runtime.schedule:go1.0.1 包含 70 多行代码,在这里保留了该函数的核心逻辑:

static void schedule(G *gp) {
	schedlock();
	if(gp != nil) {
		gp->m = nil;
		uint32 v = runtime·xadd(&runtime·sched.atomic, -1<<mcpuShift);
		if(atomic_mcpu(v) > maxgomaxprocs)
			runtime·throw("negative mcpu in scheduler");

		switch(gp->status){
		case Grunning:
			gp->status = Grunnable;
			gput(gp);
			break;
		case ...:
		}
	} else {
		...
	}
	gp = nextgandunlock();
	gp->status = Grunning;
	m->curg = gp;
	gp->m = m;
	runtime·gogo(&gp->sched, 0);
}

整体的逻辑与单线程调度器没有太多区别,因为程序中可能同时存在多个活跃线程,所以多线程调度器引入了 GOMAXPROCS 变量帮助我们灵活控制程序中的最大处理器数,即活跃线程数。

多线程调度器的主要问题是调度时的锁竞争会严重浪费资源,Scalable Go Scheduler Design Doc 中对调度器做的性能测试发现 14% 的时间都花费在 runtime.futex:go1.0.1 上,该调度器有以下问题需要解决

  1. 调度器和锁是全局资源,所有的调度状态都是中心化存储的,锁竞争问题严重;
  2. 线程需要经常互相传递可运行的 Goroutine,引入了大量的延迟;
  3. 每个线程都需要处理内存缓存,导致大量的内存占用并影响数据局部性;
  4. 系统调用频繁阻塞和解除阻塞正在运行的线程,增加了额外开销;

这里的全局锁问题和 Linux 操作系统调度器在早期遇到的问题比较相似,解决的方案也都大同小异。

多线程下调度模型

M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。

该调度器有几个缺点

1)、创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。

2)、M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’ 交给M’ 执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M’。

3)、系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

3、任务窃取调度器

任务窃取调度器是Go调度器具有突破性的设计。

2012 年 Google 的工程师 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了现有多线程调度器的问题并在多线程调度器上提出了两个改进的手段:

  1. 在当前的 G-M 模型中引入了处理器 P,增加中间层;
  2. 在处理器 P 的基础上实现基于工作窃取的调度器;

基于任务窃取的 Go 语言调度器使用了沿用至今的 G-M-P 模型,在 runtime: improved scheduler 提交中找到任务窃取调度器刚被实现时的源代码,调度器的 runtime.schedule:779c45a 在这个版本的调度器中反而更简单了:

static void schedule(void) {
    G *gp;
 top:
    if(runtime·gcwaiting) {
        gcstopm();
        goto top;
    }

    gp = runqget(m->p);
    if(gp == nil)
        gp = findrunnable();

    ...

    execute(gp);
}
  1. 如果当前运行时在等待垃圾回收,调用 runtime.gcstopm:779c45a 函数;
  2. 调用 runtime.runqget:779c45aruntime.findrunnable:779c45a 从本地或者全局的运行队列中获取待执行的 Goroutine;
  3. 调用 runtime.execute:779c45a 在当前线程 M 上运行 Goroutine;

当前处理器本地的运行队列中不包含 Goroutine 时,调用 runtime.findrunnable:779c45a触发工作窃取,从其它的处理器的队列中随机获取一些 Goroutine。

运行时 G-M-P 模型中引入的处理器 P 是线程和 Goroutine 的中间层,从它的结构体中就能看到处理器与 M 和 G 的关系:

struct P {
	Lock;

	uint32	status;
	P*	link;
	uint32	tick;
	M*	m;
	MCache*	mcache;

	G**	runq;
	int32	runqhead;
	int32	runqtail;
	int32	runqsize;

	G*	gfree;
	int32	gfreecnt;
};

处理器持有一个由可运行的 Goroutine 组成的环形的运行队列 runq,还反向持有一个线程。调度器在调度时会从处理器的队列中选择队列头的 Goroutine 放到线程 M 上执行。如下所示的图片展示了 Go 语言中的线程 M、处理器 P 和 Goroutine 的关系。

基于工作窃取的多线程调度器将每一个线程绑定到了独立的 CPU 上,这些线程会被不同处理器管理,不同的处理器通过工作窃取对任务进行再分配实现任务的平衡,也能提升调度器和 Go 语言程序的整体性能,今天所有的 Go 语言服务都受益于这一改动。

4、抢占式调度器

对 Go 语言并发模型的修改提升了调度器的性能,但是 1.1 版本中的调度器仍然不支持抢占式调度,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。Go 语言的调度器在 1.2 版本中引入基于协作的抢占式调度解决下面的问题:

  • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
  • 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作;

1.2 版本的抢占式调度虽然能够缓解这个问题,但是它实现的抢占式调度是基于协作的,在之后很长的一段时间里 Go 语言的调度器都有一些无法被抢占的边缘情况,例如:for 循环或者垃圾回收长时间占用线程,这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。

a.基于协作的抢占式调度

可以在 pkg/runtime/proc.c 文件中找到引入基于协作的抢占式调度后的调度器。Go 语言会在分段栈的机制上实现抢占调度,利用编译器在分段栈上插入的函数,所有 Goroutine 在函数调用时都有机会进入运行时检查是否需要执行抢占。Go 团队通过以下的多个提交实现该特性:

  • runtime: add stackguard0 to G
    • 为 Goroutine 引入 stackguard0 字段,该字段被设置成 StackPreempt 意味着当前 Goroutine 发出了抢占请求;
  • runtime: introduce preemption function (not used for now)
    • 引入抢占函数 runtime.preemptone:1e112cdruntime.preemptall:1e112cd,这两个函数会改变 Goroutine 的 stackguard0 字段发出抢占请求;
    • 定义抢占请求 StackPreempt
  • runtime: preempt goroutines for GC
    • runtime.stoptheworld:1e112cd 中调用 runtime.preemptall:1e112cd 设置所有处理器上正在运行的 Goroutine 的 stackguard0StackPreempt
    • runtime.newstack:1e112cd 中增加抢占的代码,当 stackguard0 等于 StackPreempt 时触发调度器抢占让出线程;
  • runtime: preempt long-running goroutines
    • 在系统监控中,如果一个 Goroutine 的运行时间超过 10ms,就会调用 runtime.retake:1e112cdruntime.preemptone:1e112cd
  • runtime: more reliable preemption
    • 修复 Goroutine 因为周期性执行非阻塞的 CGO 或者系统调用不会被抢占的问题;

上面的多个提交实现了抢占式调度,但是还缺少最关键的一个环节 — 编译器如何在函数调用前插入函数,我们能在非常古老的提交 runtime: stack growth adjustments, cleanup 中找到编译器插入函数的雏形,最新版本的 Go 语言会通过 cmd/internal/obj/x86.stacksplit 插入 runtime.morestack,该函数可能会调用 runtime.newstack 触发抢占。从上面的多个提交中,能归纳出基于协作的抢占式调度的工作原理

  1. 编译器会在调用函数前插入 runtime.morestack
  2. Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时发出抢占请求 StackPreempt
  3. 当发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack 会检查 Goroutine 的 stackguard0 字段是否为 StackPreempt
  4. 如果 stackguard0StackPreempt,就会触发抢占让出当前线程

这种实现方式虽然增加了运行时的复杂度,但是实现相对简单,也没有带来过多的额外开销,总体来看还是比较成功的实现,也在 Go 语言中使用了 10 几个版本。因为这里的抢占是通过编译器插入函数实现的,还是需要函数调用作为入口才能触发抢占,所以这是一种协作式的抢占式调度

b.基于信号的抢占式调度

基于协作的抢占式调度虽然实现巧妙,但是并不完备,能在 runtime: non-cooperative goroutine preemption 中找到一些遗留问题:

  • runtime: tight loops should be preemptible #10958
  • An empty for{} will block large slice allocation in another goroutine, even with GOMAXPROCS > 1 ? #17174
  • runtime: tight loop hangs process completely after some time #15442

Go 语言在 1.14 版本中实现了非协作的抢占式调度,在实现的过程中重构已有的逻辑并为 Goroutine 增加新的状态和字段来支持抢占。Go 团队通过下面的一系列提交实现了这一功能,我们可以按时间顺序分析相关提交理解它的工作原理:

  • runtime: add general suspendG/resumeG
    • 挂起 Goroutine 的过程是在垃圾回收的栈扫描时完成的,我们通过 runtime.suspendGruntime.resumeG 两个函数重构栈扫描这一过程;
    • 调用 runtime.suspendG 时会将处于运行状态的 Goroutine 的 preemptStop 标记成 true
    • 调用 runtime.preemptPark 可以挂起当前 Goroutine、将其状态更新成 _Gpreempted 并触发调度器的重新调度,该函数能够交出线程控制权;
  • runtime: asynchronous preemption function for x86
    • 在 x86 架构上增加异步抢占的函数 runtime.asyncPreemptruntime.asyncPreempt2
  • runtime: use signals to preempt Gs for suspendG
    • 支持通过向线程发送信号的方式暂停运行的 Goroutine;
    • runtime.sighandler 函数中注册 SIGURG 信号的处理函数 runtime.doSigPreempt
    • 实现 runtime.preemptM,它可以通过 SIGURG 信号向线程发送抢占请求;
  • runtime: implement async scheduler preemption
    • 修改 runtime.preemptone 函数的实现,加入异步抢占的逻辑;

目前的抢占式调度也只会在垃圾回收扫描任务时触发,我们可以梳理一下上述代码实现的抢占式调度过程:

  1. 程序启动时,在 runtime.sighandler 中注册 SIGURG 信号的处理函数 runtime.doSigPreempt
  2. 在触发垃圾回收的栈扫描时会调用runtime.suspendG挂起 Goroutine,该函数会执行下面的逻辑:
    1. _Grunning 状态的 Goroutine 标记成可以被抢占,即将 preemptStop 设置成 true
    2. 调用 runtime.preemptM 触发抢占;
  3. runtime.preemptM 会调用 runtime.signalM 向线程发送信号 SIGURG
  4. 操作系统会中断正在运行的线程并执行预先注册的信号处理函数 runtime.doSigPreempt
  5. runtime.doSigPreempt 函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用 runtime.sigctxt.pushCall
  6. runtime.sigctxt.pushCall 会修改寄存器并在程序回到用户态时执行 runtime.asyncPreempt
  7. 汇编指令 runtime.asyncPreempt 会调用运行时函数 runtime.asyncPreempt2
  8. runtime.asyncPreempt2 会调用 runtime.preemptPark
  9. runtime.preemptPark 会修改当前 Goroutine 的状态到 _Gpreempted 并调用 runtime.schedule 让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行;

上述 9 个步骤展示了基于信号的抢占式调度的执行过程。除了分析抢占的过程之外,我们还需要讨论一下抢占信号的选择,提案根据以下的四个原因选择 SIGURG 作为触发异步抢占的信号

  1. 该信号需要被调试器透传;
  2. 该信号不会被内部的 libc 库使用并拦截;
  3. 该信号可以随意出现并且不触发任何后果;
  4. 我们需要处理多个平台上的不同信号;

STW 和栈扫描是一个可以抢占的安全点(Safe-points),所以 Go 语言会在这里先加入抢占功能。基于信号的抢占式调度只解决了垃圾回收和栈扫描时存在的问题,它到目前为止没有解决所有问题,但是这种真抢占式调度是调度器走向完备的开始,相信在未来我们会在更多的地方触发抢占。

5、非均匀内存访问调度器

非均匀内存访问(Non-uniform memory access,NUMA)调度器现在只是 Go 语言的提案。该提案的原理就是通过拆分全局资源,让各个处理器能够就近获取,减少锁竞争并增加数据的局部性。

在目前的运行时中,线程、处理器、网络轮询器、运行队列、全局内存分配器状态、内存分配缓存和垃圾收集器都是全局资源。运行时没有保证本地化,也不清楚系统的拓扑结构,部分结构可以提供一定的局部性,但是从全局来看没有这种保证。

如上图所示,堆栈、全局运行队列和线程池会按照 NUMA 节点进行分区,网络轮询器和计时器会由单独的处理器持有。这种方式虽然能够利用局部性提高调度器的性能,但是本身的实现过于复杂,所以 Go 语言团队还没有着手实现这一提案。

6、小结

Go 语言的调度器在最初的几个版本中迅速迭代,但是从 1.2 版本之后调度器就没有太多的变化,直到 1.14 版本引入了真正的抢占式调度才解决了自 1.2 以来一直存在的问题。在可预见的未来,Go 语言的调度器还会进一步演进,增加触发抢占式调度的时间点以减少存在的边缘情况。

三、调度器流程

1、调度器启动

调度器的启动过程是我们平时比较难以接触的过程,不过作为程序启动前的准备工作,理解调度器的启动过程对我们理解调度器的实现原理很有帮助,运行时通过 runtime.schedinit 初始化调度器:

func schedinit() {
	_g_ := getg()
	...

	sched.maxmcount = 10000

	...
	sched.lastpoll = uint64(nanotime())
	procs := ncpu
	if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
		procs = n
	}
	if procresize(procs) != nil {
		throw("unknown runnable goroutine during bootstrap")
	}
}

在调度器初始函数执行的过程中会将 maxmcount 设置成 10000,这也就是一个 Go 语言程序能够创建的最大线程数,虽然最多可以创建 10000 个线程,但是可以同时运行的线程还是由 GOMAXPROCS 变量控制。【也即获得了程序能够同时运行的最大处理器数】

我们从环境变量 GOMAXPROCS 获取了程序能够同时运行的最大处理器数之后就会调用 runtime.procresize 更新程序中处理器的数量,在这时整个程序不会执行任何用户 Goroutine,调度器也会进入锁定状态,runtime.procresize 的执行过程如下:

  1. 如果全局变量 allp 切片中的处理器数量少于期望数量,会对切片进行扩容;
  2. 使用 new 创建新的处理器结构体并调用 runtime.p.init 初始化刚刚扩容的处理器;
  3. 通过指针将线程 m0 和处理器 allp[0] 绑定到一起;
  4. 调用 runtime.p.destroy 释放不再使用的处理器结构;
  5. 通过截断改变全局变量 allp 的长度保证与期望处理器数量相等;
  6. 将除 allp[0] 之外的处理器 P 全部设置成 _Pidle 并加入到全局的空闲队列中;

调用 runtime.procresize 是调度器启动的最后一步,在这一步过后调度器会完成相应数量处理器的启动,等待用户创建运行新的 Goroutine 并为 Goroutine 调度处理器资源。

2、调度循环

调度循环,就是极力的寻找可执行的G的过程。

调度器启动之后,Go 语言运行时会调用 runtime.mstart 以及 runtime.mstart1,前者会初始化 g0 的 stackguard0stackguard1 字段,后者会初始化线程并调用 runtime.schedule 进入调度循环:

func schedule() {
	_g_ := getg()

top:
	var gp *g
	var inheritTime bool

	if gp == nil {
		if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
			lock(&sched.lock)
			gp = globrunqget(_g_.m.p.ptr(), 1)
			unlock(&sched.lock)
		}
	}
	if gp == nil {
		gp, inheritTime = runqget(_g_.m.p.ptr())
	}
	if gp == nil {
		gp, inheritTime = findrunnable()
	}

	execute(gp, inheritTime)
}

runtime.schedule 函数会从下面几个地方查找待执行的 Goroutine:

  1. 为了保证公平,当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine;
  2. 从处理器本地的运行队列中查找待执行的 Goroutine;
  3. 如果前两种方法都没有找到 Goroutine,会通过 runtime.findrunnable 进行阻塞地查找 Goroutine;

runtime.findrunnable 的实现非常复杂,这个 300 多行的函数通过以下的过程获取可运行的 Goroutine:

  1. 从本地运行队列、全局运行队列中查找;
  2. 从网络轮询器中查找是否有 Goroutine 等待运行;
  3. 通过 runtime.runqsteal 尝试从其他随机的处理器中窃取待运行的 Goroutine,该函数还可能窃取处理器的计时器;

因为函数的实现过于复杂,上述的执行过程是经过简化的,总而言之,当前函数一定会返回一个可执行的 Goroutine,如果当前不存在就会阻塞等待。

接下来由 runtime.execute 执行获取的 Goroutine,做好准备工作后,它会通过 runtime.gogo 将 Goroutine 调度到当前线程上。

func execute(gp *g, inheritTime bool) {
	_g_ := getg()

	_g_.m.curg = gp
	gp.m = _g_.m
	casgstatus(gp, _Grunnable, _Grunning)
	gp.waitsince = 0
	gp.preempt = false
	gp.stackguard0 = gp.stack.lo + _StackGuard
	if !inheritTime {
		_g_.m.p.ptr().schedtick++
	}

	gogo(&gp.sched)
}

runtime.gogo 在不同处理器架构上的实现都不同,但是也都大同小异,下面是该函数在 386 架构上的实现:

TEXT runtime·gogo(SB), NOSPLIT, $8-4
	MOVL buf+0(FP), BX     // 获取调度信息
	MOVL gobuf_g(BX), DX
	MOVL 0(DX), CX         // 保证 Goroutine 不为空
	get_tls(CX)
	MOVL DX, g(CX)
	MOVL gobuf_sp(BX), SP  // 将 runtime.goexit 函数的 PC 恢复到 SP 中
	MOVL gobuf_ret(BX), AX
	MOVL gobuf_ctxt(BX), DX
	MOVL $0, gobuf_sp(BX)
	MOVL $0, gobuf_ret(BX)
	MOVL $0, gobuf_ctxt(BX)
	MOVL gobuf_pc(BX), BX  // 获取待执行函数的程序计数器
	JMP  BX                // 开始执行

它从 runtime.gobuf 中取出了 runtime.goexit 的程序计数器和待执行函数的程序计数器,其中:

  • runtime.goexit 的程序计数器被放到了栈 SP 上;
  • 待执行函数的程序计数器被放到了寄存器 BX 上;

正常的函数调用都会使用 CALL 指令,该指令会将调用方的返回地址加入栈寄存器 SP 中,然后跳转到目标函数;当目标函数返回后,会从栈中查找调用的地址并跳转回调用方继续执行剩下的代码。

runtime.gogo 就利用了 Go 语言的调用惯例成功模拟这一调用过程,通过以下几个关键指令模拟 CALL 的过程:

	MOVL gobuf_sp(BX), SP  // 将 runtime.goexit 函数的 PC 恢复到 SP 中
	MOVL gobuf_pc(BX), BX  // 获取待执行函数的程序计数器
	JMP  BX                // 开始执行

上图展示了调用 JMP 指令后的栈中数据,当 Goroutine 中运行的函数返回时,程序会跳转到 runtime.goexit 所在位置执行该函数:

TEXT runtime·goexit(SB),NOSPLIT,$0-0
	CALL	runtime·goexit1(SB)

func goexit1() {
	mcall(goexit0)
}

经过一系列复杂的函数调用,最终在当前线程的 g0 的栈上调用 runtime.goexit0 函数,该函数会将 Goroutine 转换会 _Gdead 状态、清理其中的字段、移除 Goroutine 和线程的关联并调用 runtime.gfput 重新加入处理器的 Goroutine 空闲列表 gFree

func goexit0(gp *g) {
	_g_ := getg()

	casgstatus(gp, _Grunning, _Gdead)
	gp.m = nil
	...
	gp.param = nil
	gp.labels = nil
	gp.timer = nil

	dropg()
	gfput(_g_.m.p.ptr(), gp)
	schedule()
}

在最后 runtime.goexit0 会重新调用 runtime.schedule 触发新一轮的 Goroutine 调度,Go 语言中的运行时调度循环会从 runtime.schedule 开始,最终又回到 runtime.schedule,可以认为调度循环永远都不会返回。

这里介绍的是 Goroutine 正常执行并退出的逻辑,实际情况会复杂得多,多数情况下 Goroutine 在执行的过程中都会经历协作式或者抢占式调度,它会让出线程的使用权等待调度器的唤醒。

3、触发调度

简单介绍下所有触发调度的时间点,因为调度器的 runtime.schedule 会重新选择 Goroutine 在线程上执行,所以只要找到该函数的调用方就能找到所有触发调度的时间点,经过分析和整理,能得到如下的树形结构运行时触发调度】:

除了上图中可能触发调度的时间点,运行时还会在线程启动 runtime.mstart 和 Goroutine 执行结束 runtime.goexit0 触发调度。我们在这里会重点介绍运行时触发调度的几个路径:

  • 主动挂起 — runtime.gopark -> runtime.park_m
  • 系统调用 — runtime.exitsyscall -> runtime.exitsyscall0
  • 协作式调度 — runtime.Gosched -> runtime.gosched_m -> runtime.goschedImpl
  • 系统监控 — runtime.sysmon -> runtime.retake -> runtime.preemptone

我们在这里介绍的调度时间点不是将线程的运行权直接交给其他任务,而是通过调度器的 runtime.schedule 重新调度。

① 主动挂起

runtime.gopark 是触发调度最常见的方法,该函数会将当前 Goroutine 暂停,被暂停的任务不会放回运行队列,我们来分析该函数的实现原理:

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
	mp := acquirem()
	gp := mp.curg
	mp.waitlock = lock
	mp.waitunlockf = unlockf
	gp.waitreason = reason
	mp.waittraceev = traceEv
	mp.waittraceskip = traceskip
	releasem(mp)
	mcall(park_m)
}

上述会通过 runtime.mcall 切换到 g0 的栈上调用 runtime.park_m

func park_m(gp *g) {
	_g_ := getg()

	casgstatus(gp, _Grunning, _Gwaiting)
	dropg()

	schedule()
}

runtime.park_m 会将当前 Goroutine 的状态从 _Grunning 切换至 _Gwaiting,调用 runtime.dropg 移除线程和 Goroutine 之间的关联,在这之后就可以调用 runtime.schedule 触发新一轮的调度了。

当 Goroutine 等待的特定条件满足后,运行时会调用 runtime.goready 将因为调用 runtime.gopark 而陷入休眠的 Goroutine 唤醒。

func goready(gp *g, traceskip int) {
	systemstack(func() {
		ready(gp, traceskip, true)
	})
}

func ready(gp *g, traceskip int, next bool) {
	_g_ := getg()

	casgstatus(gp, _Gwaiting, _Grunnable)
	runqput(_g_.m.p.ptr(), gp, next)
	if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 {
		wakep()
	}
}

runtime.ready 会将准备就绪的 Goroutine 的状态切换至 _Grunnable 并将其加入处理器的运行队列中,等待调度器的调度。

② 系统调用

系统调用也会触发运行时调度器的调度,为了处理特殊的系统调用,我们甚至在 Goroutine 中加入了 _Gsyscall 状态,Go 语言通过 syscall.Syscallsyscall.RawSyscall 等使用汇编语言编写的方法封装操作系统提供的所有系统调用,其中 syscall.Syscall 的实现如下:

#define INVOKE_SYSCALL	INT	$0x80

TEXT ·Syscall(SB),NOSPLIT,$0-28
	CALL	runtime·entersyscall(SB)
	...
	INVOKE_SYSCALL
	...
	CALL	runtime·exitsyscall(SB)
	RET
ok:
	...
	CALL	runtime·exitsyscall(SB)
	RET

在通过汇编指令 INVOKE_SYSCALL 执行系统调用前后,上述函数会调用运行时的 runtime.entersyscallruntime.exitsyscall,正是这一层包装能够让我们在陷入系统调用前触发运行时的准备和清理工作。

不过出于性能的考虑,如果这次系统调用不需要运行时参与,就会使用 syscall.RawSyscall 简化这一过程,不再调用运行时函数。这里包含 Go 语言对 Linux 386 架构上不同系统调用的分类,我们会按需决定是否需要运行时的参与。

系统调用类型
SYS_TIMERawSyscall
SYS_GETTIMEOFDAYRawSyscall
SYS_SETRLIMITRawSyscall
SYS_GETRLIMITRawSyscall
SYS_EPOLL_WAITSyscall

由于直接进行系统调用会阻塞当前的线程,所以只有可以立刻返回的系统调用才可能会被设置成 RawSyscall 类型,例如:SYS_EPOLL_CREATESYS_EPOLL_WAIT(超时时间为 0)、SYS_TIME 等。

正常的系统调用过程相对比较复杂,下面将分别介绍进入系统调用前的准备工作和系统调用结束后的收尾工作。

a.准备工作

runtime.entersyscall 会在获取当前程序计数器和栈位置之后调用 runtime.reentersyscall,它会完成 Goroutine 进入系统调用前的准备工作:

func reentersyscall(pc, sp uintptr) {
	_g_ := getg()
	_g_.m.locks++
	_g_.stackguard0 = stackPreempt
	_g_.throwsplit = true

	save(pc, sp)
	_g_.syscallsp = sp
	_g_.syscallpc = pc
	casgstatus(_g_, _Grunning, _Gsyscall)

	_g_.m.syscalltick = _g_.m.p.ptr().syscalltick
	_g_.m.mcache = nil
	pp := _g_.m.p.ptr()
	pp.m = 0
	_g_.m.oldp.set(pp)
	_g_.m.p = 0
	atomic.Store(&pp.status, _Psyscall)
	if sched.gcwaiting != 0 {
		systemstack(entersyscall_gcwait)
		save(pc, sp)
	}
	_g_.m.locks--
}
  1. 禁止线程上发生的抢占,防止出现内存不一致的问题;
  2. 保证当前函数不会触发栈分裂或者增长;
  3. 保存当前的程序计数器 PC 和栈指针 SP 中的内容;
  4. 将 Goroutine 的状态更新至 _Gsyscall
  5. 将 Goroutine 的处理器和线程暂时分离并更新处理器的状态到 _Psyscall
  6. 释放当前线程上的锁;

需要注意的是 runtime.reentersyscall 会使处理器和线程的分离,当前线程会陷入系统调用等待返回,在锁被释放后,会有其他 Goroutine 抢占处理器资源。

b.恢复工作

当系统调用结束后,会调用退出系统调用的函数 runtime.exitsyscall 为当前 Goroutine 重新分配资源,该函数有两个不同的执行路径:

  1. 调用 runtime.exitsyscallfast
  2. 切换至调度器的 Goroutine 并调用 runtime.exitsyscall0
func exitsyscall() {
	_g_ := getg()

	oldp := _g_.m.oldp.ptr()
	_g_.m.oldp = 0
	if exitsyscallfast(oldp) {
		_g_.m.p.ptr().syscalltick++
		casgstatus(_g_, _Gsyscall, _Grunning)
		...

		return
	}

	mcall(exitsyscall0)
	_g_.m.p.ptr().syscalltick++
	_g_.throwsplit = false
}

这两种不同的路径会分别通过不同的方法查找一个用于执行当前 Goroutine 处理器 P,快速路径 runtime.exitsyscallfast 中包含两个不同的分支:

  1. 如果 Goroutine 的原处理器处于 _Psyscall 状态,会直接调用 wirep 将 Goroutine 与处理器进行关联;
  2. 如果调度器中存在闲置的处理器,会调用 runtime.acquirep 使用闲置的处理器处理当前 Goroutine;

另一个相对较慢的路径 runtime.exitsyscall0 会将当前 Goroutine 切换至 _Grunnable 状态,并移除线程 M 和当前 Goroutine 的关联:

  1. 当我们通过 runtime.pidleget 获取到闲置的处理器时就会在该处理器上执行 Goroutine;
  2. 在其它情况下,我们会将当前 Goroutine 放到全局的运行队列中,等待调度器的调度;

无论哪种情况,我们在这个函数中都会调用 runtime.schedule 触发调度器的调度,因为上一节已经介绍过调度器的调度过程,所以在这里就不展开了。

③ 协作式调度

我们在设计原理中介绍过了 Go 语言基于协作式和信号的两种抢占式调度,这里主要介绍其中的协作式调度。runtime.Gosched 函数会主动让出处理器,允许其他 Goroutine 运行。该函数无法挂起 Goroutine,调度器可能会将当前 Goroutine 调度到其他线程上:

func Gosched() {
	checkTimeouts()
	mcall(gosched_m)
}

func gosched_m(gp *g) {
	goschedImpl(gp)
}

func goschedImpl(gp *g) {
	casgstatus(gp, _Grunning, _Grunnable)
	dropg()
	lock(&sched.lock)
	globrunqput(gp)
	unlock(&sched.lock)

	schedule()
}

经过连续几次跳转,我们最终在 g0 的栈上调用 runtime.goschedImpl,运行时会更新 Goroutine 的状态到 _Grunnable,让出当前的处理器并将 Goroutine 重新放回全局队列,在最后,该函数会调用 runtime.schedule 触发调度。

④ 运行时监控—— sysmon

抢占式调度基于运行时监控。

Go标准库专门用于监控程序帮助解决程序可能遇到的瓶颈的线程称为sysmon。sysmon并没有链接到GMP模型中的任意P上,所以不会被调度器调度,因此会始终处于运行状态。【通过go tool trace也无法跟踪到这个线程】

a.作用范围

该线程作用广泛并涉及以下方面:

  • 由应用程序创建的计时器(timers)。sysmon查看应该在运行但仍在等待的计时器。在这种情况下,Go将查看空闲的M和P列表以尽快运行它们。

  • 网络轮训器(net poller )和系统调用( system calls)。它运行网络操作中阻塞的goroutines。

  • 如果垃圾收集器(garbage collector)已经很长时间没有运行(超过2分钟),sysmon将强制一轮垃圾回收。

  • 长时间运行goroutine的抢占。任何运行超过10毫秒的goroutine将会被抢占把运行时间留给其它goroutines

b.监控频率

sysmon非常聪明,无事可做时不会消耗资源。它的频率是动态的,并取决于正在运行程序的当前活动。

初始频率是20纳秒,意味着线程一直在检查。然后,经过数轮后,如果sysmon还是无事可做,频率将会加倍一直可以增加到10毫秒的频率。如果你的程序没有很多系统调用或者长时间运行的goroutine,则sysmon将会大多数时间维持10毫秒的频率,这将给你的应用程序带来非常小的开销。

该线程还能检测何时不应运行。这是两种情况:

  • 垃圾收集器将要运行时。sysmon将会在回收结束后恢复运行。
  • 所有线程都处于空闲时。

在这两种情况下,sysmon都会休眠并且不会消耗任何资源。

更多推荐

Go 调度器——schedule