【Go并发编程】GPM模型

239 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 7 天,点击查看活动详情

并发

假如一个软件(进程)包含很多程序:

  • 并发:同一时间段有多个程序在执行
  • 并行:同一时刻有多个程序在执行

并发关乎结构,并行关乎执行,我们所谓的并发编程就是编写并发执行的程序,因为不确定程序的执行顺序,引入了并发编排、信息共享等机制。 对于并行执行,首先要有并发代码的软件支撑,其次需要多核多线程CPU的硬件支撑。

对于给定的CPU核数,我们编写对其定制的并发编程代码,才能够最大化地利用好硬件的性能。

  • 线程:传统并发编程控制的最小单元,一个进场可以由多个线程执行,他们共同享有进程的资源。 多线程操作系统上,语言可以创建操作系统线程进而移交给操作系统控制线程的创建与执行,操作系统内核自动编排这些线程,将其放到CPU上执行,因此传统并发编程所能触及到的就是线程。

传统并发编程的缺点:

  • 复杂:线程的状态控制、流程控制、数据共享
  • 开销大:线程调度的时间开销很大,创建的空间开销大,不能无限创建线程

GPM模型

image.png

goroutine

对于线程开销大

  • Goroutine很轻量,栈的大小默认是 2KB(空间)
  • Goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。(时间) 一个 Go 程序中可以创建成千上万个并发的 Goroutine。

micro

用户级线程,对应传统并发编程的线程,其交给操作系统执行。

process

如果只有GM会有什么缺点:

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

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,因此有了process,相当于goroutine对于micro的调度器,G需要进入一个P队列,P再将G交给M运行。

队列调度的问题:

  • 队列是非抢占式的,g阻塞,这个p就卡住了
  • p=1时退化为非并发模型

调度器的进化

“抢占式”调度(Go 1.2)

  • 局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点)
  • 对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。

针对文件 I/O 的 Poller(Go 1.9)

对于文件 I/O 操作来说,一旦阻塞,那么线程(M)将进入挂起状态,等待 I/O 返回后被唤醒。这种情况下 P 将与挂起的 M 分离,再选择一个处于空闲状态(idle)的 M。如果此时没有空闲的 M,就会新创建一个 M。这种情况下,大量 I/O 操作仍然会导致大量线程被创建。

通过文件 I/O poller 减少 M 的阻塞

  • Go 运行时实现 netpoller,G发起网络 I/O 操作,也不会导致 M 被阻塞(仅阻塞 G),也就不会导致大量线程(M)被创建出来。
  • 针对文件 I/O 的 Poller可以像 netpoller 那样,在 G 操作那些支持监听(pollable)的文件描述符时,仅会阻塞 G,而不会阻塞 M。不过这个功能依然不能对常规文件有效,常规文件是不支持监听的(pollable)。

基于协作的抢占式调度(Go 1.12)

  • G: 代表 Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等,而且 G 对象是可以重用的;
  • P: 代表逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量,P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态;
  • M: M 代表着真正的执行计算资源。

G在绑定有效的 P 后,进入一个调度循环,从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。

G 被抢占调度 某个 G 没有进行系统调用(syscall)、没有进行 I/O 操作、没有阻塞在一个 channel 操作上时,G 是被抢占调度的。

Go 程序启动时,运行时会去启动一个名为 sysmon 的 M(监控线程),g0直接单独在监控线程上独占执行,无需P的调度。

sysmon 每 20us~10ms 启动一次:

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

channel 阻塞或网络 I/O 时的调度:

如果 G 被阻塞在某个 channel 操作或网络 I/O 操作上时,G 会被放置到某个等待(wait)队列中,而 M 会尝试运行 P 的下一个可运行的 G。如果这个时候 P 没有可运行的 G 供 M 运行,那么 M 将解绑 P,并进入挂起状态。当 I/O 操作完成或 channel 操作完成,在等待队列中的 G 会被唤醒,标记为可运行(runnable),并被放入到某 P 的队列中,绑定一个 M 后继续执行。

系统调用阻塞时的调度:

如果 G 被阻塞在某个系统调用(system call)上,那么不光 G 会阻塞,执行这个 G 的 M 也会解绑 P,与 G 一起进入挂起状态。如果此时有空闲的 M,那么 P 就会和它绑定,并继续执行其他 G;如果没有空闲的 M,但仍然有其他 G 要去执行,那么 Go 运行时就会创建一个新 M(线程)。

当系统调用返回后,阻塞在这个系统调用上的 G 会尝试获取一个可用的 P,如果没有可用的 P,那么 G 会被标记为 runnable,之前的那个挂起的 M 将再次进入挂起状态。

总结:

如果 G 被阻塞在某个 channel 操作或网络 I/O 操作上时,M 可以不被阻塞,这避免了大量创建 M 导致的开销。但如果 G 因慢系统调用而阻塞,那么 M 也会一起阻塞,但在阻塞前会与 P 解绑,P 会尝试与其他 M 绑定继续运行其他 G。但若没有现成的 M,Go 运行时会建立新的 M,这也是系统调用可能导致系统线程数量增加的原因

非协作的抢占式调度(Go 1.14)

  • 通过向线程发送信号的方式来抢占正在运行的 Goroutine。