进一步谈进程、线程、调度以及Goroutine的调度

写在前面

这次春节请了比较长的假,主要想多陪陪父母,同时关注关注下一代的成长——毕竟到了反哺的年纪😋。不过话虽这么讲,其实享受的还是自己:享受长辈们的关照、美食,享受小辈们的陪伴、吹捧。当前自己能做的事情还是太少太少了,不过也没什么办法,“成长如飞高,是个没有捷径的过程”,想成大事的话急不来,必须要一步一步地前进。

之前的文章《Linux系统调度原理浅析》写了写自己对进程、线程、时间片、多线程模型的理解,后来就一直规划着写写 “Golang 的调度”。趁着春节后的这几天假期抽空看了几篇文章和一些源码,在对比分析了几个知识点后却发现最终还是牵涉到 Linux 的调度原理这里来;换句话说,理解 Golang 的调度原理,本质是理解 Linux 系统调度原理。

适用人群

入门——初级√——中级——高级;本文适合初级及以上。

到底谁被调度了?为什么调度它?

我在《Linux系统调度原理浅析》里把 CPU(单核)与内存比喻成为一个四方小木桌:四方小木桌是一种通用的工具,我们可以在上面完成许许多多的事情(比如吃饭、下象棋、打牌等),就像服务器上的 CPU 与内存,我们可以在上面运行许多进程。但是文章里一直在避讳多核 CPU 的事情——因为讲不通——当时我认为是比喻的局限性导致的,因此没有深究。现在来看,只是当时没有想清楚而已……

再谈进程——为什么出现进程的概念?

如果 CPU 只处理一件事情——比如根据信号控制电机转速——就不需要有进程的概念了;这种情况下,CPU 不是在根据信号控制电机转速,就是等着信号从而处理信号控制电机转速。如果四方小木桌只处理一件事情——比如吃饭,就不需要折腾小木桌上的摆设了:小木桌不是在用来吃饭,就是等着被用来吃饭,盘子、碗一直放在桌面上就好了。

基于绿色环保的理念,不能让 CPU 闲着!也不能让小木桌闲着!无论是 CPU 还是小木桌,等着做某件事情的时候都可以做其他的事情,于是进程出现了

按照科班里的说法:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配调度的基本单位,是操作系统结构的基础。可以简单认为:

 进程 = 数据集合 + 运行逻辑

其中数据集合对应到内存中进程的堆、栈,运行逻辑对应到 CPU 中运行的逻辑。

再谈时间片——有进程就有进程的调度

进程出现后,就有进程的调度问题。比如同时有进程 A、B、C,到底让哪个进程先运行,哪个进程后运行呢?

有了时间片的概念以后,就可以给每个进程分配一定时间(比如 50ms)的 CPU 使用权,每个进程可以轮流使用 CPU 处理事情。当然,为了更为合理地利用 CPU 资源,可以根据优先级给进程分配不同长度的时间片。

异步编程——在同一个程序里“同时”做多件事

因为早期的 CPU 只有一个核,这一小节就只考虑一个核的情况

假如只有一个 CPU 核,如果一个应用想要“同时”做多件事情,由应用编写者决策① 要么顺序执行代码逻辑,② 要么另外创建一个子进程“异步”执行代码逻辑,对于异步的情况,其实所有的逻辑还是一个一个地排序执行的(只有一个核嘛)。比如要计算10个斐波那契序列,在一个 CPU 核的情况下,顺序依次计算和多个子进程异步计算,最终花费的时间是相当的,反而多个子进程异步计算因为进程切换导致时间的消耗更大,理论上更耗时。

在只有一个 CPU 核的情况下,虽然异步计算的编程方式会增加进程切换的时间消耗,但是这种编程模型可以大大降低编码的难度(解放程序员的生产力),因此具有非常大的魅力。试想,我们开发一个图形界面的浏览器时,需要同时兼顾获取远程数据、本地图形界面渲染、响应用户点击等多个事件。假如 ① 采用同步编程的方式,需要手动编码专门对三个事件进行时时轮询检查,也就是用人脑来维持三个事件之间的触发秩序;而如果 ② 采用异步编程的方式,则只需要写各个事件的代码逻辑,由操作系统或框架维持事件的触发秩序即可。

上面讨论的都是基于只有一个 CPU 核的情况,如果有多个 CPU 核,情况会改变吗?继续往后看。

异步编程(二)——内核态与用户态

内核态和用户态与编程模式没有太大的直接关系,这两个概念的出现是为了减少有限资源的访问和使用冲突,从而对不同的操作赋予不同的执行等级。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。

在异步编程这里提内核态与用户态的概念,是因为异步编程既可以在内核态实现,也可以在用户态实现。如果 ① 顺着内核态实现的思路,可以推演出内核线程存在的必要性,而内核线程又是轻量级进程(LWP,用户可操作的内核态线程)的实现基础;而如果 ② 顺着用户态实现的思路,则可以推演出事件驱动、非阻塞式 I/O 的模型,这又是 NodeJs 及类似框架实现的基础。

