目录
预备知识
冯诺依曼和现代计算机结构
操作系统的理解
进程和PCB的概念
PCB中的信息
查看进程信息的指令 - ps
pid
进程状态
预备知识
在学习操作系统之前我们需要先了解一下如下的预备知识。
冯诺依曼和现代计算机结构
美籍匈牙利科学家冯·诺依曼最先提出“程序存储”的思想,并成功将其运用在计算机的设计之中,根据这一原理制造的计算机被称为冯·诺依曼结构计算机。由于他对现代计算机技术的突出贡献,因此冯·诺依曼又被称为“现代计算机之父”。程序存储是指:指令以代码的形式事先输入到计算机的主存储器中,然后按其在存储器中的首地址执行程序的第一条指令,以后就按该程序的规定顺序执行其他指令,直至程序执行结束。结构示意图如下
早期的冯诺依曼模型是以运算器为核心,由输入设备将数据和相关的指令传递给运算器,之后先将数据放到存储器,然后运算器将数据处理完成之后再传递给输出设备。而这整个过程的协调和分配就是由控制器来控制的。
但是这样会有一个很严重的缺陷,就是运算器的速率会被输入输出设备大大的影响。在微处理器问世之前,运算器与控制器分离。而且存储器容量小,因此设计成以运算器为中心的结构,其他部件都通过运算器完成信息的传递,也就是上图的样子。
而随着微电子技术的进步,同时计算机需要处理的信息也越来越多,大量I/O设备的速度和CPU的速度差距悬殊,因此需要更新换代计算机的组织结构以适应新的需求。计算机发展为了以存储器为中心,使I/O设备尽可能的绕过CPU,直接在I/O设备与存储器之间完成操作,以提高整体效率。由此便产生了现代主流的计算机结构,结构示意图如下
可以看到,当代的计算机结构是以存储器为核心的,输入设备将信息传递给存储器,然后由存储器将信息传递给运算器,运算器处理完数据之后再传回存储器,最后由存储器将数据传递到输出设备。其中,控制器和运算器共同组成我们现在耳熟能详的CPU。
可以看到,现代的计算机结构是以存储器为核心的,这样就实现了运算器只与存储器之间进行交互,大大提高了运算器的效率。
我们可以把上面的结构抽象为下面的简化图,这样CPU和主存储器(也就是我们的内存)就组成了我们专业术语上的主机,而其它的任何输入输出设备都叫做外设。
其次,我们还需要介绍一下存储相关的知识的,首先是存储设备的层级结构:
下面的内容选自《深入理解计算机系统(第三版)》
实际上,每个计算机系统中的存储设备都被组织成了一个存储器层次结构,如图下图所示。在这个层次结构中,从上至下,设备的访问速度越来越慢、容量越来越大、离CPU越来越远,并且每字节的造价也越来越便宜。寄存器文件在层次结构中位于最顶部,也就是第0级或记为L0。这里我们展示的是三层高速缓存L1到L3,占据存储器层次结构的第1层到第3层。主存在第4层,以此类推。
特别的,主存储器主要由RAM和ROM组成,而RAM主要有DRAM和SRAM两种。其中,内存条就是DRAM集成的,也就是俗称的运行内存。而像SRAM、CACHE等高速缓存和寄存器一般是被集成在CPU中的。至于ROM,一般被嵌入在主板中,存储了BIOS等一些很重要的程序。关于RAM和ROM的一些区别,感兴趣的可以参考这篇文章:RAM和ROM的区别(转) - 知乎 (zhihu.com)
操作系统的理解
我曾在 Linux初探 - 概念上的理解和常见指令的使用 博客中简单介绍了一下操作系统的概念,这里我们将细说一下操作系统的上下级,也就是操作系统所处的层级结构。
可以看到,操作系统是处于一个“承上启下”的位置,用于处理用户和硬件程序之间的交互功能。
首先,操作系统的本质其实就是一款软件,它是我们的计算机启动时第一个启动的软件。如果细究的话,操作系统其实是由主板上ROM存储中的或是其它硬件中的一些程序启动的。感兴趣的话可以试着阅读这篇文章:操作系统是如何启动起来的? - 知乎 (zhihu.com)
由于操作系统的本质其实就是一款很大的软件,所以是无法直接在操作系统上操作键盘、鼠标、音响(甚至是主存储器和CPU)等硬件的。那么为了实现硬件与操作系统这款软件之间的交互操作,于是便有了”驱动程序“这个中介,驱动程序将硬件的信息的方法等组织封装起来然后打包提供给操作系统(这些一般是嵌入式的活)。正是由于驱动程序的介入,使得操作系统可以像处理文件那样与我们的硬件进行交互,同时这也应证了”Linux下一切皆文件“的这句经典名言。通常情况下,很多人认为主存储器和CPU与操作系统之间没有驱动,实际上并不是没有驱动,而是由于安全和便捷等因素的考虑,一般把CPU和主存储器的驱动程序内嵌在主板和操作系统中(这也就是为什么有些CPU或内存条在某些主板上不适配的原因之一)。所以,有了驱动程序这个中介,操作系统就可以按照我们的需求与硬件之间进行交互了。
那么操作系统又是怎么和用户或者应用软件之间进行交互的呢?这里就不得不提一下“系统调用”了。虽然操作系统能够以它能理解的方式操作硬件了,但是为了防止用户的一些危险操作,并且使得用户/程序员能够以一种方便、易于理解的方式来操作这些硬件设备,最终操作系统会提供给我们一系列的操作接口,这些接口就是所谓的“系统调用”。系统调用给了上层程序一个清晰的接口,隐藏了内核的复杂结构,这些接口使得我们能够以一种简单并且安全的方式访问我们的硬件程序。特别的,我们没有能够与CPU之间进行交互的系统调用,只有与内存之间进行交互的系统调用,这是因为CPU是严格按照一些既定的规则来处理日常的这些指令、操作的(些指令基本上都来源于内存或者寄存器),所以我们根本就不能而且完全没必要与CPU之间进行交互。可以理解为CPU就是一个计算机的大脑,他有自己的想法,不需要我们去教它做事。
不过由于系统调用非常的基础,所以有时使用起来也是很繁杂的,比如一个简单的给变量分配内存空间的操作,就需要动用多个系统调用。所以在系统调用的基础上又有了库函数和外壳程序等工具。操作系统会将一些编程中常用操作所对应的系统调用封装成对应的库函数,以供开发者的使用,这大大的提高了编程效率和学习成本,比如C语言中的malloc函数,看似只是一个函数,实际上却调用了大量的系统调用的接口。(实际上,一个操作系统只要称得上是Linux系统,必须要拥有一些库函数,比如ISO C标准库,POSIX标准库等)。至于外壳程序(shell),如果我们在使用操作系统的时候,每执行一个简单的操作都需要我们手动的去调用大量的系统调用的话,那么即使是一些很专业的开发者也会不堪重负。因此,便有了Windows下的图形化界面和Linux下像bash这样的命令行解释器,每当我们在Windows下点击鼠标或者是在Linux下输入以下指令,其背后都是调用了大量的系统调用的。其实,shell外壳程序可以看作是一种特殊的软件,它为我们封装了大量的系统调用,为我们提供了一种高效、稳定、安全的操作环境。
我们日常生活中说的操作系统是包含了系统内核、系统调用、shell外壳这些内容的。而严格意义上讲,操作系统的内核不包含所谓的系统接口、库函数等内容,如下图所示。也就是说,我们日常在Windows下看到的那个图形化界面并不属于操作系统的内核,他只是一种套在系统内核上让用户可以快速上手的外壳程序。正是因为有了这个图形化界面,于是便有了一系列的可以运行在这图形化界面环境下的应用程序,诸如我们日常使用的QQ、bilibili、Steam等。我们可以在外壳程序的基础之上快速上手使用这些应用程序,而不是打开一个软件之前还需要我们手动的去调用大量系统接口。
至此,我们知道了操作系统对下是通过一系列的驱动程序来调度硬件设备的,对上提供一系列安全、稳定的系统调用。人们又将这些系统调用封装为一些库函数或者便于使用的shell外壳,而在外壳程序之上又衍生了一大批的应用程序。而操作系统的一个很重要的任务就是管理和调度这些上下层以及操作系统本身的软硬件资源。我们人为地将操作系统的管理功能进行分类,其中较为常见的有内存管理、进程管理、文件管理和驱动管理这么几类。下面我就就先针对进程管理进行介绍。
进程和PCB的概念
可执行程序的运行首先是需要加载或者说是拷贝到内存中的,然后由CPU去读取和处理内存中对应区域的二进制指令。广义上讲,进程就是可执行程序加载到内存。但严格意义上讲,并没有这么简单,一个简单的描述是:进程=可执行程序+PCB。那么何为PCB呢?下面是一段对PCB的介绍(摘自百度百科):
为了描述控制进程的运行,系统中存放进程的管理和控制信息的数据结构称为进程控制块(PCB Process Control Block),它是进程实体的一部分,是操作系统中最重要的记录性数据结构。它是进程管理和控制的最重要的数据结构,每一个进程均有一个PCB,在创建进程时,建立PCB,伴随进程运行的全过程,直到进程撤消而撤消。
那么为什么要引入这个PCB呢?简单来说就是为了便于对进程进行统一的管理和描述。因为可执行程序的具体实现五花八门,操作系统无法对每一个进程做到做监控,而操作系统又需要对进程进行统一的管理,于是PCB便诞生了。PCB就相当于是一个信息收集表,表中是和进程相关的一些数据,如进程标识符、进程状态等信息。这样操作系统就不需要对每一个进程做到实时监控,而是只需要通过分析处理进程所对应的PCB中的信息即可,例如下图所示。这就像学校为了管理学生的体质情况会进行体测,最终的体测结果填入统一的项目表中,最终学校只需要分析和处理我们对应的体测表信息就可以了,而不是每次想要查看某个同学的体测结果时都要去调监控。
而PCB是进程控制块的一个统称,不是具体的一个实例。我们知道,Linux是用C语言写的,所以在Linux下PCB其实就是一个名为task_struct的结构体。而PCB与task_struct的关系,就和shell与bash的关系一样,是一类东西的名称和具体实例的关系。
PCB中的信息
查看进程信息的指令 - ps
在Linux下我们通常使用ps指令来获取一个进程的各种信息,具体使用细节见ps命令 – 显示进程状态 – Linux命令大全(手册) (linuxcool.com)https://www.linuxcool.com/ps通常使用组合就是 ps ajx 或者ps aux 配合管道,用grep检索使用,例如:
ps aux | grep test # 列出所有进程,并筛选含有"test"条目的信息
pid
进程标识符,又名pid、进程号等,是当前OS中每个进程唯一的标识符。PID是一个正整数,取值范围一般是 2—32768, /proc/sys/kernel/pid_max 文件中的内容就是最大可支持的进程个数。
我们知道,Linux是用C语言写的,所以在Linux下,我们可以在C语言编程时使用对应的getpid这个库函数来获取一个进程的pid。
其中, sys/types.h 是 pid_t 这个类型声明的头文件,而getpid()函数是在 unistd.h 头文件中的。getpid函数直接返回一个pid_t类型的当前进程号,这个pid_t就是某个整型类型的声明,因为这是封装好的库函数,所以用法上就和C语言中普通的函数调用一样。
在Linux中,内存管理的一系列进程的PCB信息都是统一存放在 ./proc 目录中的,例如:
其中,这些数字形式的目录就是对应的进程,而且这些目录的中的内容格式都是一致的,例如下面是目录内容的部分截图:
值得一提的是,cwd和exe这两个链接文件分别指向自己所在的工作目录和具体的路径位置,这也就是为什么我们可以采取相对路径的方式输入指令,因为指令创建的进程所在的地址是可以通过对应的链接符直接找到的。其中,可以使用chdir库函数实现改变工作目录,具体用法就不再过多赘述,感兴趣的可以自行查阅相关资料。
暂且可以认为这些目录中的信息就是进程所对应的PCB信息。与一般目录不同的是, /proc 目录下的信息内容是动态的,当有进程被创建时,就会向其中增加对应的目录信息,文件名一般就是其对应的PID。而有进程结束时, /proc 目录下就会随之删除对应的信息。
进程状态
想要描述一个进程,一个必不可少的信息就是进程状态,我们需要知道进程当前是在运行、还是被终止了,亦或是其它状态,所以PCB中一个必不可少的信息就是进程的状态。那么以Linux为例,Linux中对上述描述的具体实现就是 task_struct 结构体中的 state 变量,如下图所示,task_struct中的第一个就是state。
而PCB对进程各种状态的描述,就是state对应了不同的变量,例如:
也就是说,仅从PCB的角度来看待进程的话,那么所谓的进程状态,其实就是一些提前定义好的数据,进程处在哪个状态,对应的state变量就切换成对应的值。进程状态切换时,PCB中其实就只是修改了state变量的值而已。
那么从操作系统的角度来看,进程状态又是怎么一回事呢?以操作系统的视角来看,进程大致有如下新建、就绪、运行、阻塞、挂起、结束等几个状态。
下面我们就简单的从概念层面上介绍这几个进程状态
- 新建状态:新建状态也常被称作创建状态,顾名思义,新建状态就是执行进程创建相关的工作。此时,操作系统会执行但不限于如下操作:为新进程申请一个空白PCB、为新进程分配其运行所需的资源、初始化PCB中的相关信息(例如PCB的state变量)。
- 就绪状态:当进程的创建操作完成之后,并不是马上将其PCB放到运行队列中,而是先把它放到就绪队列中(并修改PCB的state变量),等待操作系统的调度分配(具体是如何调度的与进程的优先级和系统的调度算法等有关,暂时不展开讨论),此时进程的状态就是就绪状态。
- 运行状态:一般情况下,进入到就绪队列中的PCB基本上都会被调度到CPU的运行队列中(并修改PCB的state变量),此时CPU会逐条执行处于运行队列中PCB所对应可执行程序中的二进制代码。那么此时进程所处的状态就是运行状态。值得注意的是,一个CPU只有一个运行队列,多个CPU可以有多个运行队列。
- 阻塞状态:一般来说,如果进程当前要获取的资源(软硬件资源)没有就绪,或者说进程当前无法获取到目标资源。那么此时这个进程的PCB就会被转移到对应资源的等待队列中,对应的state变量也会被修改。此时CPU的运行队列就没有了这个进程的PCB,那么也就无法去调度执行这个进程。那么此时的进程状态就叫做阻塞状态。
- 挂起状态:当系统资源紧张的时候,操作系统会对在内存中的资源进行更合理的安排,这时就会将将某些优先级不高的进程设为挂起状态,并将其移到内存外面,一段时间内不对其进行任何操作,当条件允许的时候,会被操作系统再次调回内存,重新进入等待被执行的状态即就绪态。
- 结束状态:顾名思义,进程执行结束,操作系统为其释放对应的系统资源和执行一些清理工作的过程就叫做结束状态。
下面是与进程状态一些相关的内容:(有点杂乱,不想看可以直接跳过)
- 操作系统在管理硬件资源时,也有对应的硬件信息队列(只是提一嘴,不细究),这些硬件资源的管理和PCB的运行队列类似(虽然实际情况肯定不是这样的,但却是以这种形式为基础的,所以可以这样描述),当硬件资源就绪时,进程就可以正常访问、获取进程想要的资源,而如果硬件资源没有就绪时,那么前来访问资源的进程就会被安排在对应资源的等待队列中,当资源就绪时,进程获取资源,对应的PCB再回到CPU的运行队列,就又可以被CPU重新调度。其中,每一个硬件都有其对应的等待队列,所以并不是只有一个等待队列。所以当我们运行了多个进程,这些进程有时可能会访问一些共同的资源时,就可能会出现我们感受到的卡顿现象。而且,操作系统中,存在着各种各样的队列,不止CPU的运行队列和各种硬件的等待队列。
- 挂起状态的实例 —— 阻塞挂起:阻塞挂起的操作是,将内存中的代码和数据先暂存到外存设备的某些特定区域(如硬盘的swap区),只留一个很小的PCB放在内存中(当然也会修改对应的state变量),当进程被唤醒时再次重新被加载到内存。这样做肯定会造成速度降低,但有时却不得不这样做。例如当所剩的内存资源严重不足时,操作系统就不得不暂时挂起一些阻塞队列中的进程来缓解内存的压力。
- 阻塞和挂起的区别:(参考:一文助你理解进程七态及挂起与阻塞 - 掘金)
1、挂起是一个行为,而阻塞是进程的一种状态。
2、进程的位置不同:挂起是将进程移到外存中,而处于阻塞状态的进程还是在内存的对应资3、源的等待队列中。
4、产生的原因不同:挂起一般是由于可分配资源不足,迫使操作系统对一些进程采取挂起操作,抑或是用户指定某个进程挂起。而阻塞是进程正在等待某一事件发生,一般是等待资源或者响应等而暂时停止运行的状态。
5、挂起是被动的行为,进程被迫从内存中移至外存中。而阻塞可以看成是一个主动的行为,主动的进入到对应资源的等待队列。
至此,我们简单的从概念的层面认识了进程的几种状态,那么接下来我们就以Linux为例,介绍一下Linux下的一些具体的进程状态。
R:running or runnable (on run queue)
S:interruptible sleep (waiting for an event to complete)
D:uninterruptible sleep (usually IO)
T:stopped by job control signal
t:stopped by debugger during the tracing
W:paging (not valid since the 2.6.xx kernel)
X:dead (should never be seen)
Z:defunct ("zombie") process, terminated but not - reaped by its parent
内容参考:Linux系统之进程状态-腾讯云开发者社区-腾讯云
1、R (TASK_RUNNING):运行或可执行状态
TASK_RUNNING,运行态和就绪态的合并,表示进程正在运行或准备运行,因为CPU的运行速度非常快,所以运行状态和就绪状态之间的空隙时间很短,所以就统一用 R 状态表示了。
补充:很多教科书将正在CPU上执行的进程定义为RUNNING状态、而将可执行但是尚未被调度执行的进程定义为READY状态,这两种状态在linux下统一为 TASK_RUNNING状态
2、S (TASK_INTERRUPTIBLE):可中断睡眠状态
TASK_INTERRUPTIBLE,此时进程被阻塞,当等待的资源到来时即可唤醒。或者也可以通过其他进程信号或时钟中断唤醒,进入运行队列。这种睡眠状态是可中断的,即可以被kill掉的。
补充:通过ps命令会看到,一般情况下,进程列表中的绝大多数进程都处于 S 状态(除非机器的负载很高)。毕竟CPU就这么几个,进程动辄几十上百个,如果不是绝大多数进程都在睡眠,CPU又怎么响应得过来。
3、D (TASK_UNINTERRUPTIBLE):不可中断的睡眠状态
与TASK_INTERRUPTIBLE状态类似,进程处于睡眠状态,但是此刻进程是不可中断的。不可中断,也就是说处于这种睡眠状态的进程,哪怕是用户强制kill都不能让其强行结束。
补充:不可中断睡眠状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了。 例如进程在对某些硬件进行操作时(比如进程调用read系统调用对某个设备文件进行读操作,而read系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用TASK_UNINTERRUPTIBLE状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。这种情况下的TASK_UNINTERRUPTIBLE状态总是非常短暂的,通过ps命令基本上不可能捕捉到。
4、T (TASK_STOPPED):暂停状态
TASK_STOPPED,进程暂停执行,处于挂起状态,而不是阻塞状态。 通过向进程发送一个SIGSTOP信号(kill -19),它就会因响应该信号而进入暂停状态(除非该进程本身处于 D 状态而不响应信号)。
5、t (TASK_TRACED):跟踪状态
TASK_TRACED,此时进程被跟踪,“正在被跟踪” 指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在gdb调试中对进程打一个断点,进程在断点处停下来的时候就处于跟踪状态。而在其他时候,进程就还是处于正常情况,不是跟踪状态。
补充:对于进程本身来说,暂停状态和跟踪状态很类似,都是表示进程暂停下来。只不过跟踪状态相当于在暂停状态之上多了一层保护,处于跟踪状态的进程不能响应SIGCONT信号而被唤醒。只能等到调试进程通过一些系统调用执行对应的操作操作或是调试进程退出,被调试的进程才能恢复到其它状态。
6、Z (TASK_DEAD - EXIT_ZOMBIE):退出状态 - 进程成为僵尸进程
进程在退出的过程中,是处于TASK_DEAD状态的。在这个退出过程中,除了task_struct(PCB)之外,操作系统会将进程占有的所有资源进行回收。那么最后进程就只剩下task_struct这个空壳,所以被称为僵尸进程。
之所以保留task_struct,是因为task_struct里面保存了进程的退出码、以及一些统计信息。而其父进程很可能会关心这些信息。例如父进程可以通过wait系列的系统调用(如wait4、waitid等)来等待某个或某些子进程的退出,并获取它的退出信息,而这个退出信息就是被保存在task_struct中的,之后wait系列的系统调用会顺便将子进程的尸体(task_struct)也释放掉。
当父、子进程在不同时间点退出时,就可能会出现Z的细分状态:僵尸状态和孤儿状态。这个在后面会谈到,这里就不展开来说了。
6、X (TASK_DEAD - EXIT_DEAD),死亡状态 - 进程即将被销毁
一个进程即将退出,PCB随之也会销毁。所以死亡状态是非常短暂的,几乎不可能通过ps命令捕捉到,这里仅作为概念了解,日常生活中很少遇到。
案例分析:当编写如下的C语言的代码,并监视对应的进程时会发现,大多数情况捕获到的都是S状态。是因为CPU的执行速度非常快,而访问硬件资源或是文件等速度相对会很慢(例如代码中的printf函数),所以实际上处于R状态的时间是微乎其微的。当我们把printf和sleep都注释掉之后再去捕获进程状态,就会发现此时都是R状态了。
int main()
{
while(1)
{
printf("我是一个进程,我的pid是: %d\n", getpid());
sleep(1);
}
return 0;
}
内容补充:前台进程和后台进程
不难发现,有时进程状态后面还会跟一个加号 '+',这表示的是当前的进程是一个前台进程,前台进程占用着前台资源,所以此时是无法进行输入指令等操作的。而后台进程状态信息之后是没有那个加号的,后台进程不影响前台操作,所以还是可以进行输入指令等操作的。在Linux下,我们只需要在指令后加一个 '&',那么对应的进程就是后台进程。
未完待续……