Go 语言
并发编程
goroutine 调度原理

goroutine 调度原理

goroutine 是由 Go 运行时管理的用户层轻量级线程。 相较于操作系统线程,goroutine 的资源占用和使用代价都要小很多。我们可以创建几十个、几百个甚至成千上万个 goroutine。 Go 的运行时负责对 goroutine 进行管理,所谓的管理就是“调度”。调度就是决定何时哪个 goroutine 将获得资源开始执行,哪个 goroutine 应该停止执行让出资源,哪个 goroutine 被唤醒恢复执行等等。

调度器

由于一个 goroutine 占用资源很少,一个 Go 程序中可以创建成千上万个并发的 goroutine。 而将这些 goroutine 按照一定的算法放到 CPU 上执行的程序就被称为 goroutine 调度器。 一个 Go 程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程,goroutine 的调度全靠 Go 自己完成。

调度模型

goroutine 调度器也是经历了演进,下面我们来一一讲解。

G-M 模型

Go 1.0 版本的调度模型是 G-M 模型。G 代表 goroutine,M 代表操作系统的线程。Dmitry Vyukov 指出这个模型有个明显的不足就是限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。

问题主要体现如下几个方面:

  • 单一全局互斥锁和集中状态存储的存在导致所有 goroutine 相关操作(如创建、重新调度等)都要竞争这个全局锁(上锁)。
  • goroutine 传递问题:经常在 M 之间传递“可运行”的 goroutine,会导致调度延迟增大,带来性能损耗。
  • 每个 M 都做内存缓存,导致内存占用过高,数据局部性差。
  • 因系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞会带来额外的性能损耗。

G-P-M 模型

在 Go 1.1 版本之后,Dmitry Vyukov 亲自操刀改进了 goroutine 调度器,实现了 G-P-M 调度模型和 work stealing 算法。

有人说过:“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。” G-P-M 调度模型就是这一理论的践行者。他向 G-M 模型中增加了一个 P,使得 goroutine 调度器具有很好的伸缩性。

P 是一个“逻辑处理器”,每个 G 想要真正运行起来,首先需要分配一个 P,即进入 P 的本地运行队列(local runq)中。对于 G 来说,P 就是运行它的 CPU, 可以说 G 的眼里只有 P。 但从调度器的视角看,真正的 CPU 是 M,只有将 P 和 M 绑定才能让 P 的本地运行队列中的 G 真正运行起来。 这样的 P 与 M 的关系就好比 linux 操作系统调度层面用户线程(user thread)与内核线程(kernel thread)的对应关系:多对多(N:M)。

g-p-m

抢占式调度

G-P-M 模型的实现的调度器的一大进步,但调度器因仍存在一个问题,那就是不支持抢占式调度。 这导致一旦某个 G 中出现死循环的代码逻辑,那么 G 将永久占用分配给它的 P 和 M,而位于同一个 P 中的其他 G 将得不到调度,出现“饿死情况”。 更严重的是,如果当只有一个 P(GOMAXPROCS=1)时,整个 Go 程序中的其他 G 都将”饿死“。

于是在 Go 1.2 版本实现了抢占式调度。

这个抢占式调度的原理是在每个函数或方法的入口加上一段额外的代码,让运行时有机会检查是否需要执行抢占调度。这种协作式抢占调度的解决方案只是局部解决了饿死问题。

Go 1.14 版本中加入了基于系统信号的 goroutine 抢占式调度机制,很大程度上解决了 goroutine 饿死问题。

进一步理解调度器

G、P、M

关于 G、P、M 的定义,可以参加src/runtime/runtime2.go文件中的定义。这三个结构体定义都是大块头,每个结构体定义都包含十几甚至是二三十个字段。 调度器的核心代码都很复杂,考虑的因素很多,代码耦合在一起,不过从复杂的代码中,我们依然可以看出 G、P、M 的各自的大致作用。

  • G:代表一个 goroutine,它存储了 goroutine 的执行栈信息、任何函数、状态信息等,另外 G 对象是可以重用的。
  • P:代表逻辑 processor,P 的数量决定了系统内最大可并行的 G 数量(前提:系统的物流 CPU 核数 >= P 的数量)。P 中最有用的是其拥有的各种 G 对象队列,链表,一些缓存和状态。
  • M:代表真正的执行计算资源。在绑定有效的 P 后,进入一个调度循环;而调度循环的机制大致是各种队列、P 的本地运行队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M。这是 G 可以跨 M 的调度基础。

G 被抢占调度

与操作系统按时间片调度线程不同,Go 中没有时间片概念。 如果某个 G 没有进行系统调用(syscall)、没有进行 I/O 操作,没有阻塞在 channel 操作上,那么 M 是如何让 G 停下来并调度下一个可运行的 G 呢?答案是:G 是被抢占调度的。

