进程
进程和程序 (Process and Program)
程序(Program)是一组指令的集合,通常存储在磁盘等静态存储介质上。它本身是静态的、被动的,不占用系统的运行资源(如CPU时间、内存等)。
进程(Process)是程序的一次执行实例。当程序被加载到内存中并开始执行时,就创建了一个进程。它是动态的、活动的,是操作系统资源分配(如内存、文件句柄)和CPU调度的基本单位(更精确的说法是将线程视为独立调度单位)。
进程的状态 (Process States)
在三态模型中,进程状态分为三个基本状态,即:运行态、就绪态、阻塞态; 在五态模型中,进程状态分为五个基本状态,即:新建态、终止态、运行态、就绪态、阻塞态;
ps 命令
ps (Process Status)
命令用于显示当前系统的进程快照,即执行命令那一刻的进程信息。通常使用ps aux来显示所有用户的(a)、包含完整格式的、包括无终端进程(x)在内的所有进程的详细状态(u)。
top 命令
top 命令用于动态显示系统中各个进程的资源占用情况,默认每隔几秒刷新一次。它提供了一个实时的视图,可以看到 CPU 和内存的使用情况,以及各个进程的状态。
kill 命令
kill 命令用于向进程发送终止进程的信号(Signal), 常见的流程为:
- 使用 ps 或 top 找到目标进程的ID(PID)。
- 执行 kill
。 - 如果进程没有响应,可以执行 kill -9
强制终止。
进程号和相关函数 (Process IDs and Related Functions)
在操作系统内部,每个进程都有唯一的标识符和关联信息。在编程中,我们可以通过特定的函数来获取这些信息。
- 进程ID (PID):每个进程都有一个唯一的非负整数标识符。
- 父进程ID (PPID):标识创建当前进程的那个进程。所有进程(除了初始的 init 进程)都是由其他进程创建的。
- 进程组ID (PGID):进程组是一个或多个进程的集合。通常,同一个作业(Job)中的所有进程属于同一个进程组。
在C语言等编程环境中,可以通过以下标准函数获取这些ID:
getpid(): 获取当前进程的ID。
原型:pid_t getpid(void);
说明:该函数不接受参数,并返回调用它的进程的PID。pid_t 通常是一个整数类型。
getppid(): 获取当前进程的父进程的ID。
原型:pid_t getppid(void);
说明:返回创建当前进程的那个父进程的PID。
getpgid(): 获取指定进程的进程组ID。
原型:pid_t getpgid(pid_t pid);
说明:如果参数 pid 为0,则返回当前进程的进程组ID。否则,返回PID为 pid 的进程的进程组ID。
进程的创建 (Process Creation)
在 Linux/Unix-like 操作系统中,创建一个新进程的主要方式是通过 fork() 系统调用。
fork() 的工作机制非常独特:它会创建一个与调用它的进程(称为父进程)几乎一模一样的新进程(称为子进程)。这个子进程是父进程的一个副本,它从 fork() 调用返回之后开始执行。
1 |
|
1 |
|
上述代码编译执行后会输出两次 “hello world”,原因在于当父进程调用 fork() 时,操作系统会创建一个几乎与父进程完全相同的子进程, 这两个进程(父进程和子进程)在创建完毕后都会从 fork() 调用处返回, 继续执行 fork() 下一行的后续代码,因此 printf(“hello world”); 这行代码会被父子进程执行两次。
循环创建进程 (Creating Processes in a Loop)
当 fork() 被放置在循环中时, 会创建多个子进程, 每个子进程又会继续执行循环体内的代码, 这可能会导致指数级增长的进程数量。
1 | for (int i = 0; i < 3; i++) { |
如上, 原始进程 P0 调用 fork(),创建子进程 C1。现在有两个进程:P0 和 C1, 在第2次循环 (i=1)中P0 和 C1 都会 继续执行循环产生共计四个进程, 接着又会产生更多进程……
也就是说, 在循环中直接调用 fork() 会导致进程数量呈指数级增长。如果不加控制,这种代码会迅速耗尽系统资源,形成所谓的“fork 炸弹”(Fork Bomb),可能导致系统崩溃。因此,在实际应用中,通常会结合 if 判断 fork() 的返回值,确保只有父进程(或特定进程)继续创建新的子进程。
1 | for (int i = 0; i < 3; i++) { |
父子进程关系 (Parent-Child Process Relationship)
通过 fork() 创建的进程之间形成了明确的父子关系,这种关系是进程管理的基础。子进程在创建时会继承父进程的大部分资源和属性,包括:
- 进程地址空间的副本(下文详述)。
- 打开的文件描述符(File Descriptors)(因此也共享文件偏移量)。
- 环境变量。
- 当前工作目录。
- 用户ID (UID) 和组ID (GID)。
另一方面, 虽然子进程是父进程的副本,但它们是两个独立的进程,拥有自己独特的属性:
- 进程ID (PID):子进程有自己唯一的 PID。
- 父进程ID (PPID):子进程的 PPID 是其父进程的 PID。
- 资源统计:CPU时间、内存使用等资源消耗是独立计算的。
父进程的责任:父进程通常需要负责“回收”子进程的资源。当子进程结束后,它会变成一个僵尸进程 (Zombie Process),直到父进程通过 wait() 或 waitpid() 系统调用获取其退出状态后,子进程才会彻底消失。如果父进程不执行此操作,僵尸进程会一直占用系统资源。
父子进程地址空间 (Parent-Child Address Space)
进程地址空间是指进程可以访问的内存区域,包括代码段、数据段、堆和栈。父子进程的地址空间遵循以下几个原则:
独立性原则:fork() 之后,父进程和子进程拥有各自独立的地址空间。任何一方对其地址空间中的数据(如变量)进行修改,都不会影响到另一方。
读时共享, 写时复制 (Copy-on-Write, COW): 虽然逻辑上地址空间是独立的,但操作系统为了提高效率,并不会在 fork() 时立即完整地复制父进程的所有物理内存页给子进程。
相反, 当 fork() 创建子进程时,子进程的虚拟地址空间与父进程相同,并且它们共享相同的物理内存页。这些内存页被内核标记为“只读”。
当父进程或子进程尝试写入某个共享的内存页时,会触发一个缺页异常 (Page Fault)。内核捕获到这个异常,此时才会为写入方复制一份该内存页的副本,并将该副本映射到其地址空间,然后执行写入操作。
这些原则使得 fork() 的创建速度非常快,因为它避免了大量不必要的内存复制; 同时还兼具智能性:如果子进程创建后立即调用 exec() 系列函数来执行一个新程序(这是一种非常常见的模式),那么之前的地址空间会被完全替换。COW 机制避免了在这种情况下进行无用的内存复制,极大地提升了系统性能。
1 | // 父子进程地址空间 |
可以看到, 在 fork() 之前,父子进程共享变量 var 的内存页; 父进程执行 var++; 将其私有 var 从 88 改为 89。此时,COW 触发,内核为父进程分配了一个新的内存页来存放父进程的 var=89, 而子进程的 var 仍然指向原来的内存页,值仍为 88。父子进程各自拥有独立的 var 变量,互不影响。
进程的终止 (Process Termination)
进程终止是指一个进程完成其任务并退出运行的过程。进程可以通过多种方式终止,包括正常退出和异常终止。
进程的正常退出通常是通过调用 exit() 函数或者 _exit() 函数来实现的。
1 |
|
这两者的主要区别在于,exit() 是一个C 标准库 libc 中的库函数, 会执行标准I/O缓冲区的清理工作(如刷新缓冲区、关闭文件等),而 **_exit()** 是一个系统调用,由内核直接处理, 直接终止进程,不进行任何清理操作。
因此, exit()主要用于进程的正常退出, 清理资源; 而 _exit() 通常用于在子进程中调用,以确保子进程终止时不会影响父进程的资源状态。
等待子进程退出的函数
当一个子进程结束其生命周期时,它的父进程有责任去“回收”它。这个回收过程主要有两个目的:
- 获取子进程的退出状态:父进程可以了解到子进程是正常结束的,还是因为某个错误或信号而异常终止的,以及它的返回值是什么。
- 清理子进程的资源:通过执行等待函数,父进程通知内核它已经知晓子进程的状态,内核此时可以彻底释放子进程在进程表中占用的条目和其他资源。
如果父进程不执行这个回收操作,那么已经终止的子进程将变成一个僵尸进程,持续占用系统资源。因此,使用 wait() 或 waitpid() 函数是健壮的并发程序设计中必不可少的一环。
wait() 函数
wait 函数是的最基本的方法
1 |
|
waitpid() 函数
waitpid 函数是 wait 函数的一个更强大、更灵活的版本。它可以等待指定的子进程,并且可以选择非阻塞的方式进行等待。
1 |
|
僵尸进程 & 孤儿进程 (Zombie Process & Orphan Process)
这两个概念是理解进程生命周期管理的关键。
僵尸进程 (Zombie Process)
定义:一个已经执行完毕、终止运行,但其父进程尚未通过 wait() 或 waitpid() 回收它的进程。
产生原因:子进程先于父进程结束, 子进程结束后,其在内核进程表中的条目(包含PID、退出状态等信息)需要被保留,直到父进程读取这些信息。
状态:在 ps 或 top 命令中,状态通常显示为 Z (Zombie)。
危害:僵尸进程本身不占用CPU或内存,但它会占用进程表中的一个位置(PID)。如果系统中存在大量僵尸进程,可能会耗尽可用的PID资源,导致无法创建新进程。
解决方法:父进程必须调用 wait 或 waitpid 来回收子进程。如果父进程异常退出,其子僵尸进程会被 init 进程(PID为1)接管并自动回收。
孤儿进程 (Orphan Process)
定义:一个父进程已经终止,但它自身还在运行的子进程。
产生原因:父进程先于子进程结束。
生命周期:当一个进程变成孤儿进程后,为了确保它最终能被回收,操作系统会将其过继给 init 进程(在现代系统中可能是 systemd),其PPID变为1。
“领养”过程:init 进程会自动成为所有孤儿进程的新的父进程。init 进程有一个循环,会定期调用 wait 来回收它所有已终止的子进程(包括它领养的这些孤儿进程)。
结论:孤儿进程不会对系统造成危害,因为它们会被 init 进程妥善管理和回收,不会变成僵尸进程。
进程替换 (Process Replacement)
在 Unix/Linux 系统中,进程替换是指一个进程通过调用进程替换函数来加载并执行一个新的程序,从而替换掉当前进程的地址空间、代码和数据, 同时保留其原有的进程ID和其他系统资源(如文件描述符、信号处理程序等),从而实现了进程的动态更新。
常用的进程替换函数是 exec 函数族. 在替换后, 以下几个关键点需要注意:
- 进程 ID 不变: 这是 exec 与 fork 的最大区别。fork 创建一个新进程,exec 是在当前进程上加载新程序。
- 地址空间替换: 当前进程的整个用户区地址空间(包括 .text、.data、堆、栈等)都会被新的程序代码和数据覆盖和替换。
- 成功不返回: 如果 exec 函数调用成功,新的程序将从其自己的 main() 函数开始执行,exec 函数永远不会返回。只有在 exec 调用失败时,它才会返回 −1。
- 文件描述符保留: 默认情况下,所有打开的文件描述符(包括标准输入 FD 0、标准输出 FD 1 等)都会被新程序继承。
exec 族函数有六个主要成员(execl、execlp、execle、execv、execvp、execvpe),它们的区别主要在于如何传递参数和如何查找程序: | 函数名 | 参数传递方式 | 环境变量传递 | 程序查找方式 | |——–|—————-|———–|——————-| | execl | 可变参数列表(l) | 使用当前环境变量 | 需要提供完整路径 | | execlp | 可变参数列表(l) | 使用当前环境变量 | 使用 PATH 环境变量查找程序(p) | | execle | 可变参数列表(l) | 需要提供环境变量(e) | 需要提供完整路径 | | execv | 参数数组(v) | 使用当前环境变量 | 需要提供完整路径 | | execvp | 参数数组(v) | 使用当前环境变量 | 使用 PATH 环境变量查找程序(p) | | execvpe| 参数数组(v) | 需要提供环境变量(e) | 使用 PATH 环境变量查找程序(p) |
execlp() 函数
该函数通常用来调用系统的可执行程序,如:cat、ls、date
1 |
|
1 |
|
这是因为 exec 的本质是进程替换(Process Replacement),而不是函数调用或程序跳转。一旦 exec 函数成功后,当前进程的地址空间已经被新程序完全替换,这个进程的PC地址被设置为新进程的的 main() 函数。因此,任何在 exec 调用之后的代码都不会被执行,除非 exec 调用失败。
不过, 原先的父进程通常仍然需要 wait(或 waitpid) 来等待子进程结束并回收资源。因为父子关系仍然存在,新程序执行完毕后,它也会调用 exit() 或 return,导致子进程终止, 进而影响父进程。
execl() 函数
1 |
|
1 |
|
fork 和 exec 的协作
在 Linux 中,启动一个新的程序(例如在 Shell 中调用 ls.exe)通常是一个两步过程,它们必须配合使用:
- fork(): 父进程(这里的shell)调用 fork,创建一个子进程。
- exec(): 子进程调用 exec 族函数,将自己替换成新的程序(如 ls)。
- 父进程等待: 父进程通常会调用 wait() 等待子进程执行完毕并退出。
这种 fork + exec 模式是 Linux/Unix 操作系统构建命令行环境和启动新应用的基石。