操作系统之进程
关于进程
进程?进程,就是运行中的程序。
操作系统为正在运行的程序提供的抽象,就是进程。
进程是由内核定义的抽象的实体,并为该实体分配用以执行程序的各项系统资源
程序是包含了一系列信息的文件, 这些信息描述了如何在运行时创建一个进程, 所包括的内容:
- 二进制格式标识:每个程序文件都包含用于描述可执行文件格式的元信息
(metainformation)。内核(kernel)利用此信息来解释文件中的其他信息。历史上,UNIX
可执行文件曾有两种广泛使用的格式,分别为最初的 a.out(汇编程序输出)和更加复
杂的COFF(通用对象文件格式)。现在,大多数UNIX 实现(包括Linux)采用可执行
连接格式(ELF),这一文件格式比老版格式具有更多优点。 - 机器语言指令:对程序算法进行编码。
- 程序入口地址:标识程序开始执行时的起始指令位置。
- 数据:程序文件包含的变量初始值和程序使用的字面常量(literal constant)值(比
如字符串)。 - 符号表及重定位表:描述程序中函数和变量的位置及名称。这些表格有多种用途,其
中包括调试和运行时的符号解析(动态链接)。 - 共享库和动态链接信息:程序文件所包含的一些字段,列出了程序运行时需要使用的
- 共享库,以及加载共享库的动态链接器的路径名。
- 其他信息:程序文件还包含许多其他信息,用以描述如何创建进程。
从内核角度看,进程由用户内存空间(user-space memory)和一系列内核数据结构组成,其
中用户内存空间包含了程序代码及代码所使用的变量, 而内核数据结构则用于维护进程状态信息。
记录在内核数据结构中的信息包括许多与进程相关的 标识号(IDs) 、虚拟内存表、打开文件的描述符表、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录 和大量的其他信息。
进程状态
运行(running):在运行状态下,进程正在处理器上运行。这意味着它正在执行指令。
就绪(ready):在就绪状态下,进程已准备好运行,但由于某种原因,操作系统选择不在此时运行。
阻塞(blocked):在阻塞状态下,一个进程执行了某种操作,直到发生其他事件时才会准备运行。 一个常见的例子是, 当进程向磁盘发起 I/O 请求时, 它会被阻塞,因此其他进程可以使用处理器
如图:

