原创分享 21 面试必问!Goroutine 的调度原理

happy_brother · 2021年01月21日 · 117 次阅读

正常的执行顺序就是线性的,谁在前面,谁就先执行,但是并发能力,会让你的程序,可以由若干个代码片段组合而成,并且每个片段都是独立运行的。Go 语言天生支持这种并发能力,而 Goroutine 就是 Go 原生支持并发的具体实现。无论是 Go 的运行时还是用户写的代码都是运行在 Goroutine 中。

Goroutine 是由 Go 运行时管理的轻量级线程。和操作系统线程相比,goroutine 的资源占用和使用代价非常小。我们可以创建几百到十几万个 goroutine,甚至更多。Go 运行时负责对 goroutine 进行管理和调度。

不要让—“管理和调度” 这种专业术语吓住,你可以简单理解,什么时候、哪个 goroutine 将获得资源开始执行,哪个 goroutine 应该停止执行让出资源,哪个 goroutine 应该被唤醒回复执行等。这样的理解和现实生活中是一模一样的,例如哨兵站岗。goroutine 的调度模型和原理,对于编写出优雅而高质量的代码是大有益处的。因此,在面试中可以说是每次必问。

一 goroutine 调度器

调度:操作系统中对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理 CPU 上去运行。如 C、C++ 等并发实现多是基于线程模型的,就是应用程序负责创建线程(libpthread 等库函数去实现),操作系统负责调度线程。这种模式就是有一些不足:

复杂

1) 写过 C、C++ 的人肯定都知道,这里有多么的复杂,利用 libpthread 库中的 API 创建一个线程的时候,虽然要传入的参数很多,但是还可以接受。一旦涉及到线程的退出,那就要考虑主线程与新线程的很多资源相关的问题。

2) 并发执行单元互相通信困难,容易出错:多个线程间的通信有很多机制,但用起来也是很复杂的;一旦用到共享内存,那就是各种锁机制,导致死锁,更是很轻松就做到的。

难度

1) 我们使用线程的代价要比进程小很多,但是依然不能大量创建线程,除了每个线程占用的资源不小之外,操作系统调度切换线程的代价也很大。

2) 很多服务端程序,由于不能大量创建线程,只能选择在少量线程里做网络多路复用的方案(epoll/kqueue/IoCompletionPort 这种机制),或者你会说可以用 libevent 和 libev 啊,这样的写法存在大量的钩子回调,给开发人员带来不小的负担。

看到上面的痛点,Go 采用了 Goroutine 来解决这些痛点。Goroutine 占用资源非常小,每个 Gorouine 栈的大小默认是 2k 字节。goroutine 调度的切换也不用在操作系统内核中完成,代价很低。所以一个 Go 程序可以创建成千上万个并发的 goroutine,而把这些 goroutine 按照一定算法放到 cpu 上执行的程序,我们就成为 goroutine 调度器(Scheduler)。

一个 Go 程序运行起来,在操作系统看来就是一个用户程序,操作系统的概念,只有线程,它甚至不知道有 Goroutine 的存在。Goroutine 的调度完全靠 GO 自己完成。实现 GO 程序内 Goroutine 之间的公平竞争 CPU 的资源,这个任务就靠 GO 运行时(runtime)了,一个 Go 程序中,除了用户层代码,其他就是 go 运行时了。

二 Go 调度器模型与演化过程

第一版 G-M 模型 2012.3.28 日,Go1.0 正式发布,Go 团队实现了一个简单的 goroutine 调度器。在这个调度其中,每个 goroutine 对应于运行时中的一个抽象结构 G(Goroutine),另外一个结构体是 M(Machine),它被看成是 “物理 CPU” 的操作系统线程。这个模型实现起来比较简单,且工作正常,但是也有一些问题,最重要的是限制了 GO 并发程序的伸缩性,如下几个方面:

单一全局互斥锁 (Sched.Lock) 和集中状态存储的存在导致所有 goroutine 相关操作。如创建、重新调度都要加锁。

goroutine 传递问题:M 经常在 M 之间传递 “可运行” 的 goroutine,这导致调度延迟增大及额外的性能损耗;

每个 M 都做内存缓存,导致内存占用过高,数据局部性交差。

由于系统调用而形成的频繁的工作线程阻塞和解阻塞,导致额外性能损耗。

第二版 G-P-M 模型 基于第一版的问题,在 Go1.1 中实现了 G-P-M 模型和 work stealing 算法,这个模型一直沿用。 在这里插入图片描述

我们看到在 G-M 中增加了一个 P,这个 P 是何方神圣呢? P 是一个 “逻辑 Processor”,每个 G 要想真正运行起来,都需要被分配到一个 P,即进入到 P 的本地运行队列中,先暂时忽略全局队列。对于 G 来说,P 就是运行它的 “CPU”,在 G 看来只有 P。但从调度器的角度看,真正的 “CPU” 是 M,只有将 P 和 M 绑定才能让 P 中的 G 真正的运行起来。这样的 P 与 M 的关系,类似 Linux 操作系统中用户线程和内核线程的对应关系 (N*M)

3 抢占式调度

