Goroutine 并发调度模型分析

简述

在调用器基本原理之- 进程,线程,协程你真的了解吗?一文中,我们对操作系统中的进程,线程,和协程的概念做了一个复习。
在Go调度器实现原理相关资料整理篇文章中,我们梳理一些关于Goroutine 原理的分析文章。
今天,结合自己的理解,整理出Goroutine 的并发调度模型的关键问题,及其中的解决方案。

Gorotine 与 Coroutine 的区别

通常, goroutine会被当做coroutine(协程)的 golang实现. 但实际上并不准确。
理由若干,如下:

1. goroutines imply parallelism; coroutines in general do not
2. goroutines communicate via channels; coroutines communicate via yield and resume operations

In general, goroutines are much more powerful than coroutines.  In particular, it is easy to port 
coroutine logic to goroutines and gain parallelism in the process.

为什么需要 goroutine 调度器

之前的文章,我们知道,在混合线程模型中,用户态的线程最终执行是依托内核线程来实现。由运行时来完成和内核的通信,从而实现阻塞线程的切换。

Go采用了用户层轻量级thread或者说是类coroutine,goroutine占用的资源非常小(Go 1.4将每个goroutine stack的size默认设置为2k),goroutine调度的切换也不用陷入(trap)操作系统
内核层完成,代价很低。Go代码都在goroutine中执行,将这些goroutines按照一定算法放到“CPU”上执行的程序就称为goroutine调度器或goroutine scheduler

Goroutine的调度问题:
go runtime如何将程序内的众多goroutine按照一定算法调度到“CPU”资源上运行。

3 种模型

N:1: 多个用户线程对应一个OS线程。上下午切换很快,但不能利用多核的优势

1:1: 一个用户线程,对应一个OS线程。能充分利用多核系统优势,但线程的上下文切换效率低下,因为会陷入系统调用。

M:N:能够调度任意数量的 goroutines到任意数量的OS线程上。此方案,既能获得快速的上下文切换,也能充分发挥多核CPU的优势。

Go调度器模型

  1. G-M 模型

正如前文所叙述,GO调度器解决的核心问题是:将M个用户线程,调度到N个内核线程去执行。显然,我们需要做两个至少概念的抽象:即用户线程,和内核线程。

每个goroutine对应于runtime中的一个抽象结构:G

内核线程,os thread作为“物理CPU”的存在而被抽象为一个结构:M(machine) 。

而这正是 Go 1.0正式发布时的原始模型。但是大家思考一个生活常识:在一个工厂里,有很多工作任务需要去执行,同时有很多工人,如果想让这些工作任务有条不紊的执行,是需要一个管理角色来协调各个工作的分配和切换。

果然,在1.0 发出后, 前Intel blackbelt工程师、现Google工程师Dmitry Vyukov在其《Scalable Go Scheduler Design》一文中指出了G-M模型的不足:

    * 单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有goroutine相关操作,比如:创建、重新调度等都要上锁;
    * goroutine传递问题:M经常在M之间传递”可运行”的goroutine,这导致调度延迟增大以及额外的性能损耗;
    * 每个M做内存缓存,导致内存占用过高,数据局部性较差;
    * 由于syscall调用而形成的剧烈的worker thread阻塞和解除阻塞,导致额外的性能损耗。
  1. G-M-P 模型

Dmitry Vyukov亲自操刀改进Go scheduler, 在Go 1.1中实现了G-P-M调度模型和work stealing算法.
Gorotutine 调度原理图如图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G6zTJyIU-1586668212695)(https://tonybai.com/wp-content/uploads/goroutine-scheduler-model.png)]

G-M-P 模型中:

P 可以看作一个本地化版本的线程,
它和内核线程M绑定。

You can look at it as a localized version of the scheduler which runs Go code on a single thread. It's the
important part that lets us go from a N:1 scheduler to a M:N scheduler. In the runtime code, it's called P 
for processor. 

从这个角度,也可称P 为 contexts。P的初始化数量,可通过 GOMAXPROCS 环境变量指定。

G 代表的是 goroutine,

The circle represents a goroutine. It includes the stack, the instruction pointer and other information 
important for scheduling goroutines, like any channel it might be blocked on. In the runtime code, it's
called a G.

以上是基于网上的资料梳理的,下面加上我自己的理解,可能不正确,欢迎大家指正

   我是这么思考的:在一个营业大厅里,开了N个窗口,每个窗口相当于 Go调度器中的一个 os 线程即 M . 
   此时,来了很多需要办理业务的客户,即goroutine G 。每个窗口能维护一个队列,但为了维护这个队列的高效运作(防止部分客户
   准备工作没做好,将其引导到别处处理)每个窗口配置了一名工作人员维护秩序,这个即是P。
   P的指责主要有几点:
   1. 发现当前队列没有客户了,到其他窗口的队列偷一部分客户
   2. 发现队列中,有些客户准备工作没做好,将其剔除来,让其他客户办理业务。等他准备好了后,在放入队列。

Go调度器模型进化

“抢占式”调度

Dmitry Vyukov 提出了《Go Preemptive Scheduler Design》并在Go 1.2中实现了“抢占式”调度,解决某个G中出现死循环或永久循环的代码逻辑,那么G将永久占用分配给它的P和M,位于同一个P中的其他G将得不到调度,出现“饿死”的情况

NUMA调度模型

Dmitry Vyukov在2014年9月提出了一个新的proposal design doc:《NUMA‐aware scheduler for Go》,作为未来Go scheduler演进方向的一个提议

netpoller

Go runtime已经实现了netpoller,这使得即便G发起网络I/O操作也不会导致M被阻塞(仅阻塞G),从而不会导致大量M被创建出来。

但是对于regular file的I/O操作一旦阻塞,那么M将进入sleep状态,等待I/O返回后被唤醒;这种情况下P将与sleep的M分离,再选择一个
idle的M。如果此时没有idle的M,则会新创建一个M,这就是为何大量I/O操作导致大量Thread被创建的原因

参考文章

也谈goroutine调度器

The Go scheduler

Go Language Patterns

Analysis of the Go runtime scheduler

你可能感兴趣的:(编程基础,Go语言笔记)