进程

ZaynPei Lv6

进程和程序 (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 强制终止。

在操作系统内部,每个进程都有唯一的标识符和关联信息。在编程中,我们可以通过特定的函数来获取这些信息。

  • 进程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
2
3
4
5
6
7
8
9
#include <unistd.h>
pid_t fork(void);
功能:
从一个已存在的进程中创建一个新进程(子进程), 该子进程是调用进程(父进程)的一个副本
参数:

返回值:
成功时, 在父进程中返回子进程的 PID, 在子进程中返回 0
失败时, 返回 -1, 并设置 errno
1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
//创建子进程
fork();
printf("hello world\n");

return 0;
}

上述代码编译执行后会输出两次 “hello world”,原因在于当父进程调用 fork() 时,操作系统会创建一个几乎与父进程完全相同的子进程, 这两个进程(父进程和子进程)在创建完毕后都会从 fork() 调用处返回, 继续执行 fork() 下一行的后续代码,因此 printf(“hello world”); 这行代码会被父子进程执行两次。

循环创建进程 (Creating Processes in a Loop)

当 fork() 被放置在循环中时, 会创建多个子进程, 每个子进程又会继续执行循环体内的代码, 这可能会导致指数级增长的进程数量

1
2
3
4
for (int i = 0; i < 3; i++) {
fork();
// ...
}

如上, 原始进程 P0 调用 fork(),创建子进程 C1。现在有两个进程:P0 和 C1, 在第2次循环 (i=1)中P0 和 C1 都会 继续执行循环产生共计四个进程, 接着又会产生更多进程……

也就是说, 在循环中直接调用 fork() 会导致进程数量呈指数级增长。如果不加控制,这种代码会迅速耗尽系统资源,形成所谓的“fork 炸弹”(Fork Bomb),可能导致系统崩溃。因此,在实际应用中,通常会结合 if 判断 fork() 的返回值,确保只有父进程(或特定进程)继续创建新的子进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid > 0) {
// 父进程继续创建子进程
continue;
} else if (pid == 0) {
// 子进程跳出循环,避免继续创建子进程
break;
} else {
// 处理 fork() 失败的情况
perror("fork failed");
exit(1);
}
}