有了 G-P-M 模型,是很大的进步,但是仍有一个问题,它不支持抢占式调度,一旦某个 G 中出现死循环的代码逻辑,那么 G 将永久占用分配给他的 P 和 M,而在同一个 P 中的其他 G 永远不能被调度,出现其他 G 被 “饿死” 的情况。在 Go1.2 中实现了 “抢占式” 调度。

抢占式的原理是在每个函数或方法的入口,加一段额外的代码,让运行时有机会检查是否需要执行抢占调度。这种解决方案只能局部解决 “饿死” 问题。对于没有函数调用而是存算法计算的 G,仍然无法实现抢占。

4 NUMA 调度模型 从 Go1.2 后,Go 将重点放在对 GC 的低延迟的优化上,只是一些小的改动。

5 其他优化 Go 运行时已经实现了 netpoller(https://morsmachine.dk/netpoller,即便 G 发起网络 I/O 操作,也不会导致 M 被阻塞(仅阻塞 G),从而不会导致大量(M)被创建出来。但是对于常规 I/O 操作一旦阻塞,那么线程(M)将进入挂起状态,等待 I/O 返回后被唤醒。这时,P 将与挂起的 M 分离,再选择一个处于空闲的 M.如果此时没有空闲的 M,则新建一个 M,这就是为何大量 I/O 操作会导致大量线程被创建的原因。)

三 对 Go 调度器深入了解

1. G、P、M G、P、M 的定义,在 $GOROOT/src/runtime/runtime2.go 源文件中。 G、P、M 这三个结构体定义都是很繁重的,每个结构体定义都包含十几甚至二、三十个字段。像调度器这样的核心代码都是非常复杂的,考虑的因素也很多。简单说明一下:

G: 它是 Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等(G 是可以重用的)。

P: 它是逻辑 Processor,P 的数量决定了系统内最大可并行的 G 的数据(物理 CPU 核数>=P 的数量);P 最大的作用是它有各种 G 对象队列、链表、缓存和状态。

M: 它是真正执行计算的资源。在绑定有效的 P 后,一个调度循环开始;而调度循环的机制是从各种队列、P 的本地运行队列中获取 G,切换到 G 的执行栈上并行执行 G 的函数,调用 goexit 做清理工作,然后回到 M。这样反复。M 并不保存 G 的状态,这是 G 可以跨 M 调度的基础。

G 被抢占调用调度 操作系统是按时间片调度线程的,Go 并没有时间片的概念。如果某个 G 没有进行系统调用、没有 I/O 操作、没有阻塞在一个 channel 上,那么 M 是怎么让 G 停下来并调度下一个可运行的 G? 这就要说抢占调度了。

上面说了,除非是无限死循环,否则只要 G 调用函数,Go 运行时就有了抢占 G 的机会。GO 程序启动的时候,运行时会启动一个名为 sysmon 的 M(你可以简单理解为监控器或监控协程),该 M 特殊之处就是其无需绑定 P 即可运行(以 g0 的形式),该 M 在整个 Go 程序的运行过程中非常重要。

$GOROOT/src/runtime/proc.go

//The main goroutine. func main() { ... ... systemstack(func() { newm(sysmon, nil) }) .... ... }

// Always runs without a P, so write barriers are not allowed. // //go:nowritebarrierrec func sysmon() { // If a heap span goes unused for 5 minutes after a garbage collection, // we hand it back to the operating system. scavengelimit := int64(5 * 60 * 1e9) ... ...

if .... { ... ... // retake P's blocked in syscalls // and preempt long running G's if retake(now) != 0 { idle = 0 } else { idle++ } ... ... } }

从上面源代码可以看到 symon 每 20us—10ms 启动一次,sysmon 主要工作:

释放闲置超过 5 分钟的 span 物理内存; 如果超过 2 分钟没有垃圾回收,强制执行; 将长时间未处理的 netpoll 结果添加到任务队列; 向长时间运行的 G 任务发出抢占调度; 收回因 syscall 长时间阻塞的 P;

3 channel 阻塞或网络 I/O 下的调度

如果 G 被阻塞在某个 channel 操作或者网络 I/O 操作上的时候,G 会被放入到某个等待队列中,而 M 会尝试运行 P 的下一个可运行的 G;如果此时 P 没有可运行的 G 给 M 运行,那么 M 将解绑 P,并进入挂起状态。当 I/O 或者 channel 操作完成,在等待队列中的 G 会被唤醒,标记为可运行,并被放入到某个 P 队列中,绑定一个 M 后继续运行。

4 系统调用阻塞情况下,如何调度

如果 G 被阻塞在某个系统调用上,那么不仅仅 G 会阻塞,执行 G 的 M 也会解绑 P,与 G 一起进入挂起状态。如果此时有空闲的 M,则 P 和与其绑定并继续执行其他的 G;如果没有空闲的 M,但还是有其他 G 去执行,那么会创建一个新 M。当系统调用返回后,阻塞在该系统调用上的 G 会尝试获取一个可用的 P,如果没有可用的 P,那么这个 G 会被标记为 runnable,之前的那个挂起的 M 将再次进入挂起状态。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册