https://kaiwu.lagou.com/course/courseInfo.htm?courseId=1414

  • 内核态线程适用于计算密集型任务;
  • 用户态线程(协程)更适用于 IO 密集型任务。

在各大厂的面试中:进程、线程、协程这三个概念经常被问到,我戏称其为“面试官操作系统三板斧”。

如果你是一个有经验的开发,我相信你对这“三板斧”并不陌生,但是很多人在真正面试时却答不上来。你可以回忆下你的面试过程,是不是也发现被这几个概念扫射到了?所以,如何更好地理清这些概念,成了大家在求职前亟待解决的问题之一。

简单地扔出一些概念来让人背总是很容易的,但是这对你来说收获有限。本文将尝试从演进的角度来说明这些概念的产生,由来,以及它们要解决的根本问题是什么。

通过本文,你将获得:

  1. 什么是进程、线程、协程?
  2. 我们为什么需要了解这三个概念,它们的产生的原因是什么?
  3. 它们的应用场景分别是什么?什么时候应该用内核态线程,什么时候应该使用协程?

关于进程

什么是进程?进程是怎么由来的?又有哪些应用和发展?接下来我们从故事的最开始讲起。

进程的由来

所有的这一切都得从计算机出现早期开始讲起。大家可能都知道,最早的计算机程序是使用纸带穿孔来执行的。

视频 | 趣谈操作系统三板斧:进程、线程、协程 - 图1
视频 | 趣谈操作系统三板斧:进程、线程、协程 - 图2
图1. (上)程序的执行;(下)纸带穿孔程序

这种机器的执行流程是——

  • 第 1 步:由计算机管理员把打好控的纸袋安装到计算机上
  • 第 2 步:按下开机键,计算机就运转起来,一条语句一条语句地开始执行纸带上的指令。
  • 第 3 步:程序执行直到结束,并最终输出穿孔纸带上的结果。

当时计算机的用户(User)也就是科学家们开始排队提交自己的程序。在沟通过程中,人们发现纸带穿孔程序(paper tape punching procedure)读起来实在是有点绕口,于是一般把它简称为 Program。而运行着的 program,也就是进程,计算机管理员们约定使用 JobTaskProcess 这样更简单的、好识别的称呼。这就是为什么我们在读 Linux kernel 内核源码的时候,经常看到 job、task_struct、process 这样的单词,这些都是指进程。

话说回来,在那个时候,即使一个非简单的程序,跑个一两天都是常有的事儿。用户们提交了程序,不可能一直坐在那里等,往往是把 Program 往计算机管理员的桌子上一扔,就回去等通知拿结果。时间长了,这个“堆满纸带穿孔程序的计算机管理员的桌子”也开始有了一个简单的称呼:task_listjob_list

那么,计算机管理员的日常工作就是这样:

  1. for task in task_list {
  2. run(task); // <-- process: 进程
  3. }

而这个管理员和他的桌子,可能是世界上最早、最简单的操作系统了。这时候的进程真正独霸了整个计算机,不用考虑何时需要把计算机让给其他应用程序;进程的管理、CPU 资源的分配也都不用考虑。

不过,这样简单而又美好的时期并不能维持多久,因为他们很快就遇到了一个问题。

进程的切换

image.png

如果所有的 task 都是高速运算,那我们只需要把 task_list 里面的 task 扔到计算机里,让它执行就可以了。然而并不是。总是有那么一些程序,假设有一个 program A,执行了 10 天之后,需要 User 输入一些信息,才能继续后面的计算。那在通知 User 到机房去执行输入的这段时间里,process 就会处于一个等待状态。

这个等待状态给管理员带来了以下思考:

image.png

  • 此时这个 process (program A)独霸了整个计算机,其他要执行的 program B 肯定也在等待。
  • 如果我们将计算机停机,加载并执行后面的 program B,那当重新执行 program A 的时候,一切又要从头开始,执行了 10 天的程序还得要重新跑。
  • 而且不能保证 program B 就不会出现需要 User 进行输入的情况。

当这个棘手的问题摆在了计算机管理员面前时,人们不得不尝试考虑,是否可以将 program A 执行到 10 天时整个机器的状态保存下来呢?中间先执行 program B,下次要继续执行 A 的时候,就将这个保存起来的机器的状态恢复,继续执行 10 天后续的纸带穿孔指令。

这其实就是最早的进程切换问题。我们现在遇到的情形,只不过是当时的问题和解决思路的延伸罢了。

那么当时的管理员是如何解决问题的呢?首先,计算机管理员根据机器的特点,确认哪些信息是需要保存的:

  1. new_process = clone(SIGCHLD, 0);
  2. // clone的时候,要求子进程退出时向父进程发信号。
  3. // 其他资源子进程必须要自己重新生成。

