Golang - 调度剖析【第二部分】

回顾本系列的 第一部分,重点讲述了操做系统调度器的各个方面,这些知识对于理解和分析 Go 调度器的语义是很是重要的。
在本文中,我将从语义层面解析 Go 调度器是如何工做的,并重点介绍其高级特性。
Go 调度器是一个很是复杂的系统,咱们不会过度关注一些细节,而是侧重于剖析它的设计模型和工做方式。
咱们经过学习它的优势以便够作出更好的工程决策。

开始

当 Go 程序启动时,它会为主机上标识的每一个虚拟核心提供一个逻辑处理器(P)。若是处理器每一个物理核心能够提供多个硬件线程(超线程),那么每一个硬件线程都将做为虚拟核心呈现给 Go 程序。为了更好地理解这一点,下面实验都基于以下配置的 MacBook Pro 的系统。golang

图片描述

能够看到它是一个 4 核 8 线程的处理器。这将告诉 Go 程序有 8 个虚拟核心可用于并行执行系统线程。segmentfault

用下面的程序来验证一下:安全

package main

import (
    "fmt"
    "runtime"
)

func main() {

    // NumCPU 返回当前可用的逻辑处理核心的数量
    fmt.Println(runtime.NumCPU())
}

当我运行该程序时,NumCPU() 函数调用的结果将是 8 。意味着在个人机器上运行的任何 Go 程序都将被赋予 8 个 P网络

每一个 P 都被分配一个系统线程 M 。M 表明机器(machine),它仍然是由操做系统管理的,操做系统负责将线程放在一个核心上执行。这意味着当在个人机器上运行 Go 程序时,有 8 个线程能够执行个人工做,每一个线程单独链接到一个 P。多线程

每一个 Go 程序都有一个初始 G。G 表明 Go 协程(Goroutine),它是 Go 程序的执行路径。Goroutine 本质上是一个 Coroutine,但由于是 Go 语言,因此把字母 “C” 换成了 “G”,咱们获得了这个词。你能够将 Goroutines 看做是应用程序级别的线程,它在许多方面与系统线程都类似。正如系统线程在物理核心上进行上下文切换同样,Goroutines 在 M 上进行上下文切换。并发

最后一个重点是运行队列。Go 调度器中有两个不一样的运行队列:全局运行队列(GRQ)本地运行队列(LRQ)每一个 P 都有一个LRQ,用于管理分配给在P的上下文中执行的 Goroutines,这些 Goroutine 轮流被P绑定的M进行上下文切换。GRQ 适用于还没有分配给P的 Goroutines。其中有一个过程是将 Goroutines 从 GRQ 转移到 LRQ,咱们将在稍后讨论。负载均衡

下面图示展现了它们之间的关系:异步

图片描述

协做式调度器

正如咱们在第一篇文章中所讨论的,OS 调度器是一个抢占式调度器。从本质上看,这意味着你没法预测调度程序在任何给定时间将执行的操做。由内核作决定,一切都是不肯定的。在操做系统之上运行的应用程序没法经过调度控制内核内部发生的事情,除非它们利用像 atomic 指令 和 mutex 调用之类的同步原语。函数

Go 调度器是 Go 运行时的一部分,Go 运行时内置在应用程序中。这意味着 Go 调度器在内核之上的用户空间中运行。Go 调度器的当前实现不是抢占式调度器,而是协做式调度器。做为一个协做的调度器,意味着调度器须要明肯定义用户空间事件,这些事件发生在代码中的安全点,以作出调度决策。oop

Go 协做式调度器的优势在于它看起来和感受上都是抢占式的。你没法预测 Go 调度器将会执行的操做。这是由于这个协做调度器的决策不掌握在开发人员手中,而是在 Go 运行时。将 Go 调度器视为抢占式调度器是很是重要的,而且因为调度程序是非肯定性的,所以这并非一件容易的事。

Goroutine 状态

就像线程同样,Goroutines 有相同的三个高级状态。它们标识了 Go 调度器在任何给定的 Goroutine 中所起的做用。Goroutine 能够处于三种状态之一:Waiting(等待状态)Runnable(可运行状态)Executing(运行中状态)

Waiting这意味着 Goroutine 已中止并等待一些事情以继续。这多是由于等待操做系统(系统调用)或同步调用(原子和互斥操做)等缘由。这些类型的延迟是性能降低的根本缘由。

Runnable 这意味着 Goroutine 须要M上的时间片,来执行它的指令。若是同一时间有不少 Goroutines 在竞争时间片,它们都必须等待更长时间才能获得时间片,并且每一个 Goroutine 得到的时间片都缩短了。这种类型的调度延迟也可能致使性能降低。

Executing 这意味着 Goroutine 已经被放置在M上而且正在执行它的指令。与应用程序相关的工做正在完成。这是每一个人都想要的。

上下文切换

Go 调度器须要有明肯定义的用户空间事件,这些事件发生在要切换上下文的代码中的安全点上。这些事件和安全点在函数调用中表现出来。函数调用对于 Go 调度器的运行情况是相当重要的。如今(使用 Go 1.11或更低版本),若是你运行任何未进行函数调用的紧凑循环,你会致使调度器和垃圾回收有延迟。让函数调用在合理的时间范围内发生是相当重要的。

