分享 Go Runtime 的调度器

happy_brother · 2021年06月10日 · 最后由 alexoo 回复于 2021年06月11日 · 494 次阅读
本帖已被设为精华帖!

以 goroutine 形式进行 Go 并发编程是一种非常方便的方法,但有没有想过他是如何有效地运行这些 goroutine?下面从设计的角度,深入了解和研究 Go 运行时调度程序,以及如何在性能调试过程中使用它来解释 Go 程序的调度程序跟踪信息。

要了解为什么需要有一个运行时的调度以及它是如何工作的,先要回到操作系统的历史上,在这里将找到答案,因为如果不了解问题的根源。

操作系统的历史

  1. 单用户(无操作系统)
  2. 批处理 单编程 运行完成
  3. 多程序

多程序的目的是使 CPU 和 I/O 重叠。如何做到的呢?

  • 多批次
    IBM OS / MFT(具有固定数量的任务的多重编程)
  • 多批次
    IBM OS / MVT(具有可变数量的任务的多重编程)—在这里,每个作业仅获得其所需的内存量。即,随着作业的进出,内存的分区发生变化。
  • 分时
    这是在作业之间快速切换的多道程序设计。决定何时切换以及切换到哪些作业称为调度。

现代大多数操作系统使用分时调度程序。

这些调度程序调度的是什么呢?
1 不同的程序正在执行(进程)
2 作为进程子集存在的 CPU 利用率(线程)的基本单位

这些都是有代价的。

调度成本

因此,使用一个包含多个线程的进程效率更高,因为进程创建既耗时又耗费资源。随后出现了多线程问题:C10k 问题是主要的问题。

例如,如果将调度程序周期定义为 10 毫秒(毫秒),并且有 2 个线程,则每个线程将分别获得 5 毫秒。如果您有 5 个线程,则每个线程将获得 2ms。但是,如果有 1000 个线程怎么办?给每个线程 10s(微秒)的时间片?你将花费大量时间进行上下文切换,但是无法完成真正的工作。

你需要限制时间片的长度。在最后一种情况下,如果最小时间片是 2ms,并且有 1000 个线程,则调度程序周期需要增加到 2s(秒)。如果有 10,000 个线程,则调度程序周期为 20 秒。在这个简单的示例中,如果每个线程都使用其全时切片,则所有线程一次运行需要 20 秒。因此,我们需要一些可以使并发成本降低而又不会造成过多开销的东西。

用户级线程
• 线程完全由运行时系统(用户级库)管理。
• 理想情况下,快速高效:切换线程不比函数调用贵多少。
• 内核对用户级线程一无所知,并像对待单线程进程一样对其进行管理。

在 Go 中,我们叫它 “ Goroutine”(在逻辑上)

Goroutine

协程是轻量级线程,由 Go 运行时管理(逻辑上一个执行的线程)。要 go 在函数调用之前启动运行 go 关键字。

func main() {
    var wg sync.WaitGroup
    wg.Add(11)
    for i := 0; i <= 10; i++ {

        go func(i int) {
          defer wg.Done()
          fmt.Printf("loop i is - %d\n", i)
        }(i)
    }
    wg.Wait()
    fmt.Println("Hello, Welcome to Go")
}
运行结果

loop i is - 10
loop i is - 0
loop i is - 1
loop i is - 2
loop i is - 3
loop i is - 4
loop i is - 5
loop i is - 6
loop i is - 7
loop i is - 8
loop i is - 9
Hello, Welcome to Go

看一下输出,就会有两个问题。

  1. 11 个 goroutine 如何并发运行的?
  2. goroutine 以什么顺序运行?

这两个问题,又引发新的思考:

  1. 如何将这些多个 goroutine 分布到在可用 CPU 处理器上运行的多个 OS 线程上。
  2. 这些多个 goroutine 应该以什么顺序运行以保持公平性?

其余的讨论将主要围绕从设计角度解决 Go 运行时调度程序的这些问题。调度程序可能会瞄准许多目标中的一个或多个,对于我们的案例,我们将限制自己满足以下要求。

  1. 应该是并行的、可扩展的、公平的。
  2. 每个进程应可扩展到数百万个 goroutine(10⁶)
  3. 内存高效。(RAM 很便宜,但不是免费的。)
  4. 系统调用不应导致性能下降。(最大化吞吐量,最小化等待时间)

因此,让我们开始为调度程序建模,以逐步解决这些问题。

