Go语言进阶(九)Goroutine原理

Goroutine

goroutine 定义

“Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或方法。一个运行的程序由一个或更多个 goroutine 组成。它与线程、协程、进程等不同。它是一个 goroutine” —— Rob Pike

Goroutines 在同一个用户地址空间里并行独立执行 functionschannels 则用于 goroutines 间的通信和同步访问控制。

image-20211214141632279

goroutine 和 thread 的区别?

  • 内存占用,创建一个 goroutine 的栈内存消耗为 2 KB(Linux AMD64 Go v1.4后),运行过程中,如果栈空间不够用,会自动进行扩容。

创建一个 thread 为了尽量避免极端情况下操作系统线程栈的溢出,默认会为其分配一个较大的栈内存( 1 - 8 MB 栈内存, 线程标准 POSIX Thread),而且还需要一个被称为 “guard page” 的区域用于和其他 thread 的栈空间进行隔离。而栈内存空间一旦创建和初始化完成之后其大小就不能再有变化,这决定了在某些特殊场景下系统线程栈还是有溢出的风险。

image-20211214141951499

  • 创建/销毁,线程创建和销毀都会有巨大的消耗,是内核级的交互(trap)。

POSIX 线程(定义了创建和操纵线程的一套 API)通常是在已有的进程模型中增加的逻辑扩展,所以线程控制和进程控制很相似。而进入内核调度所消耗的性能代价比较高,开销较大。

goroutine 是用户态线程,是由 go runtime 管理,创建和销毁的消耗非常小。

  • 调度切换

抛开陷入内核,线程切换会消耗 1000-1500 纳秒(上下文保存成本高,较多寄存器,公平性,复杂时间计算统计),一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。

goroutine 的切换约为 200 ns(用户态、3个寄存器),相当于 2400-3600 条指令。因此,goroutines 切换成本比 threads 要小得多。

  • 复杂性

线程的创建和退出复杂,多个 thread 间通讯复杂(share memory)。不能大量创建线程(参考早期的 httpd),成本高,使用网络多路复用,存在大量callback(参考twemproxy、nginx 的代码)。对于应用服务线程门槛高,例如需要做第三方库隔离,需要考虑引入线程池等。

M:N模型

image-20211214142456047

Go 创建 M 个线程(CPU 执行调度的单元,内核的 task_struct),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行,即 M:N 模型。它们能够同时运行,与线程类似,但相比之下非常轻量。因此,程序运行时,Goroutines 的个数应该是远大于线程的个数的

同一个时刻,一个线程只能跑一个 goroutine。当 goroutine 发生阻塞 (chan 阻塞、mutex、syscall 等等) 时,Go 会把当前的 goroutine 调度走,让其他 goroutine 来继续执行,而不是让线程阻塞休眠,尽可能多的分发任务出去,让 CPU 忙。

GMP 调度模型

  • G,goroutine 的缩写,每次 go func() 都代表一个 G,无限制。

使用 struct runtime.g,包含了当前 goroutine 的状态、堆栈、上下文。

源码可以查看src\runtime\runtime2.go下的type g struct

  • M,工作线程(OS thread)也被称为 Machine,使用 struct runtime.m,所有 M 是有线程栈的。

如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则 M.stackG.stack,M 的 PC 寄存器指向 G 提供的函数,然后去执行。

源码可以查看src\runtime\runtime2.go下的type m struct

早期的调度模型

Go 1.2前的调度器实现,限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。

image-20211214143632139

每个 goroutine 对应于 runtime 中的一个抽象结构:G,而 thread 作为“物理 CPU”的存在而被抽象为一个结构:M(machine)。

当 goroutine 调用了一个阻塞的系统调用,运行这个 goroutine 的线程就会被阻塞,这时至少应该再创建/唤醒一个线程来运行别的没有阻塞的 goroutine。

线程这里可以创建不止一个,可以按需不断地创建,而活跃的线程(处于非阻塞状态的线程)的最大个数存储在变量 GOMAXPROCS中。

问题

  • 单一全局互斥锁(Sched.Lock)和集中状态存储