那么,每当进程被卡住,需要停下来进行手动输入的时候,计算机管理员先按下“保存”按钮,把当前的状态信息打印到一个纸带上。保存完毕之后,他就可以选择一个其他进程来执行了。那么,对于这个保存下来的内容,我们当然应该起个名字,就叫作进程上下文(process context),而保存并且切换到下一个待执行的进程,这个动作就被称之为上下文切换

再进一步想一下切换问题,保存好当前的进程状态之后,下一个被执行的进程应该如何挑选呢?要知道,管理员的桌子上有那么多穿孔纸带,如果我们随便指定一个进程来执行,就会遇到这样一个问题:万一我们又一次挑中的进程还是缺少相应的输入,那怎么办?

因此,为了方便选择进程,我们还需要记录每个进程的状态。

  1. struct pcb {
  2. int status;
  3. };

这里的 pcb 表示 Process Control Block,主要的作用就是让我们选择是否执行这个进程。
当然,现代的操作系统还能对进程做更多的控制,所以 pcb 能做的事情就更多了。在 Linux 内核中,“pcb”、“上下文”是如下安排的。

视频 | 趣谈操作系统三板斧:进程、线程、协程 - 图5
图2. Linux 内核的 task_struct 与 pcb,以及 context 的管理

备注:task_struct 更多记录进程与外界相关的信息,比如与其他进程的父子兄弟关系、信号处理、打开的文件等等。而 thread_struct 更多是记录进程自身的信息。

至此,我们这位计算机管理员已经能够很好地解决进程阻塞的问题了。

进程的 clone

image.png

在实际开发中,你的头脑里也许常常会冒出这样一个问题:为什么新生成一个进程的时候,Unix/Linux 系操作系统都喜欢用 clone?

为了方便理解,我们再回溯到纸带穿孔机的时代,那时候计算机的计算稳定性远远比不上现在。由于每次执行任务都需要开机,而开机当然需要执行一些自我检查的指令。糟糕的是,这部分指令由于写在用户的“穿孔”程序的开头,还给用户带来了一些困扰:

image.png

  • 不同的机器,启动时的自检程序肯定是不一样的
  • 用户只关心自己的核心计算程序,并不懂这部分自检查程序怎么写,也没有兴趣

所以对于用户来说,最省力的办法就是抄一份现成的程序,然后把核心代码换成自己的。就像现实生活中,当我们要填一个超复杂的表单的时候,最好是有一个 example 让我抄一下,我把这个表单里面核心的部分换成我自己的信息就完成了。这个抄的动作,生成新进程就叫做 clone

就像抄表单一样,你可以抄不同的内容,在 clone 一个新进程的时候,你也可以选择抄不同的内容。比如 Linux 内核中:

  1. new_process = clone(SIGCHLD, 0);
  2. // clone的时候,要求子进程退出时向父进程发信号。
  3. // 其他资源子进程必须要自己重新生成。

视频 | 趣谈操作系统三板斧:进程、线程、协程 - 图8
图3. 进程 clone 示意图

通过这种方式,就可以生成一个全新的进程。那么如何填上自己的核心代码呢?这个时候, Unix/Linux 系统会使用 exec 函数。

  1. execv("/usr/bin/ls"); // <-- 执行查看程序

视频 | 趣谈操作系统三板斧:进程、线程、协程 - 图9
图4. clone 与 exec 的合作。

这样做,就解决了用户提交新程序的需求。

关于线程

直到有一天,有个用户提出了这样一个需求:

  1. 一条船上有两门炮:当一门炮在装填的时候,另外一门炮要开始瞄准和射击。

这个需求的难点在于:如果我们还是采用 clone+exec 的方法,就很容易出现两条船,每条船上只有一门炮。

如果解决呢?我这里有两种解决办法:

  • 内核态线程
  • 用户态线程

内核态线程

如果我们在 clone 生成新任务的时候,像下面这样,就在同一条船上生成了一门新的炮。

  1. 新的一门炮 = clone(共享船 共享桨 共享坐标, 0);

当然,计算机肯定是需要将“船,桨,坐标”等信息进行抽象的,比如抽象成内存、文件、信号,所以,真正的 clone 函数应该长这样:

  1. new_process = clone(CLONE_VM | /*共享内存 */
  2. CLONE_FS | /*共享文件系统信息 */
  3. CLONE_FILES | /*共享打开的文件 */
  4. CLONE_SIGHAND, /*共享信号处理 */
  5. 0);

视频 | 趣谈操作系统三板斧:进程、线程、协程 - 图10
图5. clone 生成新的线程。

上图中 3 个线程共享了代码段、数据段、打开的文件,但是每个线程还是有一些独自己的资源,比如寄存器,栈。

