• 进程、线程(系统级)
  • goroutine(用户级)
  • 调度器

前导内容:进程与线程

进程:描述的就是程序的执行过程,是运行着的程序的代表。 换句话说,一个进程其实就是某个程序运行时的一个产物。

线程:一个进程至少会包含一个线程。如果一个进程只包含了一个线程,那么它里面的所有代码都只会被串行的执行。每个进程的第一个线程都会随着该进程的启动而被创建,即为其所属进程的主线程。

  • 相对应的,如果一个进程中包含了多个线程,那么其中的代码就可以被并发的执行。除了进程的第一个线程外,其他的线程都是由进程中已存在的线程创建出来的(即,由代码显式的创建和销毁)。

Note: 在 Go 程序中,Go 语言的运行时 (runtime) 系统会帮助我们自动地创建和销毁 系统级的线程。这里的系统级线程指的就是我们刚刚说过的操作系统提供的线程。

用户级线程:指架设在系统级线程之上的,由用户(或者说我们编写的程序)完全控制的代码执行流程。用户级线程的创建、销毁、调度、状态变更以及其中代码和数据都完全需要我们的程序自己去实现和处理。

  • 优势:创建、销毁、调度不用通过操作系统去做,所以速度快,灵活
  • 劣势:复杂,我们必须全权负责与用户级线程有关的所有具体实现,而且还要实现与操作系统的对接工作

Go 语言不但有着独特的并发编程模型,以及用户级线程 goroutine,还拥有强大的用于调度 goroutine、对接系统级线程的调度器。

调度器

这个调度器是 Go 语言运行时系统的重要组成部分,它主要负责统筹调配 Go 并发编程模型中的三个主要元素,即:G (goroutine 的缩写)、P (processor 的缩写) 和 M (machine 的缩写)。

其中,

  • M 指代的就是系统级线程
  • P 指的是一种可以承载若干个 G, 而且能够使这些 G 适时地与 M 进行对接,并得到真正运行的中介。

从宏观上说,G 和 M 由于 P 的存在可以呈现出多对多的关系。当一个正在与某个 M 对接并运行着的 G,需要因某个事件 (比如等待 I/O 或 🔒 的解除) 而暂停运行的时候,调度器总会及时的发现,并把这个 G 与那个 M 分离开,以释放计算资源供那些等待运行的 G 使用。

而当一个 G 需要恢复运行的时候,调度器又会尽快地为它寻找空闲的计算资源(包括 M)并安排运行。另外,当 M 不够用时,调度器会帮我们向操作系统申请新的系统级线程,而当某个 M 已无用时,调度器又会负责把它及时的销毁掉。

正因为调度器帮助我梦做了很多事,所以我们的 Go 程序才总是能高效的利用操作系统和计算机资源。程序中的所有 goroutine 也都会被充分地调度,其中的代码也都会被并发的运行,即使这样的 goroutine 有数以十万计,也仍然可以如此。

M、P、G 之间的关系(简化版)

一个问题

Q:什么是主 goroutine, 它与我们启用的其他 goroutine 有什么不同?

与一个进程总会有一个主线程类似,每一个独立的 Go 程序在运行时也总会有一个主 goroutine。这个主 goroutine 会在 Go 程序的运行准备工作完成后被自动的启用,并不需要我们做任何手动的操作。

一般,每条 go 语句都会携带一个函数调用,这个被调用的函数常常被称为 go 函数。 而主 goroutine 的 go 函数就是那个作为程序入口的 main 函数。

Note: 一定要注意,go 函数 真正被执行的时间,总会与其所属的 go 语句 被执行的时间不同。当程序执行到一条 go 语句的时候,Go 语言的运行时系统会先试图从某个存放空闲的 G 的队列中获取一个 G (也就是 goroutine),它只有在找不到空闲 G 的情况下才会去创建一个新的 G 。

这也是为什么会总说:启用 一个 goroutine , 而不是 创建 一个 goroutine 的原因。已经存在的 goroutine 总是会被优先复用。

然而,创建 G 的成本也是非常低的。创建一个 G 并不会像创建一个进程或者一个系统级线程那样,必须通过操作系统的系统调用来完成,在 Go 语言的运行时系统内部就可以完全做到了,更何况一个 G 仅相当于为需要并发执行代码片段服务的上下文环境而已。

在拿到一个空闲的 G 之后,Go 语言运行时系统会用这个 G 去包装当前的那个 go 函数(或者说该函数中的那些代码),然后再把这个 G 追加到某个存放可运行的 G 的队列中。

这类队列中的 G 总是会按照先入先出的顺序,很快地由运行时系统内部的调度器安排运行。虽然这回很快,但是由于上面所说的那些准备工作还是可不避免的,所有耗时还是存在的。

因此,go 函数 的执行时间总是会明显滞后于它所属的 go语句 的执行时间。当然了,这里所说的 明显滞后 是对于计算机的 CPU 时钟和 Go 程序来说的。我们在大多数时候都不会有明显的感觉。

Note: 请记住,只要 go 语句本身执行完毕,Go 程序完全不会等待 go 函数的执行,它会立刻去执行后面的语句。这就是所谓的异步并发地执行。

Note: 还需要注意一个与主 goroutine 有关的重要特性,即:一旦主 goroutine 中的代码(也就是 main 函数中的那些代码) 执行完毕,当前的 Go 程序就会结束运行。

如此一来,如果在 Go 程序结束的那一刻,还有 goroutine 未得到运行机会,那么它们就真的没有机会运行了,它们中的代码也就不会执行了。

严谨地讲,Go 语言并不会去保证这些 goroutine 会以怎样的顺序运行。由于主 goroutine 会与我们手动启用的其他 goroutine 一起接受调度,又因为调度器很可能会在 goroutine 中的代码只执行力一部分的时候暂停,以期所有的 goroutine 有更公平的运行机会。

所以哪个 goroutine 先执行完、哪个 goroutine 后执行完往往是不可预知的,除非我们使用了某种 Go 语言提供的方式进行了人为的干预。

扩展知识

以三个问题来展开

  • Q1:用什么手段可以对 goroutine 的启用数量加以限制 ?

  • Q2:怎样才能让主 goroutine 等待其他 goroutine ?

  • Q3:怎样让我们启用的多个 goroutine 按照既定的顺序执行 ?

问题的分析和解答放在下一篇 文章 中.

See Also

Thanks to the authors 🙂