Go 语言协程调度模型,是 Go 语言开发者追求理解的首要知识点。

软件是运行在操作系统之上的,但真正用来计算的是 CPU。早期的操作系统,把每个程序都看作一个进程,一个进程执行完,才能执行下一个程序,即以串行的方式执行,单进程时代。

单进程时代不需要调度器,但面临了两个问题,如下。

  • 串行的执行流程,不具备异步的能力,例如无法处理鼠标与图形化界面的交互。
  • 进程阻塞,带来 CPU 时间浪费。在一个进程完整的生命周期中,要访问许多硬件,不同的硬件媒介,处理计算能力相差较大,会出现高速媒介等待和浪费的现象。

为了解决这两个问题,早期的操作系统有了多进程并发的能力。当一个进程阻塞的时候,切换到另外等待执行的进程。对于 CPU 来说,当前进程被阻塞时,会通过 CPU 调度算法,将所有未被阻塞的进程都有机会被分配到 CPU 时间片。

CPU 利用率提高了,新的问题也出现了,当进程拥有太多资源时,进程的创建、切换、销毁都会占用很长时间,如果这种成本大于程序实际目标的时间,就本末倒置了。同时,多进程开发设计上也会变得复杂,如锁、内存竞态、同步冲突等。

后来,工程师们发现,可以把进程分为线程,线程又可以分为“内核态”和“用户态”,用户态的线程,可以更少的内存占用、更少的隔离、更快的调度、更高的可控性,可以自己实现调度器。一个用户态的线程,必须绑定一个内核态,因为 CPU 的视野中,不知道有用户态的线程。

内核线程,即内核态的线程叫线程,而用户线程,用户态的线程叫协程,只要满足在同一个内核线程上执行多个任务,都可以叫做协程,例如 Go 的 Goroutine、C# 的 Task 等。

协程与线程的映射关系有如下三种。

  • N:1

N 个协程绑定 1 个线程。优点是协程在用户态完成切换,不会陷入内核态,速度非常轻量快速,缺点是所有的协程都绑定在 1 个进程下的 1 个线程上,如果一个协程出现阻塞,该线程就会阻塞,其他协程也就无法执行了,进而导致没有任何并发能力。

  • 1:1

1 个协程绑定 1 个线程,这是最容易实现的,因为协程的调度都由 CPU 完成了,并且不会有 N:1 中提到的问题,缺点是协程的调度都由 CPU 完成了,成本和代价略显昂贵。

  • M:N

M 个线程绑定 1 个线程,是前两者的结合版,避免了它们的所有的问题,缺点是实现起来最复杂。复杂的点在于,同一个调度器上挂载了 M 个协程和 N 个线程,线程由 CPU 调度,是抢占式的,协程由用户态调度,是协作式的,一个协程让出 CPU 后,才会执行下一个协程。所以 M:N 模型的中间层调度器的设计是重要且复杂的。

了解映射关系后,我们来看看 Go 是如何实现调度器的。早期的 Go 调度器就是基于 M:N 映射关系实现的。所有的协程都放在一个全局的 Go 协程队列中,所有的线程想要执行协程,都需要先拿到获取队列中协程的锁,老调度器的缺点如下。

  • 创建、销毁、调度(执行、放回)都需要每个线程获取到锁,造成了激烈的锁竞争。
  • 当一个线程执行协程的过程中,协程包含了创建新的协程,为了让协程继续执行,需要把新创建的协程分配给其他的线程执行,造成了很差的局部性。
  • 频繁的线程阻塞和取消线程阻塞,都是系统调用,造成了额外的系统开销
最后修改:2023 年 10 月 27 日
如果觉得我的文章对你有用,请随意赞赏