父子进程关系 (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 机制避免了在这种情况下进行无用的内存复制,极大地提升了系统性能。

COW机制的示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 父子进程地址空间
int main(void)
{
int var = 88;
pid_t pid = -1;

// 创建一个子进程
pid = fork();
if (-1 == pid)
{
perror("fork");
return 1;
}

if (0 == pid)
{
// 子进程
sleep(1);
printf("子进程睡醒之后 var = %d\n", var); //88
}
else
{
// 父进程
printf("父进程之前 var = %d\n", var); //88
var++;
printf("父进程之后 var = %d\n", var); //89
}

return 0;
}

可以看到, 在 fork() 之前,父子进程共享变量 var 的内存页; 父进程执行 var++; 将其私有 var 从 88 改为 89。此时,COW 触发,内核为父进程分配了一个新的内存页来存放父进程的 var=89, 而子进程的 var 仍然指向原来的内存页,值仍为 88。父子进程各自拥有独立的 var 变量,互不影响。

进程的终止 (Process Termination)

进程终止是指一个进程完成其任务并退出运行的过程。进程可以通过多种方式终止,包括正常退出和异常终止。

进程的正常退出通常是通过调用 exit() 函数或者 _exit() 函数来实现的。

1
2
3
4
5
6
7
8
9
#include <stdlib.h>
void exit(int status);

#include <unistd.h>
void _exit(int status);
功能:
终止调用进程, 并将状态码 status 返回给父进程
参数:
status: 进程的退出状态码, 一般传入0表示正常退出

这两者的主要区别在于,exit() 是一个C 标准库 libc 中的库函数, 会执行标准I/O缓冲区的清理工作(如刷新缓冲区、关闭文件等),而 **_exit()** 是一个系统调用,由内核直接处理, 直接终止进程,不进行任何清理操作。

因此, exit()主要用于进程的正常退出, 清理资源; 而 _exit() 通常用于在子进程中调用,以确保子进程终止时不会影响父进程的资源状态

等待子进程退出的函数

当一个子进程结束其生命周期时,它的父进程有责任去“回收”它。这个回收过程主要有两个目的:

  • 获取子进程的退出状态:父进程可以了解到子进程是正常结束的,还是因为某个错误或信号而异常终止的,以及它的返回值是什么。
  • 清理子进程的资源:通过执行等待函数,父进程通知内核它已经知晓子进程的状态,内核此时可以彻底释放子进程在进程表中占用的条目和其他资源。

如果父进程不执行这个回收操作,那么已经终止的子进程将变成一个僵尸进程,持续占用系统资源。因此,使用 wait() 或 waitpid() 函数是健壮的并发程序设计中必不可少的一环。

wait() 函数

wait 函数是的最基本的方法

1
2
3
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *wstatus);
功能: 阻塞父进程(调用进程), 直到其任意一个子进程终止 参数: wstatus: 一个指向int类型的指针, 是传出参数, 用于存储子进程的退出状态信息. 如果不关心子进程的退出状态, 可以传入 NULL. 我们可以使用一组宏来解析这个状态值: WIFEXITED(wstatus):如果子进程是正常退出的,则返回真。 WEXITSTATUS(wstatus):如果 WIFEXITED 为真,这个宏会返回子进程的退出码(即 main 函数的 return 值或 exit() 的参数)。 WIFSIGNALED(wstatus):如果子进程是因信号而异常终止的,则返回真。 WTERMSIG(wstatus):如果 WIFSIGNALED 为真,这个宏会返回导致子进程终止的信号编号。 返回值: 成功时, 返回终止的子进程的 PID 失败时, 返回 -1, 并设置 errno

waitpid() 函数

waitpid 函数是 wait 函数的一个更强大、更灵活的版本。它可以等待指定的子进程,并且可以选择非阻塞的方式进行等待。

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *wstatus, int options);
功能: 等待指定的子进程终止, 或者在非阻塞模式下检查子进程的状态 参数: pid: 指定要等待的子进程的 PID。它有几种特殊的取值: pid > 0: 等待 PID 等于 pid 的子进程。 pid == 0: 等待与调用进程在同一进程组中的任意子进程。 pid < -1: 等待进程组 ID 等于 -pid 的任意子进程。 pid == -1: 等同于 wait(),等待任意子进程。 wstatus: 一个指向 int 类型的指针, 用于存储子进程的退出状态信息. 如果不关心子进程的退出状态, 可以传入 NULL. options: 控制 waitpid 行为的选项, 常用的选项包括: WNOHANG: 非阻塞模式, 如果当前没有子进程终止, 则立即返回0, 而不是阻塞等待. WUNTRACED: 如果子进程被停止(如接收到 SIGSTOP 信号), 也会返回其状态. 返回值: 成功时, 返回终止的子进程的 PID. 如果使用 WNOHANG 选项且没有子进程终止, 则返回 0 失败时, 返回 -1, 并设置 errno

僵尸进程 & 孤儿进程 (Zombie Process & Orphan Process)

这两个概念是理解进程生命周期管理的关键。

僵尸进程 (Zombie Process)

  • 定义:一个已经执行完毕终止运行,但其父进程尚未通过 wait() 或 waitpid() 回收它的进程。

  • 产生原因:子进程先于父进程结束, 子进程结束后,其在内核进程表中的条目(包含PID、退出状态等信息)需要被保留,直到父进程读取这些信息。

  • 状态:在 ps 或 top 命令中,状态通常显示为 Z (Zombie)。

  • 危害:僵尸进程本身不占用CPU或内存,但它会占用进程表中的一个位置(PID)。如果系统中存在大量僵尸进程,可能会耗尽可用的PID资源,导致无法创建新进程。

  • 解决方法:父进程必须调用 waitwaitpid 来回收子进程。如果父进程异常退出,其子僵尸进程会被 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