注意:在 Go 1.12 版本中有一个提议被接受了,它可使 Go 调度器使用非协做抢占技术,以容许抢占紧密循环。

在 Go 程序中有四类事件,它们容许调度器作出调度决策:

  • 使用关键字 go
  • 垃圾回收
  • 系统调用
  • 同步和编配

使用关键字 go

关键字 go 是用来建立 Goroutines 的。一旦建立了新的 Goroutine,它就为调度器作出调度决策提供了机会。

垃圾回收

因为 GC 使用本身的 Goroutine 运行,因此这些 Goroutine 须要在 M 上运行的时间片。这会致使 GC 产生大量的调度混乱。可是,调度程序很是聪明地了解 Goroutine 正在作什么,它将智能地作出一些决策。

系统调用

若是 Goroutine 进行系统调用,那么会致使这个 Goroutine 阻塞当前M,有时调度器可以将 Goroutine 从M换出并将新的 Goroutine 换入。然而,有时须要新的M继续执行在P中排队的 Goroutines。这是如何工做的将在下一节中更详细地解释。

同步和编配

若是原子、互斥量或通道操做调用将致使 Goroutine 阻塞,调度器能够将之切换到一个新的 Goroutine 去运行。一旦 Goroutine 能够再次运行,它就能够从新排队,并最终在M上切换回来。

异步系统调用

当你的操做系统可以异步处理系统调用时,可使用称为网络轮询器的东西来更有效地处理系统调用。这是经过在这些操做系统中使用 kqueue(MacOS),epoll(Linux)或 iocp(Windows)来实现的。

基于网络的系统调用能够由咱们今天使用的许多操做系统异步处理。这就是为何我管它叫网络轮询器,由于它的主要用途是处理网络操做。经过使用网络轮询器进行网络系统调用,调度器能够防止 Goroutine 在进行这些系统调用时阻塞M。这可让M执行P的 LRQ 中其余的 Goroutines,而不须要建立新的M。有助于减小操做系统上的调度负载。

下图展现它的工做原理:G1正在M上执行,还有 3 个 Goroutine 在 LRQ 上等待执行。网络轮询器空闲着,什么都没干。

图片描述

接下来,状况发生了变化:G1想要进行网络系统调用,所以它被移动到网络轮询器而且处理异步网络系统调用。而后,M能够从 LRQ 执行另外的 Goroutine。此时,G2就被上下文切换到M上了。

图片描述

最后:异步网络系统调用由网络轮询器完成,G1被移回到P的 LRQ 中。一旦G1能够在M上进行上下文切换,它负责的 Go 相关代码就能够再次执行。这里的最大优点是,执行网络系统调用不须要额外的M。网络轮询器使用系统线程,它时刻处理一个有效的事件循环。

图片描述

同步系统调用

若是 Goroutine 要执行同步的系统调用,会发生什么?在这种状况下,网络轮询器没法使用,而进行系统调用的 Goroutine 将阻塞当前M。这是不幸的,可是没有办法防止这种状况发生。须要同步进行的系统调用的一个例子是基于文件的系统调用。若是你正在使用 CGO,则可能还有其余状况,调用 C 函数也会阻塞M

注意:Windows 操做系统确实可以异步进行基于文件的系统调用。从技术上讲,在 Windows 上运行时,可使用网络轮询器。

让咱们来看看同步系统调用(如文件I/O)会致使M阻塞的状况:G1将进行同步系统调用以阻塞M1

图片描述

调度器介入后:识别出G1已致使M1阻塞,此时,调度器将M1P分离,同时也将G1带走。而后调度器引入新的M2来服务P。此时,能够从 LRQ 中选择G2并在M2上进行上下文切换。

图片描述

阻塞的系统调用完成后:G1能够移回 LRQ 并再次由P执行。若是这种状况须要再次发生,M1将被放在旁边以备未来使用。

图片描述

任务窃取(负载均衡思想)

调度器的另外一个方面是它是一个任务窃取的调度器。这有助于在一些领域保持高效率的调度。首先,你最不但愿的事情是M进入等待状态,由于一旦发生这种状况,操做系统就会将M从内核切换出去。这意味着P没法完成任何工做,即便有 Goroutine 处于可运行状态也不行,直到一个M被上下文切换回核心。任务窃取还有助于平衡全部P的 Goroutines 数量,这样工做就能更好地分配和更有效地完成。

看下面的一个例子:这是一个多线程的 Go 程序,其中有两个P,每一个P都服务着四个 Goroutine,另在 GRQ 中还有一个单独的 Goroutine。若是其中一个P的全部 Goroutines 很快就执行完了会发生什么?

图片描述

如你所见:P1的 Goroutines 都执行完了。可是还有 Goroutines 处于可运行状态,在 GRQ 中有,在P2的 LRQ 中也有。
这时P1就须要窃取任务。

图片描述

窃取的规则在这里定义了:https://golang.org/src/runtim...

