本文由 简悦 SimpRead 转码, 原文地址 studygolang.com

进程、线程、协程

进程: 进程是系统进行资源分配的基本单位,有独立的内存空间,单切换代价极高,进程间通信也比较麻烦

线程: 线程是 CPU 调度和分派的基本单位,线程依附于进程,与其他线程共享进程的资源,仅有自己的(程序计数器,一组寄存器的值,和栈),线程切换代价小(但是线程之间的切换可能会涉及用户态和内核态的切换),由于共享进程资源,所以线程之间通信比较方便。

协程协程是一种用户态的轻量级线程,协程的调度完全由用户控制,协程切换只需要保存和恢复任务的上下文,没有内核的开销。协程间通信也比较简单(协程间本身是不可抢占的,由于操作系统的调度机制无法影响到它,因此一般存在用户自定义的调度机制)(也可以这么说内核线程依然叫 “线程 (thread)”,用户线程叫 “协程 (co-routine)”.)

Golang 为并发而生

Goroutine 非常轻量,主要体现在以下方面:

  1. 上下文切换代价小,没有内核的开销,Goroutine 的上下文切换只涉及到三个寄存器(PC/SP/DX)的值的修改,而线程的切换需要涉及模式转换,以及 16 个寄存器的刷新。
  2. 内存占用少,线程栈空间一般是 2M, 而 goroutine 只需要 2k;

Go 的调度器实现机制

Go 程序通过调度器来调度 Goroutine 在内核级线程上执行,但是并不直接绑定 os 线程 M-Machine 运行,而是由 Goroutine Scheduler 中的 P-processor 作获取内核线程资源的【中介】

G-M-P 模型

Go 的调度器通常被称为 G-M-P 模型,实际包含四个结构,分别为:

G:Goroutine

每个 Gotoutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈,状态,以及任务函数,可重用(函数实体)G 需要保存到 P 才能被调度执行

M:machine:os 内核线程的抽象

(操作系统的调度机制无法作用于协程,因为协程是用户态的),M 代表真正执行计算的资源, 在绑定有效的 P 后,进入 schedule(计划) 循环;而 shcedule 循环的机制 大致是从 Global 队列,P 的 local 队列以及 wait 队列中获取。
M 的数量是不固定的,有 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认设置为 1w 个,M 不保存 G 的上下文,这是 G 可以跨 M 的基础。

P:Processor, 表示逻辑处理器

对 G 来说,P 相当于 CPU 核,G 只有绑定到 P 才能被调度。对 M 来说,P 提供了相关的执行环境,入内存分配状态,任务队列等。
P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量)。
P 的数量由用户设置的 GoMAXPROCS 决定,但是不论 GoMAXPROCS 设置为多大,P 的数量最大为 256。

Sche:Go 调度器

它维护有存储 M 和 G 的队列以及调度器的一些状态信息等。
调度器循环的机制大致是从各种队列、P 的本地队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 Goexit 做清理工作并回到 M,如此反复。

地鼠推车搬砖的模型

可以通过经典的地鼠推车搬砖的模型来说明其三者关系:
image.png
地鼠 (Gopher) 的工作任务是:工地上有若干砖头,地鼠借助小车把砖头运送到火种上去烧制。M 就可以看作图中的地鼠,P 就是小车,G 就是小车里装的砖。

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 程序里面对应的是活动线程数;

图示 G-M-P 模型

在 Go 程序里,我们也可以通过下面的图示来展示 G-M-P 模型。
image.png

Go 调度器中有两个不同的运行队列:

全局运行队列 (GRQ)

本地运行队列 (LRQ)

每个 P 都有一个 LRQ,用于管理分配给在 P 的上下文中执行的 Goroutines,这些 Goroutine 轮流被和 P 绑定的 M 进行上下文切换(图中有一个G正在被调度执行)
GRQ 适用于尚未分配给 P 的 Goroutines。

从上图可以看出,G 的数量可以远远大于 M 的数量,换句话说,Go 程序可以利用少量的内核级线程来支撑大量 Goroutine 的并发 M:N 模型。多个 Goroutine 通过用户级别的上下文切换来共享内核线程 M 的计算资源,但对于操作系统来说并没有线程上下文切换产生的性能损耗

Go 调度器的调度策略:

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

任务窃取:

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

减少阻塞

在 Go 里阻塞主要分为以下 4 个场景:

1. 原子、互斥量或 channel 操作调用导致的阻塞

调度器将把当前阻塞的 Goroutine 切换出去,重新调度 LRQ 上的其他 Goroutine。

2. 网络请求和 IO 操作导致 Goroutine 阻塞

Go 程序提供了网络轮询器(NetPoller)来处理网络请求和 IO 操作的问题,其后台通过 kqueue(MacOS),epoll(Linux)或 iocp(Windows)来实现 IO 多路复用。

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

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

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

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

3. 系统方法调用的时候发生阻塞

这种情况下,网络轮询器(NetPoller)无法使用,而进行系统调用的 Goroutine 将阻塞当前 M。

让我们来看看同步系统调用(如文件 I/O)会导致 M 阻塞的情况:G1 将进行同步系统调用以阻塞 M1。
image.png

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

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

4. sleep 阻塞

在 Goroutine 中去执行一个 sleep 操作,导致 M 被阻塞
Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。

只要下次这个 Goroutine 进行函数调用,那么就会被强占,同时也会保护现场,然后重新放入 P 的本地队列里面等待下次执行。