【译】Go 协程底层原理

951 阅读10分钟

原文链接:Goroutines Under The Hood

前言

自 2012 年 Go 1.0 发布,开发者开始认识到它的强大,并逐渐转向使用 Go 语言开发他们的项目。Go 在“现代企业”中极其受欢迎,原因有很多,其中之一就是它确实易于上手,代码整洁,对于有 C++ 基础的程序员来说更是如此。但这并不是很多开发者支持它的唯一原因,其他原因还包括它编译、执行速度快以及通过自有代码风格标准提高可维护性与可读性的方式,而最重要的则是它针对并发的处理之道。

接下来,让我们将目光投向 Go 语言真正的特别之处,即 Go 是如何处理并发的,这种处理方式可以让多线程应用飞快运行。不过在此之前,还是让我们回头看下什么是并发,什么是线程。

如果你已经对并发、并行和线程有所了解了,那么可直接跳到描述协程的部分。

并发(Concurrency) & 并行(Parallelism)

本文不会照本宣科直接复述 维基百科对并发的定义,你若是第一次接触并发,这样做恐怕只会适得其反。简单来说,并发就是一种可以让程序同时做多件事的能力。需要注意的是,这里所说的同时做多件事并不一定指的就是在 CPU 级别真正的同时处理多任务。但即便如此,并发确有其事。现在让我们来一探究竟。鉴于一些 CPU 或操作系统的内部实现方式不同,下面的描述对于每一个用例可能不是百分百正确。为了便于理解,当前我也会忽略掉一些技术细节,比如线程(我会用“任务”代替“线程”一词),后面我会解释的,但这不会对整体过程的描述产生大的差异。

单核 CPU 的并发

CPU 有单核、多核之分,简单起见,我们先解释单核 CPU 的并发机制,之后再来理解多核的就不是什么难事了。在单核 CPU 上,CPU 核(处理器)可能会让人觉得它同时在处理多个任务,而实际情况则是,在处理器上的任务是重叠执行的(overlapping),即,每个任务都有一个在处理器上运行的时间窗口,任务之间交替运行。

举例来说,我们有两个任务 A 和 B,处理器先给任务 A 分配了 5 微秒的运行时间,时间一过,即使任务 A 还没完成,处理器也会让任务 B 接管,算出它需要的执行时间,比如也是 5 微秒。最后,任务 B 完成执行后,任务 A 再次上处理器运行 5 微秒。由于时间间隔小(以微秒计),我们不会注意到任务的交替执行,看起来就像真的是在同时运行一样。实际上却是处理器在不同的时间窗口期运行不同的任务以达到并发的效果。如前所述,这就是处理器允许任务共享它的资源,以处理它们需要的计算工作的方式。内核层面的并发是由操作系统的调度器负责的,处理器依靠调度器这个程序来完成上述的所有工作。

多核 CPU 的并发与并行

译者注:单核谈并发,多核谈并行。——摘自《操作系统真象还原》

那么多核 CPU 的情况如何呢?这里要引入一个新的概念:并行。得益于多核 CPU 的多核心(多处理器)基础架构,CPU 可以真正地在同一时间运行多个任务了,即,它可以让多个任务同时运行在不同的处理器上。既然如此,你可能会问“这是否意味着我们不再需要并发了?”答案是不,并发仍有其存在的必要。并发和并行是两个完全不同的概念,多核 CPU 上也可能存在并发,这令其更加高效。现在你明白了并发在单核 CPU 上的工作原理,试想一下,同样的事情在每个 CPU 核或处理器上都会发生。原因是每个处理器/CPU 核都运行着我们前面谈到的操作系统调度器的一个实例,并发就发生在每个 CPU 核上。下面有几张图片可以更好地解释这个问题,但是不要被并发的演示迷惑了,看起来好像并发只发生在一个 CPU 核上,而这些进程在共享这个特定的 CPU 核。其实,CPU 上每一个核心都在做如下图演示的一样的工作。

编程语言在处理并发方面有很多不同的方式,前提是编程语言支持并发。不过,这不是我们本文讨论的话题,如果你想了解更多有关并发的内容,我推荐你读一下由 Abraham Silberschatz,Peter Baer Galvin 以及 Greg Gagne 合著的《操作系统概念》。这本书解释了并发相关的所有概念以及其他有趣的知识。

什么是线程?

上一节我们探讨了任务为了运行计算工作是如何共享处理器的,而事实是任务甚至都不能直接访问或使用处理器,它们借助线程来间接与处理器打交道。

我能找到的关于线程的最棒的定义是 pwnall 在 Stack Overflow 上的一个回答

一个线程是一个执行上下文,该上下文包含了 CPU 执行一个指令流需要的所有信息。

