Go 是如何调度的?

2020-09-08  本文已影响0人  简笔话_Golden

tags: [Go并发原理]

categories: Go


[TOC]

导读

调度在计算机中是分配工作所需资源的方法. linux的调度为CPU找到可运行的线程. 而Go的调度是为M(线程)找到P(内存, 执行票据)和可运行的G.

带着问题去思考🤔:

读完下面👇的文章,这些通通都不是问题,follow me!!

image

基础常识

调度的进化:Process -> Thread(LWP, lightweight process) -> Goroutine (一种lightweight userspace thread)其实是一个不断共享, 不断减少切换成本的过程.

理解调度, 首先要理解两个概念: 运行和阻塞

小知识点: 操作系统: Linux 下的线程其实只是 task_struct 结构, CPU 在时钟的驱动下, 根据 PC 寄存器从程序中取指令和操作数, 从 RAM 中取数据, 进行计算, 处理, 跳转, 驱动执行流往前. CPU 并不关注处理的是线程还是协程, 只需要设置 PC 寄存器, 设置栈指针等(这些称为上下文), 那么 CPU 就可以欢快的运行这个线程或者这个协程了.

调度模型

GM 模型(1.0版本)

image

一个全局队列放待运行的g,新生成G, 阻塞的G变为待运行, M寻找可运行的G等操作都在全局队列中操作, 需要加线程级别的锁.

调度锁问题:单一的全局调度锁(Sched.Lock)和集中的状态, 导致伸缩性下降.

G传递问题: 在工作线程M之间需要经常传递runnable的G, 会加大调度延迟, 并带来额外的性能损耗.

Per-M的内存问题: 类似TCMalloc结构的内存结构, 每个M都需要memory cache和其他类型的cache(比如stack alloc), 然而实际上只有M在运行Go代码时才需要这些Per-M Cache, 阻塞在系统调用的M并不需要这些cache. 正在运行Go代码的M与进行系统调用的M的比例可能高达1:100, 这造成了很大的内存消耗.

GPM 模型(1.1版本+)

image

Go 调度器中有两个不同的运行队列:全局运行队列(GRQ)和本地运行队列(LRQ)。

P 代表可以“并行”运行的逻辑处理器,每个 P 都被分配到一个系统线程 M,G 代表 Go 协程。每个 P 都有一个 LRQ,用于管理分配给在 P 的上下文中执行的 Goroutines,这些 Goroutine 轮流被和 P 绑定的 M 进行上下文切换。GRQ 适用于尚未分配给 P 的 Goroutines。

举例说明

通过经典的地鼠推车搬砖的模型来说明M、P、G 其三者关系:

地鼠(Gopher)的工作任务是:工地上有若干砖头,地鼠借助小车把砖头运送到火种上去烧制。M 就可以看作图中的地鼠,P 就是小车,G 就是小车里装的砖。

image

Processor(P):

根据用户设置的 GoMAXPROCS 值来创建一批小车(P)。

Goroutine(G):

通过 Go 关键字就是用来创建一个 Goroutine,也就相当于制造一块砖(G),然后将这块砖(G)放入当前这辆小车(P)中。

Machine (M):

地鼠(M)不能通过外部创建出来,只能砖(G)太多了,地鼠(M)又太少了,实在忙不过来,刚好还有空闲的小车(P)没有使用,那就从别处再借些地鼠(M)过来直到把小车(P)用完为止。

其中这里有一个地鼠(M)不够用,从别处借地鼠(M)的过程,这个过程就是创建一个内核线程(M)。

注意:地鼠(M) 如果没有小车(P)是没办法运砖的,小车(P)的数量决定了能够干活的地鼠(M)数量,在 Go 程序里面对应的是活动线程数;

模型详情
image

GPM 模型包括 4 个重要结构,分别是G、P、M、Sched;

G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。

每个p均有local runq, 大多数时间仅与local runq无锁交互,新生成的g, 放入到local runq中.

P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量)。P 的数量由用户设置的 GoMAXPROCS 决定。

M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。

M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。

image

模型对比

G状态流转

image image image

Go 调度器

并没有一个调度器的实体, 调度是通过线程(m)执行 runtime.schedule 函数来完成的.

调度器的职责就是为需要执行的Go代码(G)寻找执行者(M)以及执行的准许和资源(P),大致是从各种队列、P 的本地队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 Goexit 做清理工作并回到 M,如此反复。

调度时机:

调度策略

为了更加充分利用线程的计算资源,Go 调度器采取了以下几种调度策略。

任务窃取(work-stealing)

我们知道,现实情况有的 Goroutine 运行的快,有的慢,那么势必肯定会带来的问题就是,忙的忙死,闲的闲死,Go 肯定不允许摸鱼的 P 存在,势必要充分利用好计算资源。

为了提高 Go 并行处理能力,调高整体处理效率,当每个 P 之间的 G 任务不均衡时,调度器允许从 GRQ,或者其他 P 的 LRQ 中获取 G 执行

减少阻塞

如果正在执行的 Goroutine 阻塞了线程 M 怎么办?P 上 LRQ 中的 Goroutine 会获取不到调度么?

主要的 4 种阻塞场景:

解决方式:调度器将把当前阻塞的 Goroutine 切换出去,重新调度 LRQ 上的其他 Goroutine;

用户代码中的协程同步造成的阻塞, 仅仅是切换协程, 而不阻塞线程.

