赞
踩
说起什么是进程,就不得不提起什么是程序存储,就不得不提起冯诺依曼计算机。现代计算机都是依托于冯诺依曼架构的冯诺依曼计算机。而冯诺依曼计算机的关键概念便是在数字计算机内部存储器中存储程序,即“Stored Programe Concept”程序存储理论。
我们编写的程序在Linux上会经过编译器编译为二进制文件即ELF文件存放在存储器中。这时候代码就是数据,数据就是代码。存储器中上的高低电平翻译成数据还是代码取决于程序员自己。计算机将存储器上的这部分区域以代码的形式读取出来,加载到内存,交给CPU执行。也就是说一个运行中的程序就是进程。这里说的运行不是说CPU正在运行这个程序、进程有着阻塞、挂起等等状态,都算是运行,更准确点应该说:“一个奔跑中的程序是运行,CPU可不会陪着它跑,可能会让它一个进程去一边阻塞不理他”。一个进程就是一个正在执行程序的实例,包括程序计数器、寄存器和变量的当前值。
冯诺依曼计算机还有一个特点是按地址访问并顺序执行指令。早期的计算机严格遵循这个特点,使用的是单道批处理的操作系统,即一个只进行一个程序,由它支配系统所有资源,资源利用率和系统吞吐率极低。后来出现了多道程序设计技术,造就了多道批处理的操作系统。这种操作系统在内存中同时加载多道程序,它们在系统中并发执行,共享系统中的各种资源。当其中一道程序因为要等待IO或者访问存储器等而暂停执行后,CPU就去执行其他程序。多道批处理系统提高了资源利用率和系统吞吐率。但是多道批处理系统因为程序共享系统的资源,所以程序之间相互限制,经常发生冲突。
还有另一个原因,早期计算机CPU速度于存储器访问速度相匹配所以不太需要进程。但是根据摩尔定律,每十八个月单位面积集成电路上的晶体管数量翻一倍,存储器的访问速度严重慢于CPU的运算速度。通过中断并发执行进程,见中断上下文切换。
我们一次次的改进计算机的根本目的可以这么说,为了尽可能快地解决尽可能多的复杂问题,简化一下就是一个字——“快”。以上说了那么多问题,都严重阻碍了计算机的运行速度,进程就是为了解决这些问题而诞生的。作为一个程序员,抽象是必备的能力。我们重新抽象下进程这个概念。
想想上面碰到的问题,你如果是一个工程师你怎么解决。既然多道批处理系统的程序共享资源会互相限制而造成冲突。那么我们就给运行的程序套一层壳——进程。使得程序只有通过进程才能申请系统资源。进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
既然CPU运行速度远高于存储器访问速度,那么由操作系统对每一个进程作上下文切换即进程调度。将进程加入到CPU的等待队列,按照一定的调度方式加载进程到CPU执行,如果这道进程要进行IO操作等非常耗时的举动,CPU就触发中断,让这个进程一边等着去,加紧处理下一个进程,CPU一刻都不能闲着。等IO操作完成,再把进程加载回来,继续执行。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体。 (在当代面向线程设计的计算机结构中,进程是线程的容器,线程是程序的基本执行实体。线程是什么后面再说)
以上述说视角都是以CPU为中心,好像满足了CPU一切就都解决了。但进程的出现对程序也是有好处的。操作系统通过虚拟化( virtualizing) CPU 来 提供这种假象。 通过让一个进程只运行一个时间片, 然后切换到其他进程, 操作系统 提供了存在多个虚拟 CPU 的假象。 这就是时分共享( time sharing) CPU 技术, 允许用户运行多个并发进程。从概念上说,每个进程拥有它自己的虚拟CPU。
程序是指令、数据及其组织形式的描述,进程是程序的实体。
死掉的程序只是存储器上的数据,活过来的程序就是进程。没错,进程是有生命的。
在 UNIX 中,仅有一个系统调用来创建一个新的进程,这个系统调用就是
fork
。这个调用会创建一个与调用进程相关的副本。在 fork 后,一个父进程和子进程会有相同的内存映像
,相同的环境字符串和相同的打开文件。在 Windows 中,情况正相反,一个简单的 Win32 功能调用
CreateProcess
,会处理流程创建并将正确的程序加载到新的进程中。这个调用会有 10 个参数,包括了需要执行的程序、输入给程序的命令行参数、各种安全属性、有关打开的文件是否继承控制位、优先级信息、进程所需要创建的窗口规格以及指向一个结构的指针,在该结构中新创建进程的信息被返回给调用者。「在 Windows 中,从一开始父进程的地址空间和子进程的地址空间就是不同的」。
正常退出(自愿的)
:多数进程是由于完成了工作而终止。当编译器完成了所给定程序的编译之后,编译器会执行一个系统调用告诉操作系统它完成了工作。这个调用在 UNIX 中是 exit
,在 Windows 中是 ExitProcess
。错误退出(自愿的)
:比如执行一条不存在的命令,于是编译器就会提醒并退出。严重错误(非自愿的)
被其他进程杀死(非自愿的)
:某个进程执行系统调用告诉操作系统杀死某个进程。在 UNIX 中,这个系统调用是 kill。在 Win32 中对应的函数是 TerminateProcess
(注意不是系统调用)。进程其实很苦逼,并不是或者就能一直运行到结束,毕竟资源不够多、CPU算得不够快啊。所以活着的进程除了真正在上CPU上运行还要做一件事——等。作为一个进程,你既要等CPU处理其他进程(就绪状态),也要等资源访问(阻塞状态)。他们之间的状态转移图如图所示。
还有一个状态叫挂起状态,它表示进程没有占有物理内存空间。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态,另外调用 sleep 也会被挂起。挂起分为阻塞挂起和就绪挂起。挂起状态分为阻塞挂起状态和就绪挂起状态。
这两种挂起状态加上前面的三种状态和创建结束两种状态,就变成了七种状态变迁,见如下图:
状态转移如下:
在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。
PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。
进程描述信息:
进程控制和管理信息:
资源分配清单:
CPU 相关信息:
从静态角度看,进程实体 = 进程控制块(PCB)+ 程序段 + 数据段。从动态角度看,进程是可并发运行程序在其数据集合上的运行过程。
Linux进程结构:可由四部分组成:PCB、代码段、数据段、堆栈段。
通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:
那么,就绪队列和阻塞队列链表的组织形式如下图:
除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。
内核负责管理维护所有进程,为了管理进程,内核在内核空间维护了一个称为进程表(Process Table)的数据结构,这个数据结构中记录了所有进程,每个进程在数据结构中都称为一个进程表项(Process Table Entry),如图。
从图中可知,进程表中除了记录了所有进程的PID,还使用一个字段记录了所有进程的指针,指向每个进程的进程控制块(Process Control Block,PCB)。
一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。
我们熟知了进程的状态变迁和进程的数据结构 PCB 后,再来看看进程的创建、终止、阻塞、唤醒的过程,这些过程也就是进程的控制。
01 创建进程
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源,当子进程被终止时,其在父进程处继承的资源应当还给父进程。同时,终止父进程时同时也会终止其所有的子进程。
创建进程的过程如下:
02 终止进程
进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill
掉)。
终止进程的过程如下:
03 阻塞进程
当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。
阻塞进程的过程如下:
04 唤醒进程
进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。
如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。
唤醒进程的过程如下:
进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。
大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时运行的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运行,于是就造成同时运行的错觉。
任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。
所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。
CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。
既然知道了什么是 CPU 上下文,那理解 CPU 上下文切换就不难了。
CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。
各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
进程运行的环境称为进程上下文(context),进程的上下文由进程控制块PCB(process control block)、正文段(text segment)、数据段(data segment)以及用户堆栈(stack)组成。其中:正文段存放该进程的可执行代码;数据段存放进程中静态产生的数据结构;PCB包括进程的编号、状态、优先级以及正文段和数据段中数据分布的大概情况。
所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:
另外需要说明的,上下文切换时保护现场和恢复现场都是有代价的,而且代价并不止于这里所描述的PCB保存和恢复。在一个进程正常运行过程中,CPU的各个寄存器和高速缓存中还保存了当前进程的很多数据和状态,在上下文切换时会丢失很多信息,在重新恢复该进程时,将刷新这些状态,不得不重新查询或计算。
所以,上下文切换频繁,必须考虑其代价问题,甚至有时候会将太多频繁的切换当作影响性能的关键指标。
正因为进程上下文切换的巨大代价,线程出现了。
对单个 CPU 上如何运行多个顺序进程的错觉做更多的解释。与每一 I/O 类相关联的是一个称作 中断向量(interrupt vector)
的位置(靠近内存底部的固定区域)。它包含中断服务程序的入口地址。假设当一个磁盘中断发生时,用户进程 3 正在运行,则中断硬件将程序计数器、程序状态字、有时还有一个或多个寄存器压入堆栈,计算机随即跳转到中断向量所指示的地址。这就是硬件所做的事情。然后软件就随即接管一切剩余的工作。
当中断结束后,操作系统会调用一个 C 程序来处理中断剩下的工作。在完成剩下的工作后,会使某些进程就绪,接着调用调度程序,决定随后运行哪个进程。然后将控制权转移给一段汇编语言代码,为当前的进程装入寄存器值以及内存映射并启动该进程运行,下面显示了中断处理和调度的过程。
一个进程在执行过程中可能被中断数千次,但关键每次中断后,被中断的进程都返回到与中断发生前完全相同的状态。
进程切换涉及到虚拟地址空间的切换而线程切换则不会。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。
存寄存器的值
4. 汇编语言过程设置新的堆栈
5. C 中断服务器运行(典型的读和缓存写入)
6. 调度器决定下面哪个程序先运行
7. C 过程返回至汇编代码
8. 汇编语言过程开始运行新的当前进程
一个进程在执行过程中可能被中断数千次,但关键每次中断后,被中断的进程都返回到与中断发生前完全相同的状态。
进程切换涉及到虚拟地址空间的切换而线程切换则不会。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。