目录

Go GMP 模型

进程、线程、协程的区别

进程

进程是操作系统对一个正在运行的程序的一种抽象,进程是资源分配的最小单位

https://cdn.xiaobinqt.cn/xiaobinqt.io/20220405/c0032558db4249a0a8ce4d6473327d38.png
进程在操作系统中的抽象表现

进程存在的意义是为了合理压榨 CPU 的性能和分配运行的时间片,不能 “闲着“

在计算机中,其计算核心是 CPU,负责所有计算相关的工作和资源。单个 CPU 一次只能运行一个任务。如果一个进程跑着,就把唯一一个 CPU 给完全占住,那是非常不合理的。

如果总是在运行一个进程上的任务,就会出现一个现象。就是任务不一定总是在执行 ”计算型“ 的任务,会有很大可能是在执行网络调用,阻塞了,CPU 岂不就浪费了?

https://cdn.xiaobinqt.cn/xiaobinqt.io/20220405/a406c795e40e4b7395a0228b299dd6bd.png
进程上下文切换

所以出现了多进程,多个 CPU,多个进程。多进程就是指计算机系统可以同时执行多个进程,从一个进程到另外一个进程的转换是由操作系统内核管理的,一般是同时运行多个软件。

线程

有了多进程,在操作系统上可以同时运行多个进程。那么为什么有了进程,还要线程呢?这是因为,

  • 进程间的信息难以共享数据,父子进程并未共享内存,需要通过进程间通信(IPC),在进程间进行信息交换,性能开销较大。
  • 创建进程(一般是调用 fork 方法)的性能开销较大。

大家又把目光转向了进程内,能不能在进程里做点什么呢?

https://cdn.xiaobinqt.cn/xiaobinqt.io/20220405/9462880ac1eb4cdeb5288716a15bac4d.png
进程由多个线程组成

一个进程可以由多个称为线程的执行单元组成。每个线程都运行在进程的上下文中,共享着同样的代码和全局数据。

多个进程,就可以有更多的线程。多线程比多进程之间更容易共享数据,在上下文切换中线程一般比进程更高效。这是因为,

  • 线程之间能够非常方便、快速地共享数据。 只需将数据复制到进程中的共享区域就可以了,但需要注意避免多个线程修改同一份内存。
  • 创建线程比创建进程要快 10 倍甚至更多。 线程都是同一个进程下自家的孩子,像是内存页、页表等就不需要了。

协程

协程(Coroutine)是用户态的线程。通常创建协程时,会从进程的堆中分配一段内存作为协程的栈。

线程的栈有 8 MB,而协程栈的大小通常只有 KB,而 Go 语言的协程更夸张,只有 2-4KB,非常的轻巧。

协程有以下优势👋:

  • 👉节省 CPU:避免系统内核级的线程频繁切换,造成的 CPU 资源浪费。好钢用在刀刃上。而协程是用户态的线程,用户可以自行控制协程的创建于销毁,极大程度避免了系统级线程上下文切换造成的资源浪费。

  • 👉节约内存:在 64 位的Linux中,一个线程需要分配 8MB 栈内存和 64MB 堆内存,系统内存的制约导致我们无法开启更多线程实现高并发。而在协程编程模式下,可以轻松有十几万协程,这是线程无法比拟的。

  • 👉稳定性:前面提到线程之间通过内存来共享数据,这也导致了一个问题,任何一个线程出错时,进程中的所有线程都会跟着一起崩溃。

  • 👉开发效率:使用协程在开发程序之中,可以很方便的将一些耗时的IO操作异步化,例如写文件、耗时 IO 请求等。

goroutine 是什么

Goroutine 是一个由 Go 运行时管理的轻量级线程,我们称为 “协程”。

1
go f(x, y, z)

操作系统本身是无法明确感知到 Goroutine 的存在的,Goroutine 的操作和切换归属于 “用户态” 中。

Goroutine 由特定的调度模式来控制,以 “多路复用” 的形式运行在操作系统为 Go 程序分配的几个系统线程上。

同时创建 Goroutine 的开销很小,初始只需要 2-4k 的栈空间。Goroutine 本身会根据实际使用情况进行自伸缩,非常轻量。

Tips

Go程序中没有语言级的关键字让你去创建一个内核线程,你只能创建 goroutine,内核线程只能由 runtime 根据实际情况去创建。

Go运行时系统并没有内核调度器的中断能力,内核调度器会发起抢占式调度将长期运行的线程中断并让出CPU资源,让其他线程获得执行机会。

什么是调度

用户态的 Goroutine,操作系统看不到它。必然需要有某个东西去管理他,才能更好的运作起来。 这就是 Go 语言中的调度,也就是 GMP 模型。

Go scheduler /ˈskedʒuːlər/ 的主要功能是针对在处理器上运行的 OS 线程分发可运行的 Goroutine,而我们一提到调度器,就离不开三个经常被提到的缩写,分别是:

G:Goroutine,实际上我们每次调用 go func 就是生成了一个 G。

P:Processor,处理器,一般 P 的数量就是处理器的核数,可以通过 GOMAXPROCS 进行修改。

M:Machine,系统线程。

这三者交互实际来源于 Go 的 M: N 调度模型。也就是 M 必须与 P 进行绑定,然后不断地在 M 上循环寻找可运行的 G 来执行相应的任务。

调度流程

https://cdn.xiaobinqt.cn/xiaobinqt.io/20220405/3ce52556ebc74dcfaf5771cfacb4e218.png
调度流程

  1. 当我们执行 go func() 时,实际上就是创建一个全新的 Goroutine,我们称它为 G。

  2. 新创建的 G 会被放入 P 的本地队列(Local Queue)或全局队列(Global Queue)中,准备下一步的动作。需要注意的一点,这里的 P 指的是创建 G 的 P。 唤醒或创建 M 以便执行 G。

  3. 不断地进行事件循环

  4. 寻找在可用状态下的 G 进行执行任务

  5. 清除后,重新进入事件循环

在描述中有提到全局和本地这两类队列,其实在功能上来讲都是用于存放正在等待运行的 G,但是不同点在于,本地队列有数量限制,不允许超过 256 个

并且在新建 G 时,会优先选择 P 的本地队列,如果本地队列满了,则将 P 的本地队列的一半的 G 移动到全局队列。 可以理解为调度资源的共享和再平衡。

窃取行为

可以看到图上有 steal 行为,这是用来做什么的呢,我们都知道当你创建新的 G 或者 G 变成可运行状态时,它会被推送加入到当前 P 的本地队列中。

其实当 P 执行 G 完毕后,它也会 “干活”,它会将其从本地队列中弹出 G,同时会检查当前本地队列是否为空,如果为空会随机的从其他 P 的本地队列中尝试窃取一半可运行的 G 到自己的名下。

https://cdn.xiaobinqt.cn/xiaobinqt.io/20220405/a378474165e94b908f8fc7b44812cc4c.png
窃取行为

上图中👆,P2 在本地队列中找不到可以运行的 G,它会执行 work-stealing 调度算法,随机选择其它的处理器 P1,并从 P1 的本地队列中窃取了三个 G 到它自己的本地队列中去。

至此,P1、P2 都拥有了可运行的 G,P1 多余的 G 也不会被浪费,调度资源将会更加平均的在多个处理器中流转。

限制条件

M 的限制

在协程的执行中,真正干活的是 GPM 中的 M(系统线程) ,因为 G 是用户态上的东西,最终执行都是得映射,对应到 M 这一个系统线程上去运行。

那么 M 有没有限制呢?

答案是:有的。在 Go 语言中,M 的默认数量限制是 10000,如果超出则会报错:

1
GO: runtime: program exceeds 10000-thread limit

但是通常只有在 Goroutine 出现阻塞操作的情况下,才会遇到这种情况。这可能也预示着你的程序有问题。

若确切是需要那么多,还可以通过 debug.SetMaxThreads 方法进行设置。

G 的限制

Goroutine 的创建数量是否有限制?

答案是:没有。但理论上会受内存的影响,假设一个 Goroutine 创建需要 4k 的连续的内存块

4k * 80,000 = 320,000k ≈ 0.3G内存

4k * 1,000,000 = 4,000,000k ≈ 4G内存

以此就可以相对计算出来一台单机在通俗情况下,所能够创建 Goroutine 的大概数量级别。

P 的限制

P 的数量是否有限制,受什么影响?

答案是:有限制。P 的数量受环境变量 GOMAXPROCS 的直接影响

环境变量 GOMAXPROCS 又是什么?在 Go 语言中,通过设置 GOMAXPROCS,用户可以调整调度中 P(Processor)的数量。

另一个重点在于,与 P 相关联的的 M(系统线程),是需要绑定 P 才能进行具体的任务执行的,因此 P 的多少会影响到 Go 程序的运行表现

P 的数量基本是受本机的核数影响,没必要太过度纠结他。

那 P 的数量是否会影响 Goroutine 的数量创建呢?

答案是:不影响。且 Goroutine 多了少了,P 也该干嘛干嘛,不会带来灾难性问题。

小结

  • M:有限制,默认数量限制是 10000,可调整。
  • G:没限制,但受内存影响。
  • P:受本机的核数影响,可大可小,不影响 G 的数量创建。

所以Goroutine 数量怎么预算,才叫合理?

在真实的应用场景中,如果你 Goroutine:

  • 在频繁请求 HTTP,MySQL,打开文件等,那假设短时间内有几十万个协程在跑,那肯定就不大合理了(可能会导致 too many files open)。
  • 常见的 Goroutine 泄露所导致的 CPU、Memory 上涨等,还是得看你的 Goroutine 里具体在跑什么东西。

跑的如果是 “资源怪兽”,只运行几个 Goroutine 都可以跑死。

为什么要有 P

// TODO

参考