Go 程序提供了网络轮询器(NetPoller)来处理网络请求和 IO 操作的问题,**

解决方式:Go通过使用网络轮询器(NetPoller)进行网络系统调用,

防止 Goroutine 在进行这些系统调用时阻塞 M。这可以让 M 执行 P 的 LRQ 中其他的 Goroutines,而不需要创建新的 M。从而减少操作系统上的调度负载。

下面展示它的工作原理:

G1 正在 M 上执行,还有 3 个 Goroutine 在 LRQ 上等待执行。网络轮询器空闲着,什么都没干。

image

接下来,G1 想要进行网络系统调用,因此它被移动到网络轮询器并且处理异步网络系统调用。然后,M 可以从 LRQ 执行另外的 Goroutine。此时,G2 就被上下文切换到 M 上了。

image

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

image

解决方案:这种情况下,网络轮询器(NetPoller)无法使用,而进行系统调用的 Goroutine 将阻塞当前 M。此时需要调度器的介入。

例如:同步系统调用(如文件 I/O)会导致 M 阻塞的情况,G1 将进行同步系统调用以阻塞 M1。

image

调度器介入后:监控线程 sysmon 识别出 G1 已导致 M1 阻塞,会将 M1 与 P 分离,同时也将 G1 带走。然后调度器引入新的 M2 来服务 P。此时,可以从 LRQ 中选择 G2 并在 M2 上进行上下文切换。

image

阻塞的系统调用完成后:G1 可以移回 LRQ 并再次由 P 执行。为防止这种情况再次发生,M1 将被放在旁边以备将来重复使用。

image

场景 4:在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了。

解决方案:Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以抢占的标识符,别的 Goroutine 就可以抢先进来执行。只要下次这个 Goroutine 进行函数调用,那么就会被强占,同时也会保护现场,然后重新放入 P 的本地队列里面等待下次执行。

小结

调度流程

image

1. 有分配到gc mark的工作需要做gc mark.

2. 没有会随机从全局runq取g.

2. 没有再看local runq是否有,

3. 没有再看global runq是否有,

4. 没有再看能否从net中poll出来,

5. 还没有再看从其他P steal一部分过来.

....

实在没有就stopm。实际调度代码复杂很多。

相关源码结构体

编译器将go关键字编译为生成一个协程结构体

image image

sysmon协程

P的数量影响了同时运行go代码的协程数. 如果P被占用很久, 就会影响调度.

sysmon协程的一个功能就是进行抢占.

启动

sysmon协程是在go runtime初始化之后, 执行用户编写的代码之前, 由runtime启动的不与任何P绑定, 直接由一个M执行的协程, 类似于linux中的执行一些系统任务的内核线程.

运行间隔

sysmon协程是在go runtime初始化之后, 执行用户编写的代码之前, 由runtime启动的不与任何P绑定, 直接由一个M执行的协程. 类似于linux中的执行一些系统任务的内核线程.

可认为是10ms执行一次. (初始运行间隔为20us, sysmon运行1ms后逐渐翻倍, 最终每10ms运行一次. 如果有发生过抢占成功, 则又恢复成初始20us的运行间隔, 如此循环)

执行工作
image
协作式抢占

目前(1.12), go支持协作的抢占调度, 还不支持非协作的抢占调度.

go 目前(1.12)还没有实现非协作的抢占. 基本流程是 sysmon 协程标记某个协程运行过久, 需要切换出去, 该协程在运行函数时会检查栈标记, 然后进行切换.

内部调用:

retake()调用preemptone()将被抢占的G的stackguard0设为stackPreempt,

被设置抢占标记的G进行下一次函数调用时, 检查栈空间失败. 进而触发morestack()(汇编代码,位于asm_XXX.s中)然后进行一连串的函数调用,主要的调用过程如下:

morestack()(汇编代码)-> newstack() -> gopreempt_m() -> goschedImpl() -> schedule()

网络操作

image

1. 封装epoll, 有网络操作时会epollcreate一个epfd.

2. 所有网络fd均通过fcntl设置为NONBLOCK模式, 以边缘触发模式放入epoll节点中.

3. 对网络fd执行Accept(syscall.accept4), Read(syscall.read), Write(syscall.write)操作时, 相关操作未ready, 则系统调用会立即返回EAGAIN; 使用gopark切换该协程

4. 在不同的时机, 通过epollwait来获取ready的epollevents, 通过其中data指针可获取对应的g, 将其置为待运行状态, 添加到runq

总结

回答疑问

Go是怎样实现少量内核线程支撑大量 Goroutine 的并发运行?

解:多个 Goroutine 通过用户级别的上下文切换来共享内核线程 M 的计算资源,但对于操作系统来说并没有线程上下文切换产生的性能损耗。因此,Go 程序可以利用少量的内核级线程来支撑大量 Goroutine 的并发。

为了最大限度利用计算资源,Go 调度器是如何处理线程阻塞的场景?

解:通过 netpoller、sysmon 等帮助 Go 程序减少线程阻塞

Go的调度为什么说是轻量的?

解:Goroutine 非常轻量,主要体现在两个方面:

三个寄存器PC、SP、DX的值修改;而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP…等寄存器的刷新;

注:但是大量的 Goroutine 也会带来额外的问题,比如栈内存增加和调度器负担加重

参考文章:

Go 为什么这么“快”

Golang 的 goroutine 是如何实现的?

推荐阅读:

https://learnku.com/articles/41728

上一篇 下一篇

猜你喜欢

热点阅读