假设你正在读一本书,现在你想休息下,但是过后你仍想接着先前的进度继续阅读。那么你可以通过记下页数,行数以及该行的已读的字数做到。在这个例子中,那 3 个数字就是你读书场景下的上下文。

你有个室友,她跟你是同行,你不看那本书的时候,她可以拿来从她上次停止的地方继续阅读。此后,你再拿回来时,仍然从自己停止的地方继续下去。

线程的工作机制跟上述情景类似。CPU 给你一个错觉:它在同一时间做多个计算工作。CPU 通过在每个计算任务上花费一点时间来做到这一点,背后的原因是它有一个用于每个计算的执行环境。就像你可以和你的朋友分享一本书一样,许多任务可以分享一个CPU。

更专业点说,执行上下文(也就是线程)由 CPU 的寄存器值组成。

每个线程有它自己单独的上下文,你甚至可以把一个多线程的程序看成多个单独的程序,这些独立的程序聚集在一起组成了一个更大的程序,下图也许可以帮你更直观地理解这一点:

单线程进程一次只能运行单个任务,而多线程进程可以同时运行 N 个任务。

线程的创建和管理有不同的方式或模型,这指的是用户线程(应用级别)和内核线程(操作系统级别)之间的映射关系:一对一、多对一以及多对多。

协程:Go 的并发处理方式

开发者喜欢 Go 语言的原因之一是它易于实现且运行高效的并发处理机制,这是该语言的一个内部特性。Go 协程基于一个存在了很长时间的概念“coroutines”,本质上是将一组独立运行在用户空间的函数“coroutines”多路复用到操作系统内核空间下的一组实际线程中。这也是 Go 协程得以高效运行的原因。但是为啥这样就高效了呢?因为当一个协程阻塞了——引起线程阻塞的原因有很多,比如调了一个阻塞的系统调用(例如读取用户输入),为了位于同一内核线程的其他排在被阻塞协程后面的协程不被阻塞,运行时会自动将它们移动到另一个不同的,可运行的内核线程中。这一开始可能会有点让人难以消化,所以让我们用一个例子来说明。

  1. 首先,假定我们有 4 个协程,为了执行它们,这 4 个协程需要多路复用到内核线程中。我们只有两个内核线程,还有就是我们的处理器是单核的。

  1. 现在假定 Go 的运行时调度器如下图那样组织协程和内核线程之间的关系(出于某种原因)。协程 A 和 B 使用左边的内核线程运行,协程 B 和 C 则使用右边的。我们还假定协程将按照协程池(下图边框围绕的部分)由底向上的顺序依次运行。

  1. 协程开始运行了。唉!等下,协程 A 调了一个可阻塞的系统调用(比如 read() 语句)。现在协程 A 被阻塞了,B 也被阻塞了。

  1. 协程 C 运行完毕。

  1. 此时,Go 运行时调度器可能意识到将协程 B 移到另一个可运行的线程会是一个更好的注意,这样它就不用一直等着 A 完成了。

  1. 协程 D 现在也执行完毕。

  1. 协程 B 现在也完成了,而被协程 A 占用的左边内核线程仍然处于阻塞状态。

如果运行时调度器不重新调度被阻塞的协程到另一个可运行的内核中,那么现在协程 B 还没开始执行呢!当然了,这是一个非常简单的例子,但是你可以把它脑补到由数千个协程组成的更复杂的大型程序中,差别可见一斑。关键的是,这对开发者来说是透明的。协程的成本不高:除了堆栈内存之外,它们的开销很小,只占数千字节。

那么,堆栈的空间大小呢?幸运的是,它也被更好地管理了起来。这里引用一个 Go 的官方回答:

为了使堆栈变小,Go 的运行时使用的是大小可调的有界堆栈。分配给一个新建协程几千字节的空间,大部分情况下是够用的。适当的时候,运行时会自动调整栈的大小,这可以让很多协程存活在空间适度的内存中。CPU 开销方面平均每个函数调用大约三个低开销指令。这样一来,在相同的内存空间中创建数十万个协程也会变得切实可行。而如果协程就是线程的话,要不了多少协程就能将系统资源耗尽。

虽然 Go 通道(channels) 也用于促进协程间通信,但我并不打算在本文中讨论它。如果你感兴趣,你可以到 这儿 了解通道是如何工作的。

总结

Go 协程是语言内部实现的一部分,是基于一个叫作“coroutines”的概念,它们易于使用且性能优良。Go 运行时调度器多路复用协程到内核线程,当一个线程阻塞了,运行时就把被阻塞的协程移至另一个可运行的线程,以实现程序的高效性。Go 也提供了很多工具来支持协程,像用于协程间通信的通道。如果你开发的应用或服务比较在乎并发性,你绝对应该考虑使用 Go!