操作系统和进程
要理解这几个名词,我想从头说起。在你将计算机通电那一刻开始,如果所有必要的硬件都工作正常,操作系统就会被引导起来。
操作系统(英语:operating system,缩写:OS)是管理计算机硬件与软件资源的计算机程序,同时也是计算机系统的内核与基石。操作系统需要处理如管理与配置内存、决定系统资源供需的优先次序、控制输入与输出设备、操作网络与管理文件系统等基本事务。操作系统也提供一个让用户与系统交互的操作界面。
操作系统 - 维基百科,自由的百科全书
我在读硕士的时候,教授操作系统的教授用了一个到目前为止我认为最为贴切的一个单词来描述操作系统:Abstraction。大多数情况下,操作系统会分为内核层(kernel)和用户层(user)。前者是真正对硬件实现封装的部分,同时它也会向用户层提供一些API用来访问硬件资源,即系统调用(system call),至于用户层是如何管理这些资源的,就取决于各自的实现了。一般来说,两个层是互相隔离的,只能通过system call。注意,这里“一般来说”很重要,因为印象中,我记得有一种OS是允许用户层访问内核空间的,或者说是临时允许,具体是哪个我没搜到。
这样看来,对于用户来说,操作系统就是对硬件的一种抽象,使得用户层可以用一些名字容易理解的函数(就是那种有时会包含汇编的C代码实现的函数)来访问硬件资源。用户层也会有不同类型的模块,这些模块可能会因为功能不同而名字不同,但本质上都算进程。这些模块,或者说这些进程一般都会实现某一种功能,用户也可以自定义程序,这就是我们大多数时候在做的事情。这里有个概念比较容易混淆,虽然大多数时候一个程序对应了一个进程,但是这并不意味着一个程序只能有一个进程。程序本身只是一个文本文件,不管它的后缀是什么,它是静态的;但是程序,即使如Python一样的解释型语言最后都会被转化为可以执行的块,也就是进程,这是一个动态的概念。但是如果你在多个终端运行你的程序,这不是多进程,这相当于多个程序对应多个进程。
当有很多不同功能的进程在操作系统里运行时,操作系统负责管理它们,这就是进程管理。操作系统需要保证当其中某个进程出现异常时(比如说长期占用I/O或不断申请内存)不会对其他进程造成连锁影响,因为这不利于操作系统的健康。为了保证这点,操作系统会让每个进程处于相互隔离的状态(分配独立的地址空间),每个进程都由自己独立的PCB(Process Control Block)、数据块和指令块,其中PCB就是进程的描述符。
我们所写的代码会被编译并作为一个进程执行,它们最终会被转化为一条一条CPU指令,然后传入CPU,由各种寄存器进行运算。CPU一般包含三种结构:控制器、运算器和寄存器,它们是一些硬件结构,通过接收信号输入(一串1/0组成的信号)进行位运算并输出结果。寄存器根据功能不同又分为几种类型,其中一种叫做程序计数器(program counter)简称PC。PC寄存器中的值永远代表了下一条将要执行的指令,当一条指令执行完,CPU会读取PC的值,然后根据解析的结果做出相应的决策,最后PC会自加1,让自己的值指向下一条指令。程序(本质上是指令)也可以主动修改PC的值,来实现跳转,最直观的就是C语言里的goto语句,循环语句、条件语句也是造成PC值修改的主要原因。如果将这些语句解析成指令,其中必然有一条是jmp [Addr]
指令,而这条指令其实就是修改了PC的值。如果想知道CPU指令究竟是如何被解析执行的,这些指令又是如何相互组合成为一个功能块的可以参考我之前用java写的一个项目:CISC Computer Simulator,我们用程序模拟了一个虚拟机,包含内存、ROM、Cache和CPU(包含14个寄存器),并用它们来模拟大约三十几个CPU指令的执行方式(其中就包含跳转指令),最后再用这些指令编写了两个小程序,一个是找20个数中的最大值,一个是搜索字符串,两个程序都可以在虚拟机里运行。
这里插一句题外话,这部分关于指令解析执行的内容并不是不推荐使用goto语句的原因。不推荐使用goto语句的原因主要是因为CPU的分支预测器(Branch Predictor), 这块我也不是很懂,我只知道BP在面临条件跳转的时候会预计算某个分支的指令,如果这部分代码需要执行,那CPU的性能就得到了提高;如果后来发现这部分指令不需要执行,至少也维持了原先性能。从这点看,“不适用goto"语句其实是个伪命题,因为goto语句对CPU运算可能造成的损失至少是和循环或者条件语句是一样的。写到这里,我忽然想到有时候系统代码里在写if语句时,会在条件前面加个likely()
或者unlikely()
,当时是说这样会有助于编译器识别更可能被执行的语句,我不知道两个方法和分支预测有没有关系?
(上一段的内容是我的一点想法,有可能是完全错误的。请谨慎阅读,如果日后我找到了确定的答案,会回来补充的。)
回到进程上来,假设计算机只有一个CPU,或者说这个CPU是单核的, 那么对于所有进程来说它们需要共享一个CPU,而CPU一次只能运行一个进程。如果我们让进程按照顺序依次执行每个进程,这显然是不合理的,万一其中一个进程进入死循环了呢?按照进程优先级执行?让最短的程序先执行?最终操作系统选用了一种叫带优先级的时间片轮询的算法,优先级就是给每个进程设置一个数字代表优先级,数字越大优先级越高;在进程执行时,CPU会进行时长记录,每个进程最多只能执行多少时间,时间一到CPU就会主动切换到下一个进程。同时操作系统也会提供系统调用,允许进程自行调整它或者它的子进程的状态,比如说,如果一个进程发送了一个I/O请求,进入等待状态,而当前的时间片还没结束,进程就可以主动挂起自己,让CPU调度下一个进程。
这里有一个问题就是CPU是如何进行进程切换的。答案是,在切换前,CPU会把所有的寄存器的内容,包括PC的值都保存到当前进程的PCB里;然后再从下一个进程的PCB里读取所有寄存器的内容,如果下个进程是个新的进程,PC值就只想这个进程的入口地址,如果下个进程是之前切换过的,那么它的PCB里保存的就是当时切换前的状态,把这些值读入CPU后,它就可以接着执行了。这些被保存或者被读取的用来在CPU中执行的内容,被称为这个进程的上下文(context), 总结起来就是读取进程A上下文->执行进程A->时间到,保存进程A上下文->读取进程B上下文->执行进程B->...
,至于进程ABCD...的顺序由调度算法决定。虽然这个步骤看起来很复杂,但事实上CPU的执行速度非常快,快到人类根本感觉不出来,这也是为什么你在用电脑的时候会觉得所有的程序都是在一起运行的。但在CPU看来,它们是一个一个执行的。
理论上操作系统会按照这个序列一直执行到所有进程结束,而事实上这是不可能发生的,因为有init进程的存在,init进程会在操作系统boot后创建,它一直都在。操作系统的进程是以init进程为root的树形结构,进程可以不断产生新的进程,也会有进程被销毁,这些可以通过system call实现。虽然进程是隔离的,但是进程之间却是需要通信的或者说是数据传递的。有时候进程会需要同时访问一个资源,比如说打印机,此时就会产生资源竞争的问题。
前一个问题很好解决,进程通信目前有很多方式:管道、信号、消息队列、共享内存、套接字等等,操作系统都会提供相应的接口函数。其中套接字不仅可以进行本地通信,还可以实现与另一台机器上的进程进行通信,这就是协议的底层,再套接字的基础上,有点进程会再细化出各种通信协议,比如说HTTP服务端,它本质上是运行在某个主机上的进程,一般通过侦听80端口来实现和HTTP客户端的通信,而这些HTTP客户端本质上也是某个主机的一个进程。
第二个问题则要引入锁的概念,锁允许一个进程独占某个潜在共享资源,多数时候是一段内存,这段内存可以是一个列表或者一个变量。使用锁的时候要注意尽量小的包含代码块,因为一旦某部分资源(对应的是一段代码)被某个进程上了锁,不管这个进程在不在CPU中执行,只要它没有释放锁,其他进程都不能访问这部分资源。锁的类型有很多,设计的不好也会出现问题,比如死锁,这里就不讲开来说了。私以为对于编程者来说,了解这些固然好,如果不了解,至少要知道写多进程代码时,要考虑到共享资源的问题。
我之前参照GO语言的线程实现方式用C写了一个轻量级的线程库,注意是线程库:Lightweight-Thread-Library。它实现了一个用户层的线程库,支持线程的创建、跳转、加入和销毁,线程调度,使用通道或者组进行线程通信。并成功移植到另一个操作系统中遵循操作系统的进程调度。这个库没有实现锁的机制。
最后还有一个小问题,如果CPU是多核的呢?情况就更复杂了,因为除了上述的操作和顾虑,多个核心之间也要通信,因为有可能不同核心上的进程要访问同一块内存,那么当其中一个进程给这段内存上锁之前,它可能需要广播一条消息,用来确保所有其他核心上的进程都不在访问这段内存。这就涉及到多核架构下的一个重要课题:一致性(consistence),同样的,太复杂了,这里就不说了。
关于进程有几篇推荐的文章:操作系统之进程的几种状态、进程管理的总结 、操作系统之进程管理、Linux进程间通信的几种方式总结。
进程和线程
首先分享一个关于进程和线程的比喻,很有趣:进程与线程的一个简单解释。
某种程度上来说,操作系统和进程的关系与进程和线程的关系有点类似。还记得上述提到的CPU的时间片段吗?这在早期计算机能力不是很强的时候,执行起来完全没问题,进程体系完美完成了它提高系统执行效率的任务。但是随着计算机的发展,多核体系的出现,人们发现原本的进程体系过于累赘。因为计算机的能力提升了,那么同样的条件下,现在可以新建的进程自然也增加了,那么操作系统进行内存管理的开销也随之增加了,这些开销包括进程的新建、切换和销毁。那么要进一步提高计算的效率,减少这些开销是一个研究的方向。
我琢磨着,当时并没有一个完美的可以用来替代原本的进程管理体系的解决方案,能做的只是诸多尝试,其中一种就是把一部分内容从进程中分割出来,这部分就是后来的线程。
那么是哪些东西从进程中分割出来了?在原本的体系中,进程就是CPU执行的基本单位,它有它自己的独立于其他进程的地址空间、拥有完成某项功能的能力、也可以与其他进程通信。在新的体系中,原本的进程只保留了其地址空间,而线程则脱离出来,继承了它的执行能力和通信能力,取代进程成为了CPU可以调度的基本单位,而原本的进程除了拥有地址空间,还负责属于它的那些线程的管理。
那么从此处开始,之后提到的进程都是新体系中的进程,之前的进程会被成为旧版进程。我在网上看到一句话叫linux不区分进程和线程
我以为应该是不区分线程和旧版进程。接下来的问题是,这样做的好处是什么?提高系统效率是肯定的,我从网上看到一个描述叫使得并发的颗粒度更细, 但是具体表现在哪些方面呢?
线程是由进程管理的,使用的都是进程已有的资源,相对于旧版的进程,线程拥有更小的控制块和数据块,这使得线程更轻盈,管理起来也更方便。
还记得上文中关于旧版进程切换时的过程吗?在这里,因为都是使用都是进程的资源,同一个进程下,如果线程切换,不会引起进程的切换;如果切换的线程属于另一个进程,才会引起进程切换。同时,因为线程更轻盈,切换起来也更快。
一个操作系统至少拥有一个进程,一个进程至少拥有一个线程。换句话说,一个进程可以有很多个线程,这些线程共享这个进程的资源。
简而言之,线程更小、更轻盈,提高了系统的并发行,还节省了系统的开销。
线程的实现
关于线程的实现,我找到了一片文档:线程的3种实现方式--内核级线程, 用户级线程和混合型线程,个人很推荐。我上文中所提的轻量级线程库就是用户级线程,在CompositeOS下测试的时候,它工作在用户层,程序初始话的时候会向内核申请一大段内存,之后会向创建的线程分配这些内存;同时也支持内核级线程的调度。
以上就是我对操作系统、进程、线程的理解。
协程
协程是线程的进一步剥离,相对于线程,协程进一步抽出了线程切换,它作用的对象也进一步缩小。线程至少也有一个完整的入口、代码、出口的流程,而协程可能只有一个代码块。而线程负责调度这些代码块使得这些代码块协同工作,可能这就是协程名字的由来。回想一下三者的英文名:process, thread, coroutine,感受一下。
关于协程的资料不多,但是因为Python 3中有协程的实现,所以略作介绍,它的维基地址:协程。
值得一提的是,虽然三者看似是个高中低三档的模式,但是实际编程的话,要根据代码的特性选择合适的方式,比如说项目是不是CPU密集运算的?或者是I/O密集运算?这些都是需要考虑的,后面会说。