操作系统之进程

关于进程

进程?进程,就是运行中的程序。
操作系统为正在运行的程序提供的抽象,就是进程。
进程是由内核定义的抽象的实体,并为该实体分配用以执行程序的各项系统资源

程序是包含了一系列信息的文件, 这些信息描述了如何在运行时创建一个进程, 所包括的内容:

  • 二进制格式标识:每个程序文件都包含用于描述可执行文件格式的元信息
    (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
2
3
#include <unistd.h>

pid_t getpid(void);

除少数系统进程外,(eg:init进程 pid = 1)程序与运行该程序进程的进程号之间没有固定关系。

1
2
3
4
5
6
7
Linux内核限制进程号需小于等于 32767。新进程创建时,内核会按顺序将下一个可用的进程号分配给其使用。每当进程号达到32767 的限制时,内核将重置进程号计数器,以便从小整数开始分配。

一旦进程号达到 32767,会将进程号计数器重置为 300,而不是 1。之所以如此,是因为低数值的进程号为系统进程和守护进程长期占用,在此范围内搜索尚使用的进程号只会是浪费时间。

在 Linux2.4 版及更早版中,进程号的上限32767,由内核常量 PIDMAX 所定义。
在 Linux 2.6 版中,情况有所改变。尽管进程号的默认上限仍是32767,但可以通过 Linux
系统特有的/proc/sys/kernel/pid_max 文件来进行调整(其值=最大进程号+1) 。在 32 位平台中, pid_max 文件的最大值为 32768, 但在 64 位平台中, 该文件的最大值可以高达到222 (约400 万) ,系统可能容纳的进程数量会非常庞大。

每个进程都有一个创建自己的父进程。使用系统调用getppid()可以检索到父进程的进程号。
getppid()函数返回该进程的父进程号:

1
2
3
#include <unistd.h>

pid_t getppid(void);

如果子进程的父进程终止,则子进程就会变成“孤儿” ,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
2
3
#include <unistd.h>

pid_t fork(viod);

进程的栈、数据以及栈段开始时是对父进程内存相应各部分的完全复制。执行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)页面创建拷贝。系统将新的页面拷贝分配给内核获的进程,还会对子进程的相应页表项做适当调整。从这一刻起,父、子进程可以分别修改各自的页拷贝,不再相互影响。