导致所有 goroutine 相关操作,比如:创建、结束、重新调度等都要上锁。

  • Goroutine 传递问题

M 经常在 M 之间传递”可运行”的 goroutine,这导致调度延迟增大以及额外的性能损耗(刚创建的 G 放到了全局队列,而不是本地 M 执行,不必要的开销和延迟)。

  • Per-M 持有内存缓存 (M.mcache)

每个 M 持有 mcache 和 stackalloc,然而只有在 M 运行 Go 代码时才需要使用的内存(每个 mcache 可以高达2mb),当 M 在处于 syscall 时并不需要。

运行 Go 代码和阻塞在 syscall 的 M 的比例高达1:100,造成了很大的浪费。同时内存亲缘性也较差,G 当前在 M 运行后对 M 的内存进行了预热,因为现在 G 调度到同一个 M 的概率不高,数据局部性不好。

  • 严重的线程阻塞/解锁

在系统调用的情况下,工作线程经常被阻塞和取消阻塞,这增加了很多开销。比如 M 找不到G,此时 M 就会进入频繁阻塞/唤醒来进行检查的逻辑,以便及时发现新的 G 来执行。

新的调度模型

引入了P的概念。

  • P,“Processor”是一个抽象的概念,并不是真正的物理 CPU。

Dmitry Vyukov 的方案是引入一个结构 P,它代表了 M 所需的上下文环境,也是处理用户级代码逻辑的处理器。它负责衔接 M 和 G 的调度上下文,将等待执行的 G 与 M 对接。当 P 有任务时需要创建或者唤醒一个 M 来执行它队列里的任务。所以 P/M 需要进行绑定,构成一个执行单元。P 决定了并行任务的数量,可通过 runtime.GOMAXPROCS 来设定。在 Go1.5 之后GOMAXPROCS 被默认设置可用的核数,而之前则默认为1。

Tips: https://github.com/uber-go/automaxprocs

mcache/stackalloc 从 M 移到了 P,而 G 队列也被分成两类,保留全局 G 队列,同时每个 P 中都会有一个本地的 G 队列。

image-20211214145714623

引入了 local queue,因为 P 的存在,runtime 并不需要做一个集中式的 goroutine 调度,每一个 M 都会在 P’s local queue、global queue 或者其他 P 队列中找 G 执行,减少全局锁对性能的影响。

也是 GMP Work-stealing 调度算法的核心。注意 P 的本地 G 队列还是可能面临一个并发访问的场景,为了避免加锁,这里 P 的本地队列是一个 LockFree的队列,窃取 G 时使用 CAS 原子操作来完成。

关于LockFree 和 CAS 的知识参见 https://yizhi.ren/2017/09/19/reorder/。

Work-stealing (工作窃取)

image-20211214150605409

当一个 P 执行完本地所有的 G 之后,并且全局队列为空的时候,会尝试挑选一个受害者 P,从它的 G 队列中窃取一半的 G。否则会从全局队列中获取(当前个数/GOMAXPROCS)个 G。

为了保证公平性,从随机位置上的 P 开始,而且遍历的顺序也随机化了(选择一个小于 GOMAXPROCS,且和它互为质数的步长),保证遍历的顺序也随机化了。

光窃取获取是不够的,可能会导致全局队列饥饿。P 的调度算法中还会每个 N 轮调度之后就去全局队列拿一个 G。谁放入的全局队列呢?

image-20211214151850854

新建 G 时 P 的本地 G 队列放不下已满并达到256个的时候会放半数 G 到全局队列去,阻塞的系统调用返回时找不到空闲 P 也会放到全局队列。

image-20211214151810608

Syscall

调用 syscall 后会解绑 P,然后 M 和 G 进入阻塞,而 P 此时的状态就是 syscall,表明这个 P 的 G 正在 syscall 中,这时的 P 是不能被调度给别的 M 的。如果在短时间内阻塞的 M 就唤醒了,那么 M 会优先来重新获取这个 P,能获取到就继续绑回去,这样有利于数据的局部性。

