Posts 进程?线程?一文读懂!(由来+概念)
Post
Cancel

进程?线程?一文读懂!(由来+概念)

本文面向所有想要/需要了解操作系统进程和线程的人。
请暂时忘掉你从第三方获得的相关知识,本文将带你从操作系统创作者视角来看进程和线程。如果你只想看结果,你可以直接跳到后半部分。

  只讲事实存在的设计,放心,不会扯底层的代码细节。内核里一个container_of宏的实现就相当于一道高考数学压轴题了,这样的细节多得不得了,这不是我们的关注点。借用linus的话就是,如果你觉得不好用你就自己去改。那时候才是你去深究它的实现细节的时候。

  如果你没有数字电路的基础、没有了解过汇编,那么,为了让你更容易理解此文,我先告诉你一个事实: ——对于现今主流数字电路CPU而言,它只认得高、低电平(高阻态等于没接所以不纳入讨论),转换到数学视角即0和1,所有输入输出都是“1010...”这样的二进制的机器码、数据块,至于“CPU指令还是普通数据”这是芯片层设计师考虑的问题,不在我们的讨论范围内。一句话就是,对于CPU来说输入输出都是数据,指令则是约定好的预置好动作了的特定数据。

一、发展

1、 假设我们有如图单CPU硬件:

  假设,这块屏幕有8个数据输入口,我们在第一个端口手动接上高电平,其它接地,即人工输入一个0x01,屏幕显示“1”,以此类推第二个第三个……现在,我们不用自己手动操控电平,接到我们CPU芯片的引脚,用CPU间接控制它。假设有个8 bit的寄存器(设它地址为0x12345678)刚好是控制芯片这边对应的8个引脚。那么,我们要让显示屏显示“1”的话,得拉高第一个端口的电平,对应的,我们需要设置上述的8 bit寄存器中的第一位为高位即“1”(假设第一位在最右边),那么CPU只需要往0x12345678这个地址写0x01,假设该操作的机器码的助记符为mov 0x01 to 0x12345678,那么我们只需要将该代码转换成真正的二进制机器码,然后放到CPU取指令的地方(就是我们通常说的烧录固件的烧录,把机器码放到CPU能直接取的地方),那么我们显示“1”的软件就部署完成了。

2、同理,我们换上正常的一块手机屏幕。
  这里我们不关注操作电平的具体细节,我们把屏幕的操作简化抽象为一个Display (something),那么我们可以这样快速刷新图像达到动图的效果:

  • Display (图片1)
    Display (图片2)
    Display (图片3)
      …

  当我们想一边播放图像一边播放声音的时候,因为CPU操作对于人的感知来说非常快,可以切换轮流操作,而人感觉它们是同时进行的。同理,把对音频设备的操作简化抽象为Speak (something),那么我们可以让CPU这么做:

  • Display (图片1)
    Speak (声音1)
    Display (图片2)
    Speak (声音2)
    Display (图片3)
    Speak (声音3)
      …

