查看原文
其他

goroutine 调度器原理

Linux爱好者 2021-09-08

推荐关注↓

【导读】goroutine调度是怎么实现的?本文细致明白地解释了goroutine的迭代和设计,一起来学习吧!

goroutine 调度器的概念

说到“调度”,首先会想到操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理 CPU 上去运行。传统的编程语言比如 C、C++ 等的并发实现实际上就是基于操作系统调度的,即程序负责创建线程,操作系统负责调度。

尽管线程的调度方式相对于进程来说,线程运行所需要资源比较少,在同一进程中进行线程切换效率会高很多,但实际上多线程开发设计会变得更加复杂,要考虑很多同步竞争等问题,如锁、竞争冲突等。

线程是操作系统调度时的最基本单元,而 Linux 在调度器并不区分进程和线程的调度,只是说线程调度因为资源少,所以切换的效率比较高。

使用多线程编程会遇到以下问题:

  • 并发单元间通信困难,易错:多个 thread 之间的通信虽然有多种机制可选,但用起来是相当复杂;并且一旦涉及到共享内存,就会用到各种 lock,一不小心就会出现死锁的情况。
  • 对于线程池的大小不好确认,在请求量大的时候容易导致 OOM 的情况
  • 虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1 兆以上的内存空间,在对线程进行切换时不仅会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁对应的资源,每一次线程上下文的切换仍然需要一定的时间(us 级别)
  • 对于很多网络服务程序,由于不能大量创建 thread,就要在少量 thread 里做网络多路复用,例如 JAVA 的Netty 框架,写起这样的程序也不容易。

这便有了“协程”,线程分为内核态线程和用户态线程,用户态线程需要绑定内核态线程,CPU 并不能感知用户态线程的存在,它只知道它在运行1个线程,这个线程实际是内核态线程。

用户态线程实际有个名字叫协程(co-routine),为了容易区分,使用协程指用户态线程,使用线程指内核态线程。

协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。

Go中,协程被称为 goroutine(但其实并不完全是协程,还做了其他方面的优化),它非常轻量,一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine,支持了更多的并发。虽然一个 goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime会自动为 goroutine 分配。

而将所有的 goroutines 按照一定算法放到 CPU 上执行的程序就称为 goroutine 调度器或 goroutine scheduler。

不过,一个 Go 程序对于操作系统来说只是一个用户层程序,对于操作系统而言,它的眼中只有 thread,它并不知道有什么叫 Goroutine 的东西的存在。goroutine 的调度全要靠 Go 自己完成,所以就需要 goroutine 调度器来实现 Go 程序内 goroutine 之间的 CPU 资源调度。

在操作系统层面,Thread 竞争的 CPU 资源是真实的物理 CPU,但对于 Go 程序来说,它是一个用户层程序,它本身整体是运行在一个或多个操作系统线程上的,因此 goroutine 们要竞争的所谓 “CPU” 资源就是操作系统线程。这样 Go scheduler 的要做的就是:将 goroutines 按照一定算法放到不同的操作系统线程中去执行。这种在语言层面自带调度器的,称之为原生支持并发。

goroutine 调度器的演进

调度器的任务是在用户态完成 goroutine 的调度,而调度器的实现好坏,对并发实际有很大的影响。

G-M模型

现在的 Go语言调度器是 2012 年重新设计的,在这之前的调度器称为老调度器,老调度器采用的是 G-M 模型,在这个调度器中,每个 goroutine 对应于 runtime 中的一个抽象结构:G,而 os thread 作为物理 CPU 的存在而被抽象为一个结构:M(machine)。M 想要执行 G、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局 G 队列是有互斥锁进行保护的。

这个结构虽然简单,但是却存在着许多问题。它限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。主要体现在如下几个方面:

  • 单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有 goroutine 相关操作,比如:创建、重新调度等都要上锁,这会造成激烈的锁竞争
  • goroutine 传递问题:M 经常在 M 之间传递可运行的 goroutine,这导致调度延迟增大以及额外的性能损耗
  • 每个 M 做内存缓存,导致内存占用过高,数据局部性较差
  • 系统调用导致频繁的线程阻塞和取消阻塞操作增加了系统开销

所以用了 4 年左右就被替换掉了。

G-P-M 模型

面对之前调度器的问题,Go 设计了新的调度器,并在其中引入了 P(Processor),另外还引入了任务窃取调度的方式(work stealing)