if gp == nil {
        // 1/61的几率检查一下全局可运行队列,以确保公平。不然,两个 goroutine 就能够经过不断地相互替换来彻底占据本地运行队列。
        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 && _g_.m.spinning {
            throw("schedule: spinning with local work")
        }
    }
    if gp == nil {
        gp, inheritTime = findrunnable()
    }

根据规则,P1将窃取P2中一半的 Goroutines,窃取完成后的样子以下:

图片描述

咱们再来看一种状况,若是P2完成了对全部 Goroutine 的服务,而P1的 LRQ 也什么都没有,会发生什么?

图片描述

P2完成了全部任务,如今须要窃取一些。首先,它将查看P1的 LRQ,但找不到任何 Goroutines。接下来,它将查看 GRQ。
在那里它会找到G9P2从 GRQ 手中抢走了G9并开始执行。以上任务窃取的好处在于它使M不会闲着。在窃取任务时,M是自旋的。这种自旋还有其余的好处,能够参考 work-stealing

图片描述

实例

有了相应的机制和语义,我将向你展现如何将全部这些结合在一块儿,以便 Go 调度程序可以执行更多的工做。设想一个用 C 编写的多线程应用程序,其中程序管理两个操做系统线程,这两个线程相互传递消息。

下面有两个线程,线程 T1 在内核 C1 上进行上下文切换,而且正在运行中,这容许 T1 将其消息发送到 T2

图片描述

T1 发送完消息,它须要等待响应。这将致使 T1C1 上下文换出并进入等待状态。
T2 收到有关该消息的通知,它就会进入可运行状态。
如今操做系统能够执行上下文切换并让 T2 在一个核心上执行,而这个核心刚好是 C2。接下来,T2 处理消息并将新消息发送回 T1

图片描述

而后,T2 的消息被 T1 接收,线程上下文切换再次发生。如今,T2 从运行中状态切换到等待状态,T1 从等待状态切换到可运行状态,再被执行变为运行中状态,这容许它处理并发回新消息。

全部这些上下文切换和状态更改都须要时间来执行,这限制了工做的完成速度。
因为每一个上下文切换可能会产生 50 纳秒的延迟,而且理想状况下硬件每纳秒执行 12 条指令,所以你会看到有差很少 600 条指令,在上下文切换期间被停滞掉了。而且因为这些线程也在不一样的内核之间跳跃,因 cache-line 未命中引发额外延迟的可能性也很高。

图片描述

下面咱们还用这个例子,来看看 Goroutine 和 Go 调度器是怎么工做的:
有两个goroutine,它们彼此协调,来回传递消息。G1M1上进行上下文切换,而M1刚好运行在C1上,这容许G1执行它的工做。即向G2发送消息。

图片描述

G1发送完消息后,须要等待响应。M1就会把G1换出并使之进入等待状态。一旦G2获得消息,它就进入可运行状态。如今 Go 调度器能够执行上下文切换,让G2M1上执行,M1仍然在C1上运行。接下来,G2处理消息并将新消息发送回G1

图片描述

G2发送的消息被G1接收时,上下文切换再次发生。如今G2从运行中状态切换到等待状态,G1从等待状态切换到可运行状态,最后返回到执行状态,这容许它处理和发送一个新的消息。

图片描述

表面上看起来没有什么不一样。不管使用线程仍是 Goroutine,都会发生相同的上下文切换和状态变动。然而,使用线程和 Goroutine 之间有一个主要区别:
在使用 Goroutine 的状况下,会复用同一个系统线程和核心。这意味着,从操做系统的角度来看,操做系统线程永远不会进入等待状态。所以,在使用系统线程时的开销在使用 Goroutine 时就不存在了。

基本上,Go 已经在操做系统级别将 IO-Bound 类型的工做转换为 CPU-Bound 类型。因为全部的上下文切换都是在应用程序级别进行的,因此在使用线程时,每一个上下文切换(平均)不至于迟滞 600 条指令。该调度程序还有助于提升 cache-line 效率和 NUMA。在 Go 中,随着时间的推移,能够完成更多的工做,由于 Go 调度器尝试使用更少的线程,在每一个线程上作更多的工做,这有助于减小操做系统和硬件的负载。

结论

Go 调度器在设计中考虑到复杂的操做系统和硬件的工做方式,真是使人惊叹。在操做系统级别将 IO-Bound 类型的工做转换为 CPU-Bound 类型的能力是咱们在利用更多 CPU 的过程当中得到巨大成功的地方。这就是为何不须要比虚拟核心更多的操做系统线程的缘由。你能够合理地指望每一个虚拟内核只有一个系统线程来完成全部工做(CPU和IO)。对于网络应用程序和其余不会阻塞操做系统线程的系统调用的应用程序来讲,这样作是可能的。

做为一个开发人员,你固然须要知道程序在运行中作了什么。你不可能建立无限数量的 Goroutine ,并期待惊人的性能。越少越好,可是经过了解这些 Go 调度器的语义,您能够作出更好的工程决策。

在下一篇文章中,我将探讨以保守的方式利用并发性以得到更好的性能,同时平衡可能须要增长到代码中的复杂性。

相关文章
相关标签/搜索