内核线程与轻量级进程(LWP)

前面提到,进程 = 数据集合 + 运行逻辑。如果不对进程进行特殊抽象处理,采用子进程的方式进行异步编程会导致冗余的数据集合,占用大量的内存空间同时消耗大量的 CPU 时间初始化这些内存空间。异步编程大多数期望的是不同的运行逻辑处理同一份数据集合,那么为什么还要创建那么多数据集合呢?这时候轻量级进程(LWP)出现了;可以认为它是一种侧重运行逻辑的进程(包含的数据非常少),也可以认为它是一种运行在内核态、但用户可操作的线程

需要说明的是,轻量级进程需要内核的支持,这种支持上的抽象便是内核线程。简单讲,内核线程是内核的分身,一个分身可以处理一件特定事情。因为内核线程唯一使用的资源就是内核栈和上下文切换时保存寄存器的空间,因此它的使用是廉价的

不过,在内核堆栈有限的情况下,内核线程是有数量限制的,而且后面会发现内核线程并没有想象中的那么廉价(有更廉价的实现方式,比如 golang 中的 goroutine)。

单核CPU与多核CPU

数据集合运行逻辑拆分开,即从操作系统(内核态)抽象出轻量级进程(侧重运行逻辑,即线程)的概念,为多核 CPU 的出现及使用奠定了基础。

如果没有轻量级进程(线程)的出现,要充分利用多核 CPU 就必须为每个创建一个进程,这就势必会造成大量的资源消耗(冗余的数据集合)。有了线程的出现,把数据集合保存在一块内存里,多个线程在多个 CPU 核上对同一块内存(同一个数据集合)进行处理,这就大大节省了内存资源(当然也会引入竞争问题,这也是各种锁出现的原因)。

再谈调度

我们可以从很多地方看到线程是处理器调度和分派的基本单位类似的说法,那么想这么一个问题:有了线程的概念后,内核在分配时间片的时候,把时间片分给了进程还是线程呢?

其实这个问题是存在误导的,正确的问题描述应该是“内核把时间片分给了进程还是轻量级进程”,答案显然是后者——轻量级进程(内核态线程)。因此可以知道,内核调度的对象也是轻量级进程

轻量级进程 = 内核态线程 ≈ 大家所谓的线程
#(其实应该区分一下内核态线程和用户态线程或协程)

进一步推导可以知道:如果一个应用创建了大量的内核态线程,一定程度会干扰内核调度从而影响其他应用的运行,因此需要限制每个应用创建的内核态线程的数量。再考虑到不同内核态线程切换的时间消耗,就有必要考虑一种更为轻量级的用户态线程(或协程)——比如 Golang 中的 goroutine——从而绕开内核态线程的限制。

Goroutine——本质是对CPU资源的进一步压榨

The Go scheduler 这边文章对 Go 的调度介绍的很详细了,并衍生出一些很好的中文博客。因此我这里就不对 Go 的调度进行展开了,只就其中一些点进行简单的阐述。

如果时间片用不完会触发系统调度

当内核把 50ms 分给内核态线程,而运行在这个线程上的业务逻辑只用了 20ms 就结束了,这时会触发系统调度(发生内核态线程切换)。

能不能把内核分配的 50ms 全部用完,减少线程切换的频次从而减少切换造成的时间消耗呢?

Goroutine调度模型

(图片摘自 The Go scheduler

Go的调度器内部有三个重要的结构:M,P,S。其中:

Goroutine调度实现

(图片摘自 The Go scheduler

Goroutine 的调度其实就是围绕一个问题展开的:如何通过 P 把无限多的 G 均衡地分配到有限的 M 上执行

主要考虑下面几种情况:

  1. 当某个 M 陷入阻塞时,如何处理?
  2. 当阻塞的 M 恢复正常时,如何处理?
  3. 各个 P 上的 G 分配不均时如何处理?
  4. 其他。

具体的细节大家可以参考 The Go scheduler ,如果英文不太好,可以参考《golang核武器goroutine调度原理、channel详解》相关部分的内容,或自行查找其他博文;理解了本文的内容后,Go 调度器相关的文章理解起来应该都会比较简单。

小结

本文在《 Linux系统调度原理浅析》的基础上进一步分析了进程、轻量级进程(线程)、时间片、调度等概念,给出了 进程 = 数据集合 + 运行逻辑轻量级进程 = 内核态线程 ≈ 大家所谓的线程 两个简易公式,并在最后引出了 Go 语言中 Goroutine 的概念及其调度相关的问题,希望能给大家一些帮助。

节后的几天假期马上就要结束了,没想到临行前天上飘起了小雪,老话讲“瑞雪兆丰年”,希望自己未来的这一年里一切都顺利吧。

参考