P:Processor,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。work stealing:当 M 绑定的 P 没有可运行的 G 时,它可以从其他运行的 M 那里偷取G。G-P-M 模型的结构如下图:

从上往下是调度器的4个部分:

全局队列(Global Queue):存放等待运行的 G。P 的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建 G 时,G 优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。P列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS 个。M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了1个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。

有关 P 和 M 的个数问题

P 的数量

由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。

M 的数量

  • go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000。但是内核很难支持这么多的线程数,所以这个限制可以忽略。
  • runtime/debug 中的 SetMaxThreads 函数,可以设置 M 的最大数量
  • 一个 M 阻塞了,会创建新的 M。

M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

抢占式调度

G-P-M 模型中还实现了抢占式调度,所谓抢占式调度指的是在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用CPU 10ms,防止其他 goroutine 被饿死,这也是 goroutine 不同于 coroutine 的一个地方。在 goroutine 中先后实现了两种抢占式调度算法,分别是基于协作的方式和基于信号的方式。

基于协作的抢占式调度

G-P-M 模型的实现是 Go scheduler 的一大进步,但此时的调度器仍然有一个问题,那就是不支持抢占式调度,导致一旦某个 G 中出现死循环或永久循环的代码逻辑,那么 G 将永久占用分配给它的 P 和 M,位于同一个 P 中的其他 G 将得不到调度,出现“饿死”的情况。当只有一个 P 时(GOMAXPROCS=1)时,整个 Go 程序中的其他 G 都会被饿死。所以后面 Go 设计团队在 Go 1.2 中实现了基于协作的抢占式调度。

这种抢占式调度的原理则是在每个函数或方法的入口,加上一段额外的代码,让 runtime 有机会检查是否需要执行抢占调度。

基于协作的抢占式调度的工作原理大致如下:

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

这种实现方式虽然增加了运行时的复杂度,但是实现相对简单,也没有带来过多的额外开销,所以在 Go 语言中使用了 10 几个版本。因为这里的抢占是通过编译器插入函数实现的,还是需要函数调用作为入口才能触发抢占,所以这是一种协作式的抢占式调度。这种解决方案只能说局部解决了“饿死”问题,对于没有函数调用,纯算法循环计算的 G,scheduler 依然无法抢占。

基于信号的抢占式调度

Go 语言在 1.14 版本中实现了非协作的抢占式调度,在实现的过程中重构已有的逻辑并为 Goroutine 增加新的状态和字段来支持抢占。

基于信号的抢占式调度的工作原理大致如下:

  1. 程序启动时,在 runtime.sighandler 函数中注册一个 SIGURG 信号的处理函数 runtime.doSigPreempt

  2. 在触发垃圾回收的栈扫描时会调用函数 runtime.suspendG 挂起 Goroutine,此时会执行下面的逻辑:

  • 将处于运行状态(_Grunning)的 Goroutine 标记成可以被抢占,即将 Goroutine 的字段 preemptStop 设置成 true;

  • 调用 runtime.preemptM函数, 它可以通过 SIGURG 信号向线程发送抢占请求触发抢占;

  1. runtime.preemptM 会调用 runtime.signalM 向线程发送信号 SIGURG

  2. 操作系统收到信号后会中断正在运行的线程并执行预先在第 1 步注册的信号处理函数 runtime.doSigPreempt

  3. runtime.doSigPreempt 函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用 runtime.sigctxt.pushCall

  4. runtime.sigctxt.pushCall 会修改寄存器并在程序回到用户态时执行 runtime.asyncPreempt;

  5. 汇编指令 runtime.asyncPreempt 会调用运行时函数 runtime.asyncPreempt2

  6. runtime.asyncPreempt2 会调用 runtime.preemptPark

  7. runtime.preemptPark 会修改当前 Goroutine 的状态到 _Gpreempted 并调用 runtime.schedule 让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行

_Gpreempted 状态表示当前 groutine 由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒

在上面的选择 SIGURG 作为触发异步抢占的信号:

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

垃圾回收过程中需要暂停整个程序(Stop the world,STW),有时候可能需要几分钟的时间,这会导致整个程序无法工作。所以 STW 和栈扫描是一个可以抢占的安全点(Safe-points), Go 语言在这里先加入抢占功能。基于信号的抢占式调度只解决了垃圾回收和栈扫描时存在的问题,它到目前为止没有解决全部问题。

go func() 调度流程