2
3
4
5
6
7
8
9
10
#include <unistd.h>
int execlp(const char *file,const char *arg,...)
功能:
用于在当前进程中执行一个新的程序, 替换当前进程的地址空间
参数:
file: 要执行的程序的文件名, 如果不包含路径, 则会在 PATH 环境变量指定的目录中查找
arg: 可变参数列表, 代表传递给新程序的参数. 第一个参数是程序的名称, 后面的参数是传递给程序的命令行参数. 最后一个参数必须是 NULL, 以标识参数列表的结束.
返回值:
成功时, 不返回, 因为当前进程的地址空间被新程序替换
失败时, 返回 -1, 并设置 errno
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
pid_t pid = fork();
if(pid < 0)
{
//fork失败
perror("fork");
exit(1);
}
else if(pid == 0)
{
//子进程
execlp("ls","ls","-l","-h",NULL); // 第一个是新进程的文件名, 第二个是传递给新进程的argv[0], 后面是参数列表, 最后一个必须是NULL
// 因为执行ls时第一个参数也是ls, 所以这里共传递两个"ls"
perror("execlp error"); //如果execlp成功,下面的不执行
exit(1);
}
else if(pid > 0)
{
//父进程
sleep(1);
printf("我是父进程:%d\n",getpid());
wait(NULL); //等待子进程退出
}
return 0;
}
需要注意的是, exec 函数族中的任何一个函数(如 execlp, execl 等)执行成功,它将永远不会返回到调用它的原函数或原程序。

这是因为 exec 的本质是进程替换(Process Replacement),而不是函数调用或程序跳转。一旦 exec 函数成功后,当前进程的地址空间已经被新程序完全替换,这个进程的PC地址被设置为新进程的的 main() 函数。因此,任何在 exec 调用之后的代码都不会被执行,除非 exec 调用失败。

不过, 原先的父进程通常仍然需要 wait(或 waitpid) 来等待子进程结束并回收资源。因为父子关系仍然存在,新程序执行完毕后,它也会调用 exit() 或 return,导致子进程终止, 进而影响父进程。

execl() 函数

1
2
3
4
5
6
7
8
9
10
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
功能:
用于在当前进程中执行一个新的程序, 替换当前进程的地址空间
参数:
path: 要执行的程序的完整路径, 通常用来执行自己当前目录下的可执行文件
arg: 可变参数列表, 代表传递给新程序的参数. 第一个参数是程序的名称, 后面的参数是传递给程序的命令行参数. 最后一个参数必须是 NULL, 以标识参数列表的结束.
返回值:
成功时, 不返回, 因为当前进程的地址空间被新程序替换
失败时, 返回 -1, 并设置 errno
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
pid_t pid = fork();
if(pid < 0)
{
//fork失败
perror("fork");
exit(1);
}
else if(pid == 0)
{
//子进程
execl("./wait","wait",NULL);
perror("execlp error"); //如果execl成功,下面的不执行
exit(1);
}
else if(pid > 0)
{
//父进程
sleep(1);
printf("我是父进程:%d\n",getpid());
wait(NULL); //等待子进程退出
}
return 0;
}

fork 和 exec 的协作

在 Linux 中,启动一个新的程序(例如在 Shell 中调用 ls.exe)通常是一个两步过程,它们必须配合使用:

  • fork(): 父进程(这里的shell)调用 fork,创建一个子进程。
  • exec(): 子进程调用 exec 族函数,将自己替换成新的程序(如 ls)。
  • 父进程等待: 父进程通常会调用 wait() 等待子进程执行完毕并退出。

这种 fork + exec 模式是 Linux/Unix 操作系统构建命令行环境和启动新应用的基石。