牛了个牛,Go 的协程设计得这么精妙!

前言

Go 语言的并发模型主要基于协程(goroutine),与其他编程语言(比如 Java)的线程相比,Go 协程以其轻量、易用和高效的特点吸引了大量开发者。本文,我将将深入分析 goroutine的工作原理及其调度机制。

1. 理解协程(goroutine)

1.1 什么是协程?

协程是协作式的线程,实现并发编程时,通过多任务协作运行。Go 中的协程,本质上是一种轻量级的线程,由 Go 运行时(runtime)进行管理和调度。与操作系统级的线程相比,协程具备以下明显的优势:

  1. 创建和销毁的开销更低:Go 的协程基于用户态实现,因此其创建和销毁的代价远低于系统线程。
  2. 比线程更轻量:协程的栈大小是动态可扩展的,远小于内核线程的初始内存占用,因此 Go 可以轻松创建成千上万的协程。
  3. 调度独立于操作系统的调度机制:协程的调度是由 Go runtime 完全自主实现的,不依赖于可能更耗时的内核调度系统。

1.2 协程的轻量性分析

每个 Go 协程的初始栈大小约为 2KB(相较于系统线程的 1MB 左右的栈内存空间),这一大小确保了 Go 程序可以轻松管理大量的并发任务。例如,在一个高吞吐量的 HTTP 服务器上,可能需要为每个连接创建一个新的协程,在这种场景下,使用传统的系统线程会导致极大的内存开销和上下文切换成本,而协程则显得尤为轻便。

在程序执行过程中,协程的栈空间会根据需要动态扩展,使用满足需求的更大空间,从而避免程序因栈空间不足而崩溃。同时,当不再需要这么多栈空间时,Go 运行时甚至还会释放多余的栈内存,保证使用最小的内存资源。

2、Go 调度器:GPM 模型

2.1 什么是 GPM 模型?

为了高效管理和调度 Go 协程,Go 语言运行时引入了GPM 模型,其中 G、P 和 M 分别代表:

  • G(Goroutine):即 Go 协程,表示一个具体执行的任务。
  • P(Processor):表示可执行 G 的处理器,它维护着一个局部的队列,用来保存准备好执行的 G。
  • M(Machine):表示系统线程,与操作系统的内核线程一一对应,M 负责真正执行 G,并与 P 关联。

整个调度模型的核心目标是让 M 通过 P 来执行 G。简化来说,P 就像是一个调度器,它维护着队列和资源,而 M 则是与操作系统交互的实体,当 M 获得一个 P 后,就可以开始执行其队列中的 G。

GO GPM调度模型

2.2 GPM 模型的工作流程

  • 初始化时,Go 运行时会创建 M 和 P。通常,P 的数量是通过环境变量 GOMAXPROCS 控制的,默认值为系统 CPU 的核心数。
  • 当需要执行新的协程时,Go 运行时会将新的 G 分配到某个 P,并像排队一样加入 P 的任务队列。
  • 然后,P 会寻找空闲的 M 来执行这些 G。如果没有多余的 M,则可能会创建新的 M。
  • 每个 M 都会获取 P 中的任务进行执行,如果 P 中没有足够的可用任务,M 也可能尝试从其他 P 的全局队列或者其他 P 中“窃取”任务,从而提高任务的利用率。

这种机制下,调度器实现了 Work Stealing(任务窃取)模型,可以平衡不同 P 之间的负载,在高并发场景下高效分配工作。

2.3 调度中的细节

上下文切换

Go 的调度器管理着 G 的协程上下文和执行。在切换上下文的时候,协程有自己的寄存器集和栈,Go 通过保存和恢复不同的 G 来达到调度的目的。相比于操作系统线程的上下文切换,协程之间的切换开销要小得多,因为它们共享同一个 OS 线程运行,不涉及到用户态和内核态的转换,因此更加高效。

协作式调度与抢占式调度

Go 1.14 及之后的版本引入了抢占式调度,但仍然保留了协作式调度的部分特性。在 Go 1.14 之前,调度器是以协作的方式进行的,即一个 G 协程需要主动让出 CPU 时间,其他 G 才能有机会运行。这种方式的弊端是,如果一个协程长时间运行且没有主动让出 CPU,整个程序的并发性就会受到影响。

