如果你还没有读过第一篇随笔,请点击这里→操作系统随笔(一)
2 进程和线程
在操作系统中,最核心的概念是进程
,这是对正在运行程序的一个抽象。操作系统的其他内容都是围绕进程的概念来展开的。
2.1 进程
在只有一个用户的PC机开机的时候,实际上会秘密启动很多进程
。例如,启动一个进程用来等待进入的电子邮件;或者启动另一个防病毒进程周期性地检查是否有病毒库更新。或者更好笑的是,一开机就是垃圾捆绑软件,什么2345,什么网页游戏,这些都是进程。这么多进程的活动都是需要管理的,于是有一个支持多进程的多道程序系统
在这里显得就很有用了。
在任何多道程序设计系统中,CPU能够很快地切换进程,这个很快是几百毫秒哦。这也就让人产生一种并行的错觉,在一秒钟内怎么开了这么多进程?同时开的吗?不是,实际上在一瞬间只能有一个进程让CPU服务,只是进程切换地太快了,这就是伪并行
。这和真正意义上的并行是有区别的,这也导致了此情形可以用来作为判别是否为多处理器系统
的指标。
2.1.1 进程模型
在进程模型中,计算机上所有可运行的软件,通常也包括操作系统,被组织成若干顺序进程,简称进程(process)
,进程是程序的一次执行过程。
每个进程都拥有自己的虚拟CPU,当然,实际上真正的CPU在各进程之间来回切换。我们在之前的1.1.2 中时间复用技术
曾经提到过,当一个资源在时间上复用时,不同的程序或用户轮流使用它。实际上对于CPU来说也是如此,在时间上进行复用的时候,不同的进程轮流使用它。这种快速地切换是需要特定的设计的,我们称为多道程序设计
。
如下图,在一段时间内,CPU为多个进程服务,但是观察c图,实际上在某个瞬间CPU只服务一个进程。
当然在上述的思考中,我们仅仅讨论的是单核CPU
,而不是多核。如果是多核CPU,根据我们之前所说,多核CPU可以看成一个大CPU里面装了多个小的CPU;甚至于有的电脑还不止一个CPU,对于一些并行计算机,多处理器的情况也是很常见的。
对于大多数进程来说并不受CPU多道程序设计或其他进程相对速度的影响,因为每个进程占用所需CPU的时间是不同的,所以我们无法确定在快速切换进程的过程中,需要给每个进程多久的处理时间处理完才切换。这时候就迫切的需要干一件事,既然我无法确定一个进程需要多久的CPU,那我干脆每个进程占有CPU的时间都相同,但是在对一个进程处理未完的情况下,我需要有一种物件
能够保存其未处理完的状态,就像别人玩单机游戏玩到一半去喝水一样。在后面的小节中,我们会给出这个物件
。
2.1.2 进程的创建
有4种主要事件会导致进程的创建:
- 系统初始化
- 正在运行的程序执行了创建进程的系统调用
- 用户请求创建一个新进程
- 一个批处理作业的初始化
启动操作系统时,通常会创建若干个进程,有些事可以同用户交互并且替他们工作的前台进程
,其余的为后台进程
。如果想要查看进程,在windows操作系统中可以使用任务管理器,在linux系统中可以用ps指令。
在像windows和ubuntu这样的交互式系统中,点击某个图标都可以启动一个程序,启动的时候就相当于开启了一个新的进程。
在一个进程开始的时候,可以不打开窗口,也可以打开一个或多个窗口,用户可以用鼠标和键盘在窗口内与进程交互,比如打开QQ的时候和别人用键盘打字聊天。
还有一种情况是在大型的批处理系统中,在操作系统认为有资源可以运行另一个作业时,它创建一个新的进程,并运行其输入队列中的下一个作业。
我们知道系统调用的作用之一是控制进程。在Unix系统中,只有一个系统调用可以用来创建进程,即fork。而在Windows中则是用Win32函数调用CreateProcess来负责进程的创建和程序装入进程的过程。除了CreateProcess,Win32还有大约100个其他的函数用于进程的管理。
2.1.3 进程的终止
有4种主要事件会导致进程的终止:
- 正常退出
- 出错退出
- 严重错误
- 被其他进程杀死
正常退出就没什么好说了,Unix用的是exit,Windows调用的是ExitProcess。
出错退出一般还好说,如果用户在Linux键入命令cc foo.c要编译文件foo.c,但是该文件不存在,那么编译器就会退出。
如果是严重错误,比如分母为0,数组越界,空指针异常这类错误,那么进程会收到信号然后中断。
最后一种在linux很常见的就是kill命令
,利用kill 进程号
可以杀死一个进程,而在Win32用的则是TerminateProcess函数。
2.1.4 进程的层次结构
在前面,我们曾经提到父子进程
这个名词,那么什么是父子进程呢?
在Unix中,通过fork函数创建的新进程是原进程的子进程,而调用fork函数的进程是fork函数创建出来的新进程的父进程。也就是说,通过fork函数创建的新进程与原进程是父子关系,fork就相当于一个凭证,有fork,就有父子关系。
但是这也有一个问题,我们学过java的都知道,继承的父类和子类共享属性,那么对应到这里的父子进程是否也有共享资源的说法呢?
事实是,父进程和子进程的共享方式采用的是
写时复制
,即两个进程在读资源的时候的确是共享,但是在写资源的时候,写资源的那个进程先把资源拷贝一份然后进行操作,操作完然后在覆盖到原来的资源上,在这个过程中我们可以发现,可写的内存时不可以被共享的。
经过上面的说明,我们可以大概知道这么个事,子进程是父进程创建出来的。父进程可以有多个子进程,但是子进程只可以有一个父进程。在Unix中,父进程和所有子进程组成了一个进程组,当一个信号传入进程组,进程组的每个进程成员皆可以捕获该信号,并且采取相应的动作。
但是在Windows则没有这些说法,所有的进程地位都是相同的。
2.1.5 进程的状态
进程之间时常要相互作用,如Linux命令:cat chapter1 | grep tree
,这个命令启动了两个进程,一个是cat,它将chapter文件进行输出;一个是grep,它在cat输出的文件中去搜索含有tree的那些单词。
从这个过程我们可以发现一件事,如果cat进程还没好,grep进程就无法运行。当一个进程在逻辑上不能继续运行时,他就会被阻塞
。
经过上面的叙述,我们引入最简单的三种状态:
状态 | 说明 |
---|---|
运行态 | 该时刻进程实际占用CPU |
就绪态 | 可运行,但是因为其他进程正在运行而暂时停止 |
阻塞态 | 除非某种外部事件发生,否则进程不能运行 |
对于就绪态来说,很多人可能会和阻塞态搞混;实际上,就绪态是万事俱备只欠CPU,即资源都准备好了但是没有CPU给它用,而阻塞态则像我们上面引入的例子,因为某个事情还没做好而导致其处于一个等待(阻塞)状态,这也是为什么有时候阻塞态被称为等待态
的原因。
特别典型的例子是C++中我们可以使用
system("pause");
来让该进程处于阻塞状态。
转换上图的2和3是由进程调度程序引起的,进程调度程序是操作系统的一部分。实际上进程调度也被叫做低级调度
,当系统认为一个运行进程占用处理器太久了,他就会让其他进程去占用CPU,此时发生转换2;当系统已经让所有的程序都占用过CPU了,公平了,那么这个时候就会“重新洗牌”,第一个进程再次占有CPU,此时发生转换3;当进程等待的一个外部事件发生时,则发生转换4,当CPU此时空闲,则可以发生转换3,该进程立刻运行,否则该进程将处于就绪态,等待CPU空闲。
2.1.6 进程的实现
为了实现进程模型,操作系统维护着一张表格,即进程表
。每个进程都占有一条元组
(数据库的说法),每条元组即PCB(进程控制块)
,该元组中包含了进程状态的重要信息,包括程序计数器、堆栈指针、内存分配情况、所打开文件的状态、账号和调度信息以及其他在进程由运行态转换为就绪态或阻塞态时必须保存的信息,从而保证该进程随后能够再次启动。
一个进程在执行过程中可能被中断数千次,但由于PCB的存在,即使中断,也可以返回到发生中断前完全相同的状态。
2.2 线程
在很久以前还没有引入进程
之前,系统中的各个程序只能串行执行。比如你想要边听歌边开QQ,这是不可能做到的,只能先做一件事再做一件事。
后来引入进程后,系统中的各个程序可以并发执行。也就是说,可以同时听歌和开QQ。但是,即使引入了进程,也不能在QQ中同时视频聊天和传输文件。这是因为操作系统每一次执行都是按照进程为单位来执行的。
从上面的例子来看,进程是程序的一次执行。但是这些功能显然不可能是由一个程序顺序处理就能实现的。
有的进程可能需要“同时做很多事”,而传统的进程只能串行地执行一系列程序。为此,引入了线程
来提高并发度。
在传统中,进程是程序执行流的最小单位,也就是说,CPU每次执行任务,最少执行一个进程。而后在现在,CPU每次执行任务,最少执行一个线程,线程是进程的子集。也就是说,引入线程后,线程成为了程序执行流的最小单位。
综上所述,我们可以把线程理解为“轻量级进程”。线程
是一个基本的CPU执行单元
,也是程序执行流的最小单位
。引入线程之后,不仅是进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务(如QQ视频、文字聊天、传文件)。引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的)。
2.2.1 进程的使用
如上所说,为了追求多功能的同时运行,轻量级进程显得尤为重要,线程比进程更轻量级,它们比进程更容易创建,也更容易撤销。
举一个例子说明为什么线程更容易创建,也更容易撤销。假如我们用电脑写一本书,通常的做法是创建一个doc文件直接写,这样的话如果中间要查询某个东西非常方便,你只需要用WPS自带的查找或者Office的查找都可以完成这个工作,这就是一个进程内含有多个线程的现实模型。
但是如果你采用创建一个文件夹,一个文件夹内含有多个章节的文件,那么你每次要处理某个章节的一小段都需要完成打开文件,修改内容,关闭文件等一系列操作,十分麻烦。
综上所述,我们可以总结如下:
资源分配、调度 | 并发性 | 系统开销 |
---|---|---|
传统进程机制中,进程是资源分配、调度的基本单位 | 传统进程机制中,只能进程间并发 | 传统的进程间并发,需要切换进程的运行环境,系统开销很大。 |
引入线程后,进程是资源分配的基本单位,线程是调度的基本单位 | 引入线程后,各线程间也能并发,提高了并发度 | 线程间并发,如果是同一进程内的线程切换,则不需要切换进程环境,系统开销小,也就是说引入线程后,并发所带来的系统开销减小。 |
2.2.2 经典的线程模型
进程模型基于两种独立的概念:资源分组处理
和执行
。引入线程后,由于一个进程含有多个线程,所以功能的执行依赖于线程的切换,而不必使用开销更大的进程切换,线程的切换即涉及到线程的调度;而对于多个线程来说,其使用的是同一个资源,程序所需的资源分配的基本单位是进程而不是线程,多个线程共享同一个资源。
在每个线程中,通常带有一个程序计数器、寄存器和堆栈指针,这和进程是十分类似的。线程给进程模型增加的内容即同一个进程可以有多个线程,其切换我们在前面也提到过,CPU允许多线程切换纳秒级完成。
和传统进程一样,线程也有进程所拥有的进程状态。在Windows中线程的创建时通过调用库函数thread_create创建的,调用库函数thread_exit进行退出。
2.2.3 POSIX线程
为了实现可移植的线程程序,IEEE定义了线程的标准。它定义的线程包叫做pthread
,大部分UNIX系统支持该标准。这个标准定义了超过60个函数调用。常见的几个如下所示:
2.2.4 在用户空间中实现线程
有两种主要的方法实现线程包:在用户空间
中和内核
中。
第一种方法是把整个线程包放在用户空间中,内核对线程包一无所知。这样的话就会出现一个问题,即使用户开多条线程,内核还是以为只有一个进程,因为它并不知道进程内发生了啥,所以这时候就会出现单线程进程
。即使多个线程处理机也是分配其中一个。
以上的情况比较明显地体现是在Java中利用thread开启多线程,多条线程的执行并不是并行地,而是并发地,你可以在两条线程中各自打印一点东西,然后同时启动你就能了解到效果了。
在用户空间管理线程时,每个进程需要有其专用的线程表
,用拉力跟踪该进程中的线程。这些表和内核中的进程表类似,不过它们仅仅记录各个线程的属性,如每个线程的程序计数器、堆栈指针、寄存器和状态等。
用户级线程有一个优点是,它允许每个进程有自己定制的调度算法,并且其具有较好的扩展性,你可以多开几条线程,但是在内核空间中万一开多了线程是会出现问题的。
其另外一个优点是,在用户级线程下,线程的切换开销小,其无需切换为核心态;如果是内核级切换线程,需要陷入内核,开销较大。
有一个问题我们前面提到,如果是在用户空间下实行多线程,那么实际上处理器的占用取决于内核中有多少条线程。如果用户空间里有6条线程,而内核中只有一条线程,那么实际处理线程时,处理器只会用到一个。也就是说,如果在用户空间下实行多线程,那么一旦有一条线程阻塞,那么其他所有线程都将阻塞。
系统调用实际上是可以全部改成非阻塞的,但是这需要修改操作系统。还有一种替代方案是某个调用如果阻塞了就提前通知,但是这个处理方法需要重写部分系统调用库,所以也不太好,但是可惜的是,没有第三种方法了。
用户级线程包的最后一个问题是,如果一个线程开始运行,那么在该进程中的其他线程就不能运行,除非第一个线程自动放弃CPU,这和我们前面所讲的是一样的。需要知道的是,在一个单独的进程内部是没有时钟中断的,所以也就不可能以轮转调度的方式调度线程。所以除非某个线程能够按照自己的意志进行运行时系统,要不然调度程序是没有任何机会的。
2.2.5 在内核中实现线程
如图所示,此时如果是在内核中实现线程,那么不需要运行时系统了。内核实现线程的情况下,进程表和线程表都由内核控制。根据我们上一小节所说,内核控制的线程可以按照线程数来分配处理器,当一个线程阻塞时,内核会自动切换另外一个线程。
虽然使用内核线程可以解决阻塞等诸多问题,但也不是一劳永逸,内核级线程的管理工作由操作系统内核完成。线程调度、切换等工作都由内核负责,因此内核级线程的切换必然需要在核心态下才能完成,线程管理的成本高,开销大。
2.2.6 混合实现
在前面,我们说的两种情况如图所示:
这两个图在有的书中也被叫做多对一模型
和一对多模型
。
但是既然这两种模型各有各的缺点,为什么不联合起来呢?将用户级线程和某些内核线程多路复用起来,这在一些书上叫做多对多模型
,如图所示:
采用这种方法的特点是:内核还是只能识别内核级线程,这也就导致了即使用户级线程再多,处理器的分配数量还是依照内核级线程来确定,但是不会再有阻塞问题,也不会再有内核线程开多出毛病的问题。
2.2.7 调度程序激活机制
尽管内核级线程在一些关键点上优于用户级线程,但是内核级线程速度慢是硬伤。为了保持其优良特性并且改进其速度,研究人员研究出了调度程序激活
机制。
调度程序激活机制的本质即:既然能在用户空间中有这么大的便利,那我把内核的权限给你不就行了,用户线程如果发出的系统调用是安全的,那么就行使内核赋予的权限去处理即可,如果实在不能处理,再去陷入内核交给内核去处理。这样的话,由于避免了在用户空间和内核空间之间的不必要转换,从而提高了效率。
在2.2.4前面我们说到过,多对一模型中,由于只有一个内核级线程,所以一旦线程堵塞就完蛋了。这时候如果堵塞,内核就会通知该进程的运行时系统,并且在堆栈中以参数形式传递有问题的线程编号和所发生事件的一个描述。内核通过在一个已知的其实地址启动运行时系统,从而发出了通知,这种机制被叫做上行调用
。一旦如此激活,运行时系统就重新调度其线程。
在某个用户线程运行的同时发生一个硬件中断时,被中断的CPU切换进内核态。如果该线程没有什么大问题,在相关的事件发生后并处理完成,那么线程会通过与PCB同样功能的TCB(线程控制块)
去重新启动自己所在的线程。