image-20211214152220993

系统监视器 (system monitor),称为 sysmon,会定时扫描。在执行 syscall 时, 如果某个 P 的 G 执行超过一个 sysmon tick(10ms),就会把他设为 idle,重新调度给需要的 M,强制解绑。

image-20211214152645043

P1 和 M 脱离后目前在 idle list 中等待被绑定(处于 syscall 状态)。而 syscall 结束后 M 按照如下规则执行直到满足其中一个条件:

  • 尝试获取同一个 P(P1),恢复执行 G
  • 尝试获取 idle list 中的其他空闲 P,恢复执行 G
  • 找不到空闲 P,把 G 放回 global queue,M 放回到 idle list

当使用了 Syscall,Go 无法限制 Blocked OS threads 的数量:比如读取文件使用 syscall, 写程序要认真考虑 pthread exhaust 问题。

GOMAXPROCS控制的是P的数量,没办法去控制底层线程的数量。

Spining thread(线程自旋)

线程自旋是相对于线程阻塞而言的,表象就是循环执行一个指定逻辑(调度逻辑,目的是不停地寻找 G)。

这样做的问题显而易见,如果 G 迟迟不来,CPU 会白白浪费在这无意义的计算上。

好处也很明显,降低了 M 的上下文切换成本,提高了性能。在两个地方引入自旋:

  • 类型1:M 不带 P 的找 P 挂载(一有 P 释放就结合)
  • 类型2:M 带 P 的找 G 运行(一有 runable 的 G 就执行)

为了避免过多浪费 CPU 资源,自旋的 M 最多只允许 GOMAXPROCS (Busy P)。同时当有类型1的自旋 M 存在时,类型2的自旋 M 就不阻塞,阻塞会释放 P,一释放 P 就马上被类型1的自旋 M 抢走了,没必要。

在新 G 被创建、M 进入系统调用、M 从空闲被激活这三种状态变化前,调度器会确保至少有一个自旋 M 存在(唤醒或者创建一个 M),除非没有空闲的 P。

  • 当新 G 创建,如果有可用 P,就意味着新 G 可以被立即执行,即便不在同一个 P 也无妨,所以我们保留一个自旋的 M(这时应该不存在类型1的自旋只有类型2的自旋)就可以保证新 G 很快被运行。
  • 当 M 进入系统调用,意味着 M 不知道何时可以醒来,那么 M 对应的 P 中剩下的 G 就得有新的 M 来执行,所以我们保留一个自旋的 M 来执行剩下的 G(这时应该不存在类型2的自旋只有类型1的自旋)。
  • 如果 M 从空闲变成活跃,意味着可能一个处于自旋状态的 M 进入工作状态了,这时要检查并确保还有一个自旋 M 存在,以防还有 G 或者还有 P 空着的。

GMP 问题总结

  • 单一全局互斥锁(Sched.Lock)和集中状态存储

G 被分成全局队列和 P 的本地队列,全局队列依旧是全局锁,但是使用场景明显很少,P 本地队列使用无锁队列,使用原子操作来面对可能的并发场景。

  • Goroutine 传递问题

G 创建时就在 P 的本地队列,可以避免在 G 之间传递(窃取除外),G 对 P 的数据局部性好; 当 G 开始执行了,系统调用返回后 M 会尝试获取可用 P,获取到了的话可以避免在 M 之间传递。而且优先获取调用阻塞前的 P,所以 G 对 M 数据局部性好,G 对 P 的数据局部性也好。

  • Per-M 持有内存缓存 (M.mcache)

内存 mcache 只存在 P 结构中,P 最多只有 GOMAXPROCS 个,远小于 M 的个数,所以内存没有过多的消耗。

  • 严重的线程阻塞/解锁

通过引入自旋,保证任何时候都有处于等待状态的自旋 M,避免在等待可用的 P 和 G 时频繁的阻塞和唤醒。

sysmon

image-20211214154223055