我们发现,这个新生成的进程的资源分为两部分。

  • 拥有的资源:操作系统会新分配 task_struct、thread_info、cpu_context、stack 给它。
  • 共享的资源:内存、文件系统、打开的文件、信号处理。

像这样的进程,与前面的进程已经有所不同了,是时候该给它一个新名字了,于是我们称之为线程,或者叫内核线程

不过,由于 Linux 在调度程序的时候,实际上采用的是如下策略:

  1. for task_struct in task_list {
  2. if (task_struct can run)
  3. run(task_struct); // <-- process: 进程
  4. }

所以,从 Linux 内核的角度来看,进程与线程在调度运行的时候是没有区别的

备注:其实大名鼎鼎的 fork() 函数就是简单地将 clone 包了一下。对 Unix/Linux 系统而言,pthread_create 创建线程,则是对 fork/clone 的进一步包装。

image.png

这里我们给线程/**内核态线程**一个定义——
操作系统的设计人员提出在进程内部添加可独立执行的单元,它们共享进程的地址空间,但又各自保存运行时所需的状态(寄存器、栈),这就是内核态线程。

注:这里我们要与 Linux 内核设计中的 kernel thread 这个概念进行区分。Linux 内核设计中的 kernel thread 指的是有一些进程永远都以内核态运行。这里不要混淆哦!

我们稍微总结一下内核态线程的优缺点:

image.png

优点
1. 当有多个处理机时,一个进程的多个线程可以同时执行
1. 内核可以看到更多的系统信息,提高系统的运行速率
缺点
1. 线程在用户态运行,而线程的调度和管理在内核实现,线程的切换速度较慢

视频 | 趣谈操作系统三板斧:进程、线程、协程 - 图13
图6. 内核态较重的上下文切换导致开销较大

那么,有没有办法可以降低一些线程的开销呢?有!答案就是用户态线程

用户态线程(协程)

image.png

首先我们理清一些概念:用户态线程纤程(Fiber)、协程(co-routine, go-routine)这些概念大体是等价的,所以在下文中,我会交替使用这 3 个等价概念。

通过前文可知,内核态线程拥有一些自己的资源,比如 task_struct、thread_info、cpu_context、stack。

但是,如果这些本来属于操作系内核的资源,在用户态空间实现,并且调度也在用户态空间来执行,那么此时的线程就称为用户态线程,或纤程,或协程

如果你问内核态线程与用户态线程的本质区别是什么?我的答案是——

image.png

  • 内核态线程中,如果有 N 个线程,那么内核就会创建 N 个 task_struct;
  • 用户态线程中,如果有 N 个线程,那么内核中还是只有 1 个 task_struct。

这里总结一下用户态线程的优缺点:

image.png

优点
1. 线程的调度不需要内核直接参与,控制简单
1. 可以在不支持线程的操作系统中实现
1. 更加轻量级,创建开销更小
缺点
1. 一个用户级线程的阻塞将会引起整个进程的阻塞,与操作系统交互的部分,比如系统调用、中断等,仍然需要内核参与才能完成
1. 用户级线程不能利用系统的多核处理,仅有一个用户级线程可以被执行

所以,我们能看到,用户态线程的功能还是受到限制。

如果只有一个内核线程,然后这个线程上创建了 N 个协程,当一个协程进行系统调用的时候,其他协程有可能会被阻塞,为了解决这个问题,又提出了新的线程模型:

视频 | 趣谈操作系统三板斧:进程、线程、协程 - 图17
图7. 用户态线程与内核态线程的 M:N 模型。

这种思路的特点就是:在内核态创建 N 个线程,当某个内核线程被 co-routine 阻塞的时候,可以把能运行的 co-routine 调度到剩下的 N-1 个线程上继续运行,这样保证了程序不会完全被阻塞。当然,这样的复杂度也更高。

此外,从实现这个角度来进行分类,协程还可以分为两类——

image.png

  • stackfull co-routine:即协程之间的切换需要使用栈来保存信息,速度稍慢,实现更容易
  • stackless co-routine:切换协程的时候,不需要使用栈来保存信息,速度更快,实现更复杂

总结

image.png

在本期内容中,我们从计算机的远古时代开始聊起,一直谈到了现代化的、各种语言提供的用户态线程,以及相应的实现。希望你在加深理解这些概念的同时,能觉得轻松有趣。

最后还是提醒下,在使用进程、线程(内核态线程、用户态线程)的时候,你要特别注意下应用场景。根据经验,一般而言:

  • 内核态线程适用于计算密集型任务;
  • 用户态线程(协程)更适用于 IO 密集型任务。

那么,不知道你对进程,线程,协程等等概念还有什么疑问呢?请在评论区留言吧。