来自《操作系统导论》4.4进程状态
进程号和父进程号
每个进程都有一个进程号(PID),它是一个正数,用以 唯一标识 系统中的某个进程。getpid()函数返回进程号:
1 |
|
除少数系统进程外,(eg:init进程 pid = 1)程序与运行该程序进程的进程号之间没有固定关系。
1 | Linux内核限制进程号需小于等于 32767。新进程创建时,内核会按顺序将下一个可用的进程号分配给其使用。每当进程号达到32767 的限制时,内核将重置进程号计数器,以便从小整数开始分配。 |
每个进程都有一个创建自己的父进程。使用系统调用getppid()可以检索到父进程的进程号。getppid()函数返回该进程的父进程号:
1 |
|
如果子进程的父进程终止,则子进程就会变成“孤儿” ,init 进程随即将收养该进程,子进程后续对 getppid()的调用将返回进程号 1。
孤儿进程和僵死进程
孤儿进程
孤儿进程是指 父进程在子进程还未结束时 提前结束了。
当父进程退出时,内核不能简单地让子进程失去“父亲”而继续运行。因此,孤儿进程会被系统进程1号 (init 进程或 systemd进程) 所收养 (adopt)。
孤儿进程的特点
进程状态:它是正常运行的进程,只是失去了原始的父进程。
新父进程:它的新父进程是 init (PID 1) 或现代系统中的 systemd。
影响:孤儿进程本身对系统没有负面影响,因为 init 进程会负责等待 (wait/waitpid) 它的结束并回收其资源,防止它变成僵死进程。
僵死进程
僵死进程是指一个 子进程已经结束运行(释放了除进程描述符之外的所有资源),但其父进程尚未通过 wait() 或 waitpid() 系统调用来获取其退出状态的进程。
它之所以被称为“僵死”,是因为它虽然已经“死亡” (不再执行任何代码),但它的进程描述符(PCB,Process Control Block)却像“僵尸”一样,仍然驻留在系统中。
僵死进程的特点
进程状态:显示为 Z 或 Z+ (如在 ps 命令中)。
占用资源:它几乎不占用内存或 CPU,它所占用的主要是进程描述符中的少量信息(包括进程 ID、退出状态、资源使用统计等)。
危害:单个僵死进程无害,但如果父进程创建了大量子进程,并且从不回收它们的退出状态,那么系统中的僵死进程数量就会不断积累,最终耗尽系统可用的进程ID (PID),导致新的进程无法创建,造成系统瘫痪。
处理僵死进程
僵死进程无法直接通过 kill 命令杀死(因为它已经“死了”,不再执行代码)。处理它的唯一方法是:
杀死父进程:最常见的方法是杀死僵死进程的父进程。一旦父进程被杀死,僵死子进程就会变成孤儿进程,被 init (PID 1) 或systemd 收养。由于 init/systemd 会自动清理其收养的子进程,僵死进程就会被立即清理并彻底从系统中移除。
修改父进程代码:在编写父进程代码时,应确保在创建子进程后,始终使用 wait() 或 waitpid() 来等待并清理子进程。
进程API
fork()系统调用
fork()介绍
fork()系统调用用来创建新进程。
1 |
|
进程的栈、数据以及栈段开始时是对父进程内存相应各部分的完全复制。执行fork()之后,每个进程均可修改各自的栈数据、以及堆段中的变量,而并不影响另一进程。
fork()在父进程中返回子进程id,在子进程中返回0。
当无法创建子进程时,fork()将返回-1。失败的原因可能在于,进程数量要么超出了系统针对此真实用户(real user ID)在进程数量上所施加的限制(RLIMITNPROC,36.3 节将对此加以描述–《Linux-UNIX系统编程手册》),要么是及允许该系统创建的最大进程数这一系统级上限。
假设程序在单个 CPU 的系统上运行(简单起见) ,那么子进程或父进程谁先运行都有可能。不过,CPU 调度程序(scheduler)决定了某个时刻哪个进程被执行。(《操作系统导论》第7章)
fork()的内存语义
从概上说来,可以将fork()认作对父进程程序段、数据段、堆段以及栈段创建拷贝。的确,在一些早期的UNIX实现中,此类复制确实是原原味:将父进程内存拷贝至交换空间,以此创建新进程映像(image) ,而在父进程保持自身内存的同时,将换出映像置为子进程。不过,真要是简单地将父进程虚拟内存页拷贝到新的子进程,那就太浪费了。原因有很多,
其中之一是:fork()之后常常随着 exec(), 这会用新程序替换进程的代码段,并重新初始化其数据段、堆段和栈段。大部分现代UNIX实现(包括 Linux)采用两种技术来避免这种浪费。
- 内核(Kernel)将每一进程的代码段标记为只读,从而使进程无法修改自身代码。这样,父、子进程可共享同一代码段。系统调用 fork()在为子进程创建代码段时,其所构建的一系列进程级页表项 (page-table entries) 均指向与父进程相同的物理内存页帧。
- 对于父进程数据段、堆段和栈段中的各页,内核采用写时复制(copy-on-write)技术来处理。最初,内核做了一些设置,令这些段的页表项指向与父进程相同的物理内存页,并将这些页面自身标记为只读。调用 fork()之后,内核会获所有父进程或子进程针对这些页面的修改企图,并为将要修改的(about-to-be-modified)页面创建拷贝。系统将新的页面拷贝分配给内核获的进程,还会对子进程的相应页表项做适当调整。从这一刻起,父、子进程可以分别修改各自的页拷贝,不再相互影响。