3、然而,在实际操作中,单单要人工”安排“CPU去实现上面这个已经非常复杂复杂复杂了。何况我们还有更多更多的操作。当然的,在需要同时操作多个数据或设备的时候,我们设计一个逻辑,不管是什么操作,让CPU自己去切换而不是人工安排,这个逻辑,我们取名叫“调度算法”,你也可以叫它“分配算法”,当然,如果你不在乎名字是否跟它的功能搭调的话你取任何名字都没啥大关系。这就是操作系统(Operating System)的开始,我们渴望更方便地使用计算机。

  通常地,我们会把实现某个目的的操作写到一块去,即常为我们所说的函数,这样的有一个或多个目的(给CPU取的、包含指令和数据的)数据块,我们可以叫它“执行任务”,简称“任务(task)”,你也可以给它取其它名字。我们要让CPU自己去切换这些任务,我们把这些操作的首地址或者能指示任务操作指令在哪里的信息取出来放到一个队列里排队分配CPU时间,这个队列我们可以给它命名为“任务队列”、“task”、“mission”等的名称。

  好了,现在可以让CPU自己管理任务了。由硬件器件的缘故,CPU只能从NOR FLASH、SRAM、(配置后的)DRAM中取指令,而不能直接操作硬盘等存储器。为了给众多任务提供显示器的操作,我们特地集合了显示器操作做成一个任务来统一接受其他任务的显示器操作请求,而这个任务挂在RAM就已经占了好几百M了,还有很多任务也需要占用内存,不够用!(然而RAM价格贵)。
  其次,全部任务的数据都在这块RAM里交杂堆着,很难管理,一个任务也可以随意访问RAM中别的任务的地址,程序员一个错误,一个很可能就把别的任务给干掉了,其中最要紧的就是在同一片RAM地址运行的调度算法了,不过,我们可以设计一个内存访问逻辑,即访问前先判断该地址(是否可用?是否被标记占用了?占用者是谁?可否抢来用?)然后再决定实际的内存操作,我们给它命名为内存权限访问机制。 现在,我们写入的任务(我们暂命名为task),即CPU要执行的内容,在RAM里简化看是这样的:

  面对内存不够用的情况,我们可以写一个程序(同为任务/task),让它自动地按时间片把刚执行过的任务的运行状态保存到硬盘中,把其它任务的内容从硬盘搬到RAM中继续执行一个时间片,然后再把硬盘中运行状态的任务搬到RAM继续运行,虚拟内存的概念诞生了。既然如此,对于我们要执行的任务(executions)来说理论上可以是无限量的了,地址编号也仅仅像门派号一样的信息,我们得为我们的任务分配地址空间,具体怎么分配这些地址空间?假设CPU是32位、物理存在的RAM地址空间有4GB。负责分配CPU的任务逻辑(a task of execution to schedule CPU)将一直占用实际的RAM,将假的地址编号分配给其他任务。关于假的(fake)地址编号的分配,现在我们有两种思路:

  • 第一种,在RAM地址编号基础上无限扩增(fake)地址编号,将其动态划分给执行任务(task)。任务指令和数据在真正要搬到RAM中给CPU取的时候再把它搬到RAM中,并将该区域RAM地址标记为这个任务实际占用的RAM地址空间,这样的过程我们称之为“映射(Map)”;
  • 第二种,给每个任务(task)fake出一段CPU支持的最大的地址编号。比如32位的CPU最大支持4GB的RAM地址,我们给每个执行任务(task of executions)各分配 0 - 4GB 的地址编号,其它思路跟第一种相同。 

  显然,我们应该采用第二种。第一种无限扩增的方式,地址编号会增大,到一定量之后,我们需要用更多的比特去存储地址编号,而且我们还得给每个任务(task)分段,而第二种则避免了这个缺点。其次,每个任务的虚拟地址编号都是一样的,这更方便于我们设计管理虚拟地址编号的逻辑。

  现在用了虚拟内存这样的新机制,我们要调度我们要让CPU做的事情就不止调度execution了,还要调度用于执行该execution所需的资源。后者也是耗时的,能省则省。我们建议应用程序设计者,尽量将方便共用数据的不同逻辑或执行任务在同一个虚拟地址空间里,从而提高程序的效率/性能。
  现在对于应用程序而言,它们只有假的地址空间(编号),这一个独立的虚拟的地址空间和绑定于此的执行任务形成的整体是一个等待或正在处理/进行中的一个任务队列,而里面又有真正意义上的一个或多个执行队列即execution(s)。明显地,现在前者被命名为了“Process” - ”A procees that operate on (computer data) by means of a program“,后者被命名为了“Thread” - “A thread of a execution within a process“。

  细看我们可以知道,真正”干活“的还是这里的Thread,即跟我们之前命名的执行任务”task“一样,所以之前调度task的逻辑不需要怎么改,只是之前RAM地址空间的task变成了虚拟地址空间的Thread;Procees则拥有着Thread执行所需的environment:内存映射(memory mapping)、文件描述符、用户ID和组ID、栈等等,在把Process的内容清空后里面的execution(Thread)也不存在了,只需要去执行任务队列里把对应的Thread排队信息删了。

  简化来看,现在我们的设计看起来是这样的:

  然而,加入了虚拟内存机制有个硬伤:太慢了!为了加速这个过程,我们把内存转换、内存权限中能用硬件实现的逻辑用硬件去实现,新来的硬件部件即所谓的内存管理单元Memory Manage Unit(MMU),新增了一个耗电大户,我们无需再编写大量软件逻辑去进行“内存标记、转换、映射”,也不能软件直接地访问内存(更省心、安全)。  

二、结论

综上,关于进程和线程,我们可以总结出这样的结论:
  线程(Thread)是系统中被调度的一个执行(单位),完整的名称是”A thread of execution“,这里的”thread“类似于”A thread of mind“中的”thread“,形容在系统里它的执行是相对很细的一部分,你把任意可执行任务(甚至是空的)放到任务调度队列中,那么它就算是一个线程;
  进程(Process)是为了概括内存地址空间和其中的执行任务而命名的一个概念,进而言之,进程是内存地址空间和线程形成的整体,是一个被调度的(基本)事件,你把一个或多个可执行任务放在一个生存空间里,把包含这(些)可执行任务即线程的这个存在空间放到调度整个地址空间的调度队列中,那么它就是一个进程。原汁原味地说则是:”A procees that operate on (computer data) by means of a program is a memory address and a thread of execution. A process holds the environment in which threads can run.”

参考资料:
[1] Mastering Embedded Linux Programming 2e[M].Packt Publishing Ltd.,June 2017.305-332
[2] 《RT-Thread内核实现与应用开发实战指南—基于STM32》[M].刘火良、杨森:机械工业出版社,2018

This post is licensed under CC BY 4.0 by the author.