在 Go 程序启动时,运行时会启动一个名为 sysmon 的 M (一般称为监控线程),该 M 的特殊之处在于它无须绑定 P 即可运行(以 g0 这个 G 的形式)。该 M 在整个 Go 程序的运行过程中至关重要。

我们来看一下代码:

// src/runtime/proc.go
 
func main() {
    // ...
    // 启动监控线程
    newm(sysmon, -1)
    // ...
}
 
// 运行无须 P 参与
// go:nowritebarrierrec
func sysmon() {
    // 如果一个 heap span 在垃圾回收后 5 分钟内没有被使用
    // 则将其返回给操作系统
    scavengelimit := int64(5 * 60 * 1e9) // 5分钟
    ...
    if ... {
        ...
        // 夺回被阻塞在系统调用中的 P
        // 抢占长期运行的 G
        if  retake(now) != 0 {
            idle = 0
        } else {
            idle++
        }
        ...
    }
}

sysmon 每 20us ~ 10ms 启动一次,主要完成如下工作:

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

向长时间运行的 G 任务发出抢占调度,这是由函数 retake 实施:

// forcePreemptNS 是在一个 G 被抢占之前的时间片
const forcePreemptNS = 10 * 1e6 // 10ms
 
func  retake(now int64) uint32 {
    ...
    // 抢占运行时间过长的 G
    t := int64(_p_.schedtick)
    if int64(pd.schedtick) != t {
        pd.schedtick = uint32(t)
        pd.schedwhen = now
        continue
    }
    if  pd.schedwhen + forcePreemptNS > now {
        continue
    }
    preemptone(_p_)
    ...
}

如果一个 G 任务运行超过 10 ms,sysmon 就会认为其运行时间太久而发出抢占式调度的请求。 一旦这个 G 的抢占标志位被设为 true,那么在这个 G 下一次调用函数或方法时,运行时便可以将 G 抢占并溢出运行状态,放入 P 的本地运行队列中(如果 P 队列已满则放到全局运行队列),然后调度下一个可运行的 G。

其他情况的调度

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

如果 G 被阻塞在某个 channel 操作或网络 I/O 操作上,那么 G 将会防止到某个等待队列中,而 M 会尝试运行 P 的下一个可运行的 G。 如果此时 P 没有可运行的 G 供 M 运行,那么 M 将解绑 P,并进入挂起状态。 当 channel 操作或网络 I/O 操作完成,在等待队列中的 G 将会被唤醒,标记为 runnable,放入到全局队列中,等到调度器再次调度。

系统调用阻塞情况下的调度

如果 G 被阻塞在某个系统调用上,那么不仅 G 会阻塞,执行该 G 的 M 也会解绑 P (实质是被 sysmon 抢占),与 G 一起进入阻塞状态。 如果此时有空闲的 M,则 P 会与其绑定并继续执行其他 G;如果没有空闲的 M,但仍然有其他 G 要执行,那么就会创建一个新 M (线程)。 当系统调用返回后,阻塞在该系统调用上的 G 会尝试取一个可用的 P,如果有可用 P,之前运行该 G 的 M 将绑定 P 继续运行 G ; 如果没有可用 P,那么 G 与 M 之间的关联将解除,同时 G 会被标记为 runnable,放入全局运行的队列中,等待调度器再次调度。

查看调度器的状态

Go 提供了调度器当前状态的查看方法:使用 Go 运行时环境变量 GODEBUG.

GODEBUG=schedtrace=1000 ./main

schedtrace=1000 含义就是每 1000ms 打印输出一次 goroutine 调度器的状态。

SCHED 0ms: gomaxprocs=4 idleprocs=0 threads=4 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 0]
SCHED 1000ms: gomaxprocs=4 idleprocs=0 threads=4 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 0]
SCHED 2000ms: gomaxprocs=4 idleprocs=0 threads=4 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 0]

每一行各字段的含义如下:

  • SCHED 1000ms:表示当前时间
  • gomaxprocs=4:表示当前系统的逻辑处理器数量
  • idleprocs=0:表示当前空闲的逻辑处理器数量
  • threads=4:表示当前系统的线程数量
  • spinningthreads=0:表示当前自旋的线程数量
  • idlethreads=0:表示当前空闲的线程数量
  • runqueue=0:表示当前全局运行队列中的 G 数量
  • [0 0 0 0]:表示当前每个逻辑处理器的本地运行队列中的 G 数量

还可以使用 scheddetail=1 输出详细的调度信息:

GODEBUG=schedtrace=1000,scheddetail=1 ./main