1.每个 Goroutine 线程——用户级线程。

  限制

    1.并行和可扩展。
      * 并行
      * 可扩展
    2. 每个进程不能扩展到数百万个 goroutine(10⁶)

2.M:N 线程——混合线程

M个内核线程执行N个“ goroutine”

M 个内核线程执行 N 个 “ goroutine”

代码和并行的实际执行需要内核线程。但是创建成本很高,所以将 N 个 goroutine 映射到 M Kernel Thread。Goroutine 是 Go Code,所以我们可以完全控制它。此外,它在用户空间中,因此创建起来很便宜。

但是因为操作系统对 goroutine 一无所知。每个 goroutine 都有一个 state 来帮助 Scheduler 根据 goroutine state 知道要运行哪个 goroutine。与内核线程相比,这个状态信息很小,goroutine 的上下文切换变得非常快。

  • Running-当前在内核线程上运行的 goroutine。
  • Runnable-够程等待内核线程来运行。
  • Blocked-等待某些条件的 Goroutine(例如,在通道,系统调用,互斥体等上被阻止)


2 个线程一次运行 2 个

因此,Go Runtime Scheduler 通过将 N Goroutine 复用到 M 内核线程来管理处于各种状态的这些 goroutine。

简单的 MN 排程器
在我们简单的 M:N Scheduler 中,我们有一个全局运行队列,某些操作将一个新的 goroutine 放入运行队列。M 个内核线程访问调度程序以从 “运行队列” 中获取 goroutine 来运行。多个线程尝试访问相同的内存区域,我们将使用 Mutex For Memory Access Synchronization 锁定此结构。

简单的 M:N

阻塞的 goroutine 在哪里?
可以阻塞的 goroutine 一些实例。

  1. 在 channel 上发送和接收。
  2. 网络 I/O。
  3. 阻止系统调用。
  4. 计时器。
  5. 互斥体。

那么,我们将这些阻塞的 goroutine 放在哪里?

阻塞的goroutine不应阻塞底层的内核线程!(避免线程上下文切换成本)

通道操作期间阻止了 Goroutine。
每个通道都有一个 recvq(waitq),用于存储被阻止的 goroutine,这些 goroutine 试图从该通道读取数据。
Sendq(waitq)存储试图将数据发送到通道的被阻止的 goroutine 。

通道操作期间阻止了 Goroutine。
通道操作后的未阻塞的goroutine被通道放入“运行”队列。

通道操作后接触阻塞的 goroutine

系统调用呢?

首先,让我们看看阻塞系统调用。一个阻塞底层内核线程的系统调用,所以我们不能在这个线程上调度任何其他 Goroutine。

隐含阻塞系统调用降低了并行级别。

不能在 M2 线程上调度任何其他 Goroutine,导致 CPU 浪费。

我们可以恢复并行度的方法是,当我们进入系统调用时,我们可以唤醒另一个线程,该线程将从运行队列中选择可运行的 goroutine。

现在,当系统调用完成时,超额执行了 Groutine 计划。为了避免这种情况,我们不会立即运行 Goroutine 从阻止系统调用中返回。但是我们会将其放入调度程序运行队列中。

避免过度预定的调度

因此,当我们的程序运行时,线程数大于内核数。尽管没有明确说明,线程数大于内核数,并且所有空闲线程也都由运行时管理,以避免过多的线程。

初始设置为 10,000 个线程,如果超过则程序崩溃。

非阻塞系统调用 --- 在集成运行时轮询器上阻塞 goroutine ,并释放线程以运行另一个 goroutine。

例如在非阻塞 I/O 的情况下,例如 HTTP 调用。第一个系统调用 - 遵循先前的工作流程 - 不会成功,因为资源尚未准备好,迫使 Go 使用网络轮询器并停放 goroutine。

这是部分 net.Read 功能的实现。