基于上面的模型,当我们使用 go func() 创建一个新的 goroutine 的时候,其调度流程如下:

  1. 通过 go func () 来创建一个 goroutine;

  2. 有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;

  3. G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G 来执行;

  4. 一个 M 调度 G 执行的过程是一个循环机制;

  5. 当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;

  6. 当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。

Goroutine 生命周期

在这里有一个线程和一个 groutine 比较特殊,那就是 M0 和 G0:

  • M0:M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。

  • G0 :G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。

对于下面的简单代码:

package main

import "fmt"

// main.main
func main() {
   fmt.Println("Hello scheduler")
}

其运行时所经历的过程跟上面的生命周期相对应:

  1. runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。

  2. 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。

  3. 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数——runtime.main,代码经过编译后,runtime.main会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为main goroutine,然后把 main goroutine 加入到P的本地队列。

  • 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
  • G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境

  • M 运行 G

  • G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。

  • 调度器的生命周期几乎占满了一个Go程序的一生,runtime.main 的 goroutine 执行之前都是为调度器做准备工作,runtime.main 的 goroutine 运行,才是调度器的真正开始,直到 runtime.main 结束而结束。

    Goroutine 调度器场景分析

    场景一

    p1 拥有 g1,m1 获取 p1 后开始运行g1,g1 使用 go func() 创建了 g2,为了局部性 g2 优先加入到 p1 的本地队列:

    场景二

    g1运行完成后(函数:goexit),m 上运行的 goroutine 切换为 g0,g0 负责调度时协程的切换(函数:schedule)。

    从 p1 的本地队列取 g2,从 g0 切换到 g2,并开始运行 g2 (函数:execute)。实现了线程 m1 的复用。

    场景三

    假设每个 p 的本地队列只能存 4 个 g。g2 要创建 6 个 g,前 4 个g(g3, g4, g5, g6)已经加入 p1 的本地队列,p1 本地队列满了。

    g2 在创建 g7 的时候,发现 p1 的本地队列已满,需要执行负载均衡,把 p1 中本地队列中前一半的 g,还有新创建的 g 转移到全局队列

    实现中并不一定是新的 g,如果 g 是 g2 之后就执行的,会被保存在本地队列,利用某个老的 g 替换新 g 加入全局队列),这些 g 被转移到全局队列时,会被打乱顺序。

    所以 g3,g4,g7 被转移到全局队列。

    蓝色长方形代表全局队列。

    如果此时 G2 创建 G8 时,P1 的本地队列未满,所以 G8 会被加入到 P1 的本地队列:

    场景四

    在创建 g 时,运行的 g 会尝试唤醒其他空闲的 p 和 m 组合去执行。假定 g2 唤醒了 m2,m2 绑定了 p2,并运行 g0,但 p2 本地队列没有 g,m2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 g)。

    m2 接下来会尝试从全局队列 (GQ) 取一批 g 放到 p2 的本地队列(函数:findrunnable)。m2 从全局队列取的 g 数量符合下面的公式:

    n = min(len(GQ)/GOMAXPROCS + 1len(GQ/2))

    公式的含义是,至少从全局队列取 1 个 g,但每次不要从全局队列移动太多的 g 到 p 本地队列,给其他 p 留点。这是从全局队列到 P 本地队列的负载均衡。

    假定场景中一共有 4 个 P(GOMAXPROCS=4),所以 m2 只从能从全局队列取 1 个 g(即 g3)移动 p2 本地队列,然后完成从 g0 到 g3 的切换,运行 g3:

    场景五

    假设 g2 一直在 m1上运行,经过 2 轮后,m2 已经把 g7、g4 也挪到了p2的本地队列并完成运行,全局队列和 p2 的本地队列都空了,如下图左边所示。

    全局队列已经没有 g,那 m 就要执行 work stealing:从其他有 g 的 p 哪里偷取一半 g 过来,放到自己的 P 本地队列。p2 从 p1 的本地队列尾部取一半的 g,本例中一半则只有 1 个 g8,放到 p2 的本地队列,情况如下图右边:

    场景六

    p1 本地队列 g5、g6 已经被其他 m 偷走并运行完成,当前 m1 和 m2 分别在运行 g2 和 g8,m3 和 m4 没有goroutine 可以运行,m3 和 m4 处于自旋状态,它们不断寻找 goroutine。

    这里有一个问题,为什么要让 m3 和 m4 自旋?自旋本质是在运行,线程在运行却没有执行 g,就变成了浪费CPU,销毁线程可以节约CPU资源不是更好吗?实际上,创建和销毁CPU都是浪费时间的,我们希望当有新 goroutine 创建时,立刻能有 m 运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费 CPU,所以系统中最多有 GOMAXPROCS 个自旋的线程,多余的没事做的线程会让他们休眠(函数:notesleep() 实现了这个思路)。

    场景七

    假定当前除了 m3 和 m4 为自旋线程,还有 m5 和 m6 为自旋线程,g8 创建了 g9,g9 会放入本地队列。加入此时g8 进行了阻塞的系统调用,m2 和 p2 立即解绑,p2 会执行以下判断:如果 p2 本地队列有 g、全局队列有 g 或有空闲的 m,p2 都会立马唤醒1个 m 和它绑定,否则 p2 则会加入到空闲 P 列表,等待 m 来获取可用的 p。本场景中,p2 本地队列有 g,可以和其他自旋线程 m5 绑定。

    场景八

    假设 g8 创建了 g9,假如 g8 进行了非阻塞系统调用(CGO会是这种方式,见cgocall()),m2 和 p2 会解绑,但 m2 会记住 p,然后 g8 和 m2 进入系统调用状态。当 g8 和 m2 退出系统调用时,会尝试获取 p2,如果无法获取,则获取空闲的 p,如果依然没有,g8 会被记为可运行状态,并加入到全局队列。

    场景九

    前面说过,Go 调度在 go1.12 实现了抢占,应该更精确的称为基于协作的请求式抢占,那是因为 go 调度器的抢占和 OS 的线程抢占比起来很柔和,不暴力,不会说线程时间片到了,或者更高优先级的任务到了,执行抢占调度。go 的抢占调度柔和到只给 goroutine 发送 1 个抢占请求,至于 goroutine 何时停下来,那就管不到了。抢占请求需要满足2个条件中的1个:

    • G 进行系统调用超过 20us
    • G 运行超过 10ms。调度器在启动的时候会启动一个单独的线程 sysmon,它负责所有的监控工作,其中 1 项就是抢占,发现满足抢占条件的 G 时,就发出抢占请求。

    状态汇总

    从上面的场景中可以总结各个模型的状态:

    G状态

    G的主要几种状态:

    状态描述
    _Gidle刚刚被分配并且还没有被初始化,值为0,为创建goroutine后的默认值
    _Grunnable没有执行代码,没有栈的所有权,存储在运行队列中,可能在某个P的本地队列或全局队列中
    _Grunning正在执行代码的goroutine,拥有栈的所有权
    _Gsyscall正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
    _Gwaiting由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
    _Gdead当前goroutine未被使用,没有执行代码,可能有分配的栈,分布在空闲列表 gFree,可能是一个刚刚初始化的 goroutine,也可能是执行了 goexit 退出的 goroutine
    _Gcopystack栈正在被拷贝,没有执行代码,不在运行队列上
    _Gpreempted由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
    _GscanGC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在

    P 状态

    状态描述
    _Pidle处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
    _Prunning被线程 M 持有,并且正在执行用户代码或者调度器
    _Psyscall没有执行用户代码,当前线程陷入系统调用
    _Pgcstop被线程 M 持有,当前处理器由于垃圾回收被停止
    _Pdead当前处理器已经不被使用

    M 状态

    自旋线程:处于运行状态但是没有可执行 goroutine 的线程,数量最多为 GOMAXPROC,若是数量大于 GOMAXPROC 就会进入休眠。

    非自旋线程:处于运行状态有可执行 goroutine 的线程。

    调度器设计

    从上面的流程可以总结出 goroutine 调度器的一些设计思路:

    调度器设计的两大思想

    复用线程:协程本身就是运行在一组线程之上,所以不需要频繁的创建、销毁线程,而是对线程进行复用。在调度器中复用线程还有2个体现:

    1. work stealing,当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。
    2. hand off,当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。

    利用并行:GOMAXPROCS 设置 P 的数量,当 GOMAXPROCS 大于 1 时,就最多有 GOMAXPROCS 个线程处于运行状态,这些线程可能分布在多个 CPU 核上同时运行,使得并发利用并行。另外,GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。

    调度器设计的两小策略

    抢占:

    在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。

    全局G队列:

    在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。

    GPM 可视化调试

    有 2 种方式可以查看一个程序 GPM 的数据:

    go tool trace

    trace 记录了运行时的信息,能提供可视化的Web页面。

    简单测试代码:main 函数创建 trace,trace 会运行在单独的 goroutine 中,然后 main 打印 “Hello trace” 退出。

    func main() {
        // 创建trace文件
        f, err := os.Create("trace.out")
        if err != nil {
            panic(err)
        }
        defer f.Close()

        // 启动trace goroutine
        err = trace.Start(f)
        if err != nil {
            panic(err)
        }
        defer trace.Stop()

        // main
        fmt.Println("Hello trace")
    }

    运行程序和运行trace:

    go run trace.go 
    Hello World

    会得到一个 trace.out 文件,然后可以用一个工具打开,来分析这个文件:

    go tool trace trace.out 
    2020/12/07 23:09:33 Parsing trace...
    2020/12/07 23:09:33 Splitting trace...
    2020/12/07 23:09:33 Opening browser. Trace viewer is listening on http://127.0.0.1:56469

    接下来通过浏览器打开 http://127.0.0.1:33479 网址,点击 view trace 能够看见可视化的调度流程:

    g 信息

    点击 Goroutines 那一行的数据条,会看到一些详细的信息:

    上面表示一共有两个 G 在程序中,一个是特殊的 G0,是每个 M 必须有的一个初始化的 G。其中 G1 就是 main goroutine (执行 main 函数的协程),在一段时间内处于可运行和运行的状态。

    m 信息

    点击 Threads 那一行可视化的数据条,会看到一些详细的信息:

    这里一共有两个 M 在程序中,一个是特殊的 M0,用于初始化使用。

    p 信息

    G1 中调用了 main.main,创建了 trace goroutine g6。G1 运行在 P0 上,G6运行在 P1 上。

    这里有三个 P。

    在看看上面的 M 信息:

    可以看到确实 G6 在 P1 上被运行之前,确实在 Threads 行多了一个 M 的数据,点击查看如下:

    多了一个 M2 应该就是 P1 为了执行 G6 而动态创建的 M2。

    Debug trace

    示例代码:

    // main.main
    func main() {
        for i := 0; i < 5; i++ {
            time.Sleep(time.Second)
            fmt.Println("Hello scheduler")
        }
    }

    编译后通过 Debug 方式运行,运行过程会打印trace:

    ➜ go build .
    ➜ GODEBUG=schedtrace=1000 ./one_routine2

    结果:

    SCHED 0ms: gomaxprocs=4 idleprocs=2 threads=3 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0]
    Hello scheduler
    SCHED 1003ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
    Hello scheduler
    SCHED 2007ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
    Hello scheduler
    SCHED 3010ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
    Hello scheduler
    SCHED 4013ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
    Hello scheduler

    各个字段的含义如下:

    • SCHED:调试信息输出标志字符串,代表本行是 goroutine 调度器的输出;

    • 0ms:即从程序启动到输出这行日志的时间;

    • gomaxprocs: P的数量,本例有 4 个P;

    • idleprocs: 处于 idle (空闲)状态的 P 的数量;通过 gomaxprocs 和 idleprocs 的差值,就可以知道执行 go 代码的 P 的数量;

    • threads: os threads/M 的数量,包含 scheduler 使用的 m 数量,加上 runtime 自用的类似 sysmon 这样的 thread 的数量;

    • spinningthreads: 处于自旋状态的 os thread 数量;

    • idlethread: 处于 idle 状态的 os thread 的数量;

    • runqueue=0:Scheduler 全局队列中 G 的数量;[0 0 0 0]: 分别为 4 个 P 的 local queue 中的 G 的数量。

    看第一行,含义是:刚启动时创建了 4 个P,其中 2 个空闲的 P,共创建 3 个M,其中 1 个 M 处于自旋,没有 M 处于空闲,第一个 P 的本地队列有一个 G。

    另外,可以加上 scheddetail=1 可以打印更详细的 trace 信息。

    命令:

    ➜ GODEBUG=schedtrace=1000,scheddetail=1 ./one_routine2

    转自:

    xiaoming.net.cn/2020/12/03/goroutine%E8%B0%83%E5%BA%A6%E5%99%A8%E5%8E%9F%E7%90%86/


    - EOF -

    推荐阅读  点击标题可跳转

    1、MyBatis-Plus,看这一篇就够了!

    2、Docker 极简入门指南,10 分钟就能看懂~

    3、C 语言,使用 union 了解内存


    看完本文有收获?请分享给更多人

    推荐关注「Linux 爱好者」,提升Linux技能

    点赞和在看就是最大的支持❤️

    : . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存