sysmon 也叫监控线程,它无需 P 也可以运行,他是一个死循环,每20us~10ms循环一次,循环完一次就 sleep 一会,为什么会是一个变动的周期呢,主要是避免空转,如果每次循环都没什么需要做的事,那么 sleep 的时间就会加大。

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

当 P 在 M 上执行时间超过10ms,sysmon 调用 preemptone 将 G 标记为 stackPreempt 。因此需要在某个地方触发检测逻辑,Go 当前是在检查栈是否溢出的地方判定(morestack()),M 会保存当前 G 的上下文,重新进入调度逻辑。

image-20211214154546275

异步抢占,注册 sigurg 信号,通过 sysmon 检测,对 M 对应的线程发送信号,触发注册的 handler,它往当前 G 的 PC 中插入一条指令(调用某个方法),在处理完 handler,G 恢复后,自己把自己推到了 global queue 中。

参考:

信号抢占:go1.14基于信号的抢占式调度实现原理

死循环:issues/11462

Network poller

Go 所有的 I/O 都是阻塞的。然后通过 goroutine + channel 来处理并发。因此所有的 IO 逻辑都是直来直去的,你不再需要回调,不再需要 future,要的仅仅是 step by step。这对于代码的可读性是很有帮助的。

image-20211214154738438

G 发起网络 I/O 操作也不会导致 M 被阻塞(仅阻塞G),从而不会导致大量 M 被创建出来。将异步 I/O 转换为阻塞 I/O 的部分称为 netpoller。打开或接受连接都被设置为非阻塞模式。如果你试图对其进行 I/O 操作,并且文件描述符数据还没有准备好,G 会进入 gopark 函数,将当前正在执行的 G 状态保存起来,然后切换到新的堆栈上执行新的 G。

那什么时候 G 被调度回来呢?

  • sysmon
  • schedule():M 找 G 的调度函数
  • GC:start the world

image-20211214155026535

调用 netpoll() 在某一次调度 G 的过程中,处于就绪状态的 fd 对应的 G 就会被调度回来。

G 的 gopark 状态:G 置为 waiting 状态,等待显示 goready 唤醒,在 poller 中用得较多,还有锁、chan 等。

Scheduler Affinity (调度器亲和力)

image-20211214155124918image-20211214155134713image-20211214155152530

GM 调度器时代的,chan 操作导致的切换代价。

  • Goroutine#7 正在等待消息,阻塞在 chan。一旦收到消息,这个 goroutine 就被推到全局队列。
  • 然后,chan 推送消息,goroutine#X 将在可用线程上运行,而 goroutine#8 将阻塞在 chan。
  • goroutine#7 现在在可用线程上运行。

新版调度模型下,在 chan 来回通信的 goroutine 会导致频繁的 blocks,即频繁地在本地队列中重新排队。然而,由于本地队列是 FIFO 实现,如果另一个 goroutine 占用线程,unblock goroutine 不能保证尽快运行。

image-20211214155452189

同时 Go 亲缘性调度的一些限制:Work-stealing、系统调用。
goroutine #9 在 chan 被阻塞后恢复。但是,它必须等待#2、#5和#4之后才能运行。goroutine #5将阻塞其线程,从而延迟goroutine #9,并使其面临被另一个 P 窃取的风险。

针对 communicate-and-wait 模式,进行了亲缘性调度的优化。Go 1.5 在 P 中引入了 runnext 特殊的一个字段,可以高优先级执行 unblock G。

image-20211214155600248

goroutine #9现在被标记为下一个可运行的。这种新的优先级排序允许 goroutine 在再次被阻塞之前快速运行。这一变化对运行中的标准库产生了总体上的积极影响,提高了一些包的性能。

Goroutine Lifecycle

整个程序始于一段汇编,而在随后的 runtime·rt0_go(也是汇编程序)中,会执行很多初始化工作。

image-20211214155845144

Go 程序启动

image-20211214155809034

  • 绑定 m0 和 g0,m0就是程序的主线程,程序启动必然会拥有一个主线程,这个就是 m0。g0 负责调度,即 shedule() 函数。
  • 创建 P,绑定 m0 和 p0,首先会创建 GOMAXPROCS 个 P ,存储在 sched 的 空闲链表(pidle)。
  • 新建任务 g 到 p0 本地队列,m0 的 g0 会创建一个 指向 runtime.main() 的 g ,并放到 p0 的本地队列。