n, err := syscall.Read(fd.Sysfd, p)
if err != nil {
  n = 0
  if err == syscall.EAGAIN && fd.pd.pollable() {
    if err = fd.pd.waitRead(fd.isFile); err == nil {
    continue
  }
}

一旦完成第一个系统调用并明确指出资源尚未准备好,goroutine 将停放,直到网络轮询器通知它资源已准备好为止。在这种情况下,线程 M 将不会被阻塞。

Poller 将基于操作系统使用 select/kqueue/epoll/IOCP 来了解哪个文件描述符已准备好,一旦文件描述符准备好进行读取或写入,它将把 goroutine 放回到运行队列中。

还有一个 Sysmon OS 线程,如果轮询时间不超过 10 毫秒,它将定期轮询网络,并将就绪 G 添加到队列中。

基本上所有的 goroutine 都被阻止在

  1. 渠道
  2. 互斥体
  3. 网络 IO
  4. 计时器

现在,运行时具有具有以下功能的调度程序。

  • 它可以处理并行执行(多线程)。
  • 处理阻止系统调用和网络 I/O。
  • 处理阻止用户级别(在通道上)的调用。

但这不是可扩展的

使用 Mutex 的全局运行队列

如图所见,我们有一个 Mutex 全局运行队列,最终会遇到一些问题,例如

  1. 缓存一致性保证的开销。
  2. 在创建,销毁和调度 Goroutine G 时进行激烈的锁争用。

使用分布式调度器克服可扩展性的问题。

分布式调度程序—每个线程运行队列。

分布式运行队列调度程序

这样,我们可以看到的直接好处是,对于每个线程本地运行队列,我们现在都没有互斥体。仍然有一个带有互斥量的全局运行队列,在特殊情况下使用。它不会影响可伸缩性。

现在,我们有多个运行队列。

  1. 本地运行队列
  2. 全局运行队列
  3. 网络轮训器

我们应该从哪里运行下一个 goroutine?

在 Go 中,轮询顺序定义如下。

  1. 本地运行队列
  2. 全局运行队列
  3. 网络轮训器
  4. 工作偷窃(Work Stealing)

即首先检查本地运行队列,如果为空则检查全局运行队列,然后检查网络轮询器,最后进行窃取工作。到目前为止,我们对 1,2,3 有了一些概述。让我们看一下 “窃取工作”。

工作偷窃

如果本地工作队列为空,请尝试 “从其他队列中窃取工作”

“偷窃” 工作

当一个线程有太多的工作要做而另一个线程处于空闲状态时,工作窃取解决了这个问题。在 Go 中,如果本地队列为空,窃取工作将尝试满足以下条件之一。

  • 从全局队列中拉取工作。
  • 从网络轮询器中拉取工作。
  • 从其他本地队列中窃取工作。

到目前为止,运行时 Go 具有具有以下功能的 Scheduler。

  • 它可以处理并行执行(多线程)。
  • 处理阻止系统调用和网络 I/O。
  • 处理阻止用户级别(在通道上)的调用。
  • 可扩展

但这不是有效的。

还记得我们在阻塞系统调用中恢复并行度的方式吗?

它的含义是,在一个系统调用中我们可以有多个内核线程(可以是 10 或 1000),这可能会增加内核数。我们最终在以下期间产生了恒定的开销:

  • 窃取工作时,它必须同时扫描所有内核线程(理想情况下并使用 goroutine 运行)本地运行队列,并且大多数都将为空。
  • 垃圾回收,内存分配器都遭受相同的扫描问题。

使用 M:P:N 线程克服效率问题。

3.M:P:N(3 级调度程序)线程化—逻辑处理器 P 简介

P — 处理器,可以将其视为在线程上运行的本地调度程序;

M:P:N 线程

逻辑进程 P 的数量始终是固定的。(默认为当前进程可以使用的逻辑 CPU)

将本地运行队列(LRQ)放入固定数量的逻辑处理器(P)中。

分布式三级运行队列调度程序

Go 运行时将首先根据计算机的逻辑 CPU 数量(或根据请求)创建固定数量的逻辑处理器 P。

每个 goroutine(G)将在分配给逻辑 CPU(P)的 OS 线程(M)上运行。

因此,现在我们在以下期间没有固定的开销:

  • 窃取工作 - 只需扫描固定数量的逻辑处理器(P)本地运行队列。
  • 垃圾回收,内存分配器也获得相同的好处。

带有固定逻辑处理器(P)的系统调用怎么样?

Go通过将系统调用包装在运行时中来优化系统调用-无论它是否阻塞

阻止系统调用包装器

Blocking SYSCALL 方法封装在 runtime.entersyscall(SB)
runtime.exitsyscall(SB)之间。
从字面上看,某些逻辑在进入系统调用之前执行,而某些逻辑在退出系统调用之后执行。进行阻塞系统调用时,此包装器将自动从线程 M 分离 P,并允许另一个线程在其上运行。

阻塞系统调用切换 P

这允许 Go 运行时在不增加运行队列的情况下有效地处理阻塞系统调用。

一旦阻止 syscall 退出,会发生什么?

  • 运行时尝试获取完全相同的 P,然后继续执行。
  • 运行时尝试在空闲列表中获取一个 P 并恢复执行。
  • 运行时将 goroutine 放到全局队列中,并将关联的 M 放回空闲列表。

自旋线程和理想线程(Spinning Thread and Ideal Thread).

当 M2 线程在 syscall 返回后变成理想理想线程时。该理想的 M2 线程该怎么办。理论上,一个线程如果完成了它需要做的事情就应该被操作系统销毁,然后其他进程中的线程可能会被 CPU 调度执行。这就是我们常说的操作系统中线程的 “抢占式调度”。

考虑上述 syscall 中的情况。如果我们销毁了 M2 线程,而 M3 线程即将进入 syscall。此时,在创建新的内核线程并将其计划由 OS 执行之前,无法处理可运行的 goroutine。频繁的线程前抢占操作不仅会增加 OS 的负载,而且对于性能要求更高的程序几乎是不可接受的。

因此,为了正确利用操作系统的资源并防止频繁的线程抢占操作系统上的负载,我们不会破坏内核线程 M2,而是进行自旋操作并保存以备将来使用。尽管这似乎是在浪费一些资源。但是,与线程之间的频繁抢占以及频繁的创建和销毁操作相比,“理想的线程” 仍然要付出更少的代价。

Spinning Thread —例如,在具有一个内核线程 M(1)和一个逻辑处理器(P)的 Go 程序中,如果正在执行的 M 被 syscall 阻止,则 “ Spinning Threads” 的数目与该数目相同需要 P 的值以允许等待的可运行 goroutine 继续执行。因此,在此期间,内核线程的数量 M 大于 P 的数量(旋转线程 + 阻塞线程)。因此,即使将 runtime.GOMAXPROCSvalue 设置为 1,程序也将处于多线程状态。

调度中的公平性如何?—公平选择下一步要执行的 goroutine。

与许多其他调度程序一样,Go也具有公平性约束,并且由goroutine的实现所强加,因为Runnable goroutine应该最终运行

以下是 Go Runtime Scheduler 中的四个典型的公平性约束。

任何运行超过 10 毫秒的 goroutine 都被标记为可抢占(软限制)。但是,抢占仅在函数序言中完成。Go 目前在函数 prologues 中使用编译器插入的合作抢占点。

    无限循环——抢占(~10ms 时间片)——软限制

但是要小心无限循环,因为 Go 的调度程序不是抢占式的(直到 1.13)。如果循环不包含任何抢占点(如函数调用或分配内存),它们将阻止其他 goroutine 运行。一个简单的例子是:

package main
func main() {
    go println("goroutine ran")
    for {}
}

执行命令

GOMAXPROCS = 1 go run main.go

直到 Go(1.13)才可能打印该语句。由于缺少抢占点,因此主要的 Goroutine 可以占用处理器。

  • 本地运行队列 - 抢占(〜10ms 时间片)- 软限制
  • 通过每 61 个调度程序刻度检查一次全局运行队列,可以避免全局运行队列饥饿。
  • Network Poller Starvation 后台线程轮询网络偶尔会被主工作线程轮询。

Go 1.14 有一个新的 “非合作式抢占”。

有了 Go,Runtime 有了一个 Scheduler,它具有所有必需的功能。

  • 它可以处理并行执行(多线程)。
  • 处理阻止系统调用和网络 I / O。
  • 处理阻止用户级别(在通道上)的调用。
  • 可扩展
  • 高效的。
  • 公平的。

这提供了大量的并发性,并且始终尝试实现最大的利用率和最小的延迟。

现在,我们总体上对 Go 运行时调度程序有了一些了解,我们如何使用它?Go 为我们提供了一个跟踪工具,即调度程序跟踪,目的是提供有关行为的见解并调试与 goroutine 调度程序有关的可伸缩性问题。

调度程序跟踪

使用 GODEBUG = schedtrace = DURATION 环境变量运行 Go 程序以启用调度程序跟踪。(DURATION 是以毫秒为单位的输出周期。)

更多原创文章干货分享,请关注公众号

加微信实战群请加微信 (注明:gocn)

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 06月10日 10:17

M 个内核线程执行 N 个 “ goroutine” —— 这个表述似乎有问题,golang 里面执行 goroutine 应该不是内核线程而是普通线程吧?

mahuaibo GoCN 每日新闻 (2021-06-10) 中提及了此贴 06月11日 04:08
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册