搞定 Golang 协程

246 阅读5分钟

什么是协程

多线程

多线程并发执行,有效提高 CPU 的利用率,但是由于多线程进行切换时是需要一定的时间成本的,尤其是当线程的数量比较多时,切换的成本就会更大,因此在这块还有一定的提升空间。

image.png

image.png

协程

线程可分为 用户空间 与 内核空间 两部分,其中 用户空间 主要是用户程序的执行相关,内核空间 主要是与硬件的管理及使用相关。如下图所示:

image.png

因此,可以将线程一分为二,分别为 用户线程 和 内核线程,用户线程部分其实即为所谓的协程。如下图所示,在操作系统层面,是无法感知到差异的,分割后的线程(内核线程)还是正常使用。

image.png

在上图中,协程与线程一一绑定,这样和原来的多线程模式是一样的,没有减少 CPU 的切换时间。因此需要继续优化,一个线程可以同时绑定多个协程,每个协程都有自己的处理任务,同时引入 协程调度器 来调度协程的执行权,而这对操作系统而言是无感的,即实现了在保证多个任务可并发执行的前提下,减少了 CPU 的上下文切换,让 CPU 把更多的时间花在执行任务的处理上。

image.png

上面的一对多模型看起来已经不错了,但还是存在一些问题,可以试想一下,如果其中有一个协程在执行过程中发生了阻塞,就会影响了下一个要执行了协程,应该也在一定程度上降低了执行效率,如下图所示:

image.png

因此,就有了多对多模型,这样一来,执行效率高不高,很大程度上取决于 协程调度器 做得好不好,对于不同的语言的协程调度器,会有不同的实现。

image.png

Golang 中协程的实现

前面介绍了什么是协程,下面说说 Golang 中协程的实现。

缩小内存占用

在 Golang 中,一个协程仅占几KB的内存,因此允许大量的生产以及灵活调度(切换)。

image.png

协程调度器

早期协程调度器

在 Golang 中,早期的协程调度器比较简单,同时也存在很多的弊端,这里就不展开介绍了。下图是其大致工作原理图。

image.png

当前协程调度器

在优化后的协程调度器中,引入了 processor,它表示一个逻辑处理器。P 负责管理一组可运行的协程(G),并将其调度到绑定的 M(处理线程)上执行。在 Go 的并发模型中,P 可以看作是逻辑上的调度单元,负责协程的调度和管理。

image.png

如下图所示,线程会绑定一个 P, P 的个数由 GOMAXPROCS 的值决定,可支持 P 个数的并发量,每个 P 都有自己的本地队列,用于存放待执行的 GORuntine (协程),同时也会维护着一个全局队列,全局队列主要用于存放一些空闲的 G,又或者是 P 的本地队列存放不下时会存放到全局队列(创建一个 G,会被优先放入 P 的本地队列)。

image.png

调度器的设计策略

复用线程
work stealing 机制

如下图所示,在 M2 的 P 本地队列的没有可执行的 G,但是 M1 的 P 本地队列中存在正在等待的 G ,这时候 M2 就会从 M1 的 P 本地队列中取出(偷取)G 进行执行,也就是所谓的偷取机制,通过这种方式来提高执行的效率。

image.png

hand off 机制

hand off 机制,具体的做法是若当前在 M1 线程上执行的 G1 发生了阻塞,这个时候会 创建/唤醒 一个线程 M3,并且把线程 M2 的 P 和 本地队列 转移到线程 M3 , 交由 M3 处理待执行的协程。

image.png

利用并行

不同的 P 之间是并发处理 G 的,可以支持 P 个数的并发,通过 GOMAXPROCS 设置 P 的个数,一般推荐设置为 CPU核数/2。

抢占

每个协程在获得 CPU 后只准许执行一定的时间,时间到后会被其他的协程抢占执行权,保证各个 G 的执行时间较为均衡,不会发生饥饿现象。

image.png

全局 G 队列

对于全局 G 队列,在前面已有提及,可以结合 work stealing 机制来讨论,当 P 自己的本地队列为空,就会尝试到其他 P 的队列中进行偷取,如果此时其他 P 的 G 队列也为空,就会尝试去全局队列中获取 G ,进而执行,获取的过程中会使用锁。

image.png

总结

协程相比传统线程占用更少的内存资源,能够支持大规模的并发操作。Golang 的协程调度器通过 P(逻辑处理器)的管理,实现了协程的高效调度与管理,包括复用线程、抢占机制以及全局 G 队列的设计策略。协程相比于传统线程模型能够更高效地利用 CPU ,并且通过协程调度器实现任务的并发执行,减少了上下文切换的开销。

参考(图片来源):goroutine基本模型和调度设计策略_哔哩哔哩_bilibili