runtime.main(): 启动 sysmon 线程;启动 GC 协程;执行 init,即代码中的各种 init 函数;执行 main.main 函数。

OS thread 创建

准备运行的新 goroutine 将唤醒 P 以更好地分发工作。这个 P 将创建一个与之关联的 M 绑定到一个 OS thread。

image-20211214160401869image-20211214160410559

go func() 中 触发 Wakeup 唤醒机制:

有空闲的 P 而没有在 spinning 状态的 M 时候, 需要去唤醒一个空闲(睡眠)的 M 或者新建一个。当线程首次创建时,会执行一个特殊的 G,即 g0,它负责管理和调度 G。

image-20211214160216291

特殊的 g0

Go 基于两种断点将 G 调度到线程上:

  • 当 G 阻塞时:系统调用、互斥锁或 chan。阻塞的 G 进入睡眠模式/进入队列,并允许 Go 安排和运行等待其他的 G。
  • 在函数调用期间,如果 G 必须扩展其堆栈。这个断点允许 Go 调度另一个 G 并避免运行 G 占用 CPU。

在这两种情况下,运行调度程序的 g0 将当前 G 替换为另一个 G,即 ready to run。然后,选择的 G 替换 g0 并在线程上运行。与常规 G 相反,g0 有一个固定和更大的栈。

  • Defer 函数的分配
  • GC 收集,比如 STW、扫描 G 的堆栈和标记、清除操作
  • 栈扩容,当需要的时候,由 g0 进行扩栈操作

image-20211214160600189image-20211214160609670image-20211214160617912image-20211214160621352

Schedule

在 Go 中,G 的切换相当轻便,其中需要保存的状态仅仅涉及以下两个:

  • Goroutine 在停止运行前执行的指令,程序当前要运行的指令是记录在程序计数器(PC)中的, G 稍后将在同一指令处恢复运行;
  • G 的堆栈,以便在再次运行时还原局部变量;

image-20211214160957926image-20211214161007027image-20211214161019841image-20211214160838072

在切换之前,堆栈将被保存,以便在 G 再次运行时进行恢复.

image-20211214161128899

从 g 到 g0 或从 g0 到 g 的切换是相当迅速的,它们只包含少量固定的指令。相反,对于调度阶段,调度程序需要检查许多资源以便确定下一个要运行的 G。

  • 当前 g 阻塞在 chan 上并切换到 g0:1、PC 和堆栈指针一起保存在内部结构中;2、将 g0 设置为正在运行的 goroutine;3、g0 的堆栈替换当前堆栈;

  • g0 寻找新的 Goroutine 来运行

  • g0 使用所选的 Goroutine 进行切换: 1、PC 和堆栈指针是从其内部结构中获取的;2、程序跳转到对应的 PC 地址;

Goroutine Recycle

G 很容易创建,栈很小以及快速的上下文切换。基于这些原因,开发人员非常喜欢并使用它们。然而,一个产生许多 shortlive 的 G 的程序将花费相当长的时间来创建和销毁它们。

每个 P 维护一个 freelist G,保持这个列表是本地的,这样做的好处是不使用任何锁来 push/get 一个空闲的 G。当 G 退出当前工作时,它将被 push 到这个空闲列表中。

image-20211214161237943

为了更好地分发空闲的 G ,调度器也有自己的列表。它实际上有两个列表:一个包含已分配栈的 G,另一个包含释放过堆栈的 G(无栈)。

锁保护 central list,因为任何 M 都可以访问它。当本地列表长度超过64时,调度程序持有的列表从 P 获取 G。然后一半的 G 将移动到中心列表。需求回收 G 是一种节省分配成本的好方法。但是,由于堆栈是动态增长的,现有的G 最终可能会有一个大栈。因此,当堆栈增长(即超过2K)时,Go 不会保留这些栈。

image-20211214161356532