从 Go 1.14 开始,Go 引入抢占式调度,意味着即使某个协程长时间执行而没有主动退出,Go 运行时也可以中断它并切换到其他协程。不过,它的抢占操作主要针对某些特定的点,比如运行栈溢出检查或系统调用等。

全局队列和本地队列

每个 P 都有一个本地队列,存放一些待执行的 G。如果本地队列耗尽,P 则从全局队列或者其他 P 偷窃任务,这是一种平衡负载的手段。此外,全局队列则是一个共享的结构,相较于本地队列,频繁进行全局队列的任务调度会带来更多的锁竞争开销。所以,Go 更推荐通过局部 P 的本地队列来管理任务以提高并发效率。本地队列的大小默认是 256。

2.4 GPM 模型的基本操作分析

在理解了 GPM 模型的基本结构后,我们来分析 GPM 模型具体流程中的一些操作

1. Go协程创建

go 关键字被调用时,Go runtime 会创建一个新的 G(协程)并将其放入到 P 的本地队列中。P 将这个任务分配给空闲的 M,M 通过内核线程执行这一任务。

2. 调度与执行

M 将读取 G 的指令并执行任务,期间如果 G 需要进行 I/O 操作,M 会等待 G 中的阻塞调用返回。如果某个 M 执行的任务过长,调度器会通过定期检查的抢占系统强制切换其他可执行的 G。

3. 任务窃取

如果某个 M 发现其所属的 P 没有更多的任务可执行,会从全局队列或者其他 P 的本地队列中尝试窃取任务。窃取机制有效的分布并行任务,减小了任务偏载问题,让每个 M 都更加高效地利用 CPU 资源。

4. Goroutine 阻塞和恢复

如果 G 进入了阻塞状态,P 会处于“失业”状态,无法执行其他任务。此时,调度器会尝试将该 P 让给其他 G,继续执行其他不受阻塞的任务。受阻塞的协程将在合适的时机恢复执行。

GOroutine 模型

2.5 M 线程的创建与销毁

M 是与系统线程(内核线程)直接关联的,每个 M 都有其系统线程上下文。当没有足够的 M 来执行任务时,Go 运行时会动态创建 M 并绑定到内核线程。然而,过度的线程创建会导致系统开销过大,因此在合适的时机,Go 运行时会对 M 进行销毁和回收。

M 的数量不会无限增长。运行时通过避免频繁地创建与销毁 M 来提升性能,如果某个 M 可以重新利用,它会被复用来执行其他 G 的任务。M 被销毁的条件包括长时间的空闲等。

3. 栈的管理

协程的栈管理是 Go runtime 的另一个核心机制。Go 协程的栈空间是动态伸缩的,初始栈大小约为 2KB,而非固定的大栈,这也是 Go 协程能够支持大量并发任务的重要原因。

3.1 动态栈扩展

当协程需要更多的栈空间时,运行时会自动扩展。为了不会影响程序的执行效率,栈扩展是分批进行的,即当程序执行到栈底时,Go runtime 会触发栈检查机制,若感知到栈空间不足,则会进行栈拷贝,将新的大栈分配给这个协程。在栈扩展的过程中,栈的拷贝是从低地址向高地址进行的,保证了栈增长的顺畅和高效。

3.2 栈收缩

与动态栈扩展相对,Go runtime 会在合适的时机进行栈压缩。当一个协程长时间未使用大部分栈空间时,Go 会自动释放不再需要的栈空间,确保内存得以有效利用。

4. 同步与通信

协程之间可以通过共享内存进行同步或者通过消息传递的方式互相通信。在 Go 语言中,推荐使用channel来进行通信,这符合 CSP(通信顺序进程)并发模型。Channel 是安全、易用的沟通协程的机制,避免了传统多线程编程中常见的数据竞争问题。

5. 总结

协程(goroutine) 作为一种轻量高效的并发模型,通过巧妙的调度器设计和动态栈管理等底层机制,能够在现代软件开发中处理高并发、高并行的任务,并且极大地简化了复杂并发问题的管理。Go 的 GPM 模型完美体现了编程语言中平衡高效和简单性的设计思路,这也是 Go 在高并发应用场景中大获成功的重要原因。

因为协程(goroutine)这种更轻量的设计,使得它在国内的流行度也越来越高,抢占了 Java很大一块市场份额。

关于我
loading