二:操作系统之进程的创建与终止
进程的生与死:深入解析操作系统的进程创建与终止
在前两篇博客中,我们了解了进程的概念——程序的一次执行实例,以及操作系统如何使用进程控制块 (PCB) 来管理进程。今天,我们将聚焦于进程生命周期的起点和终点:进程的创建与终止。理解这两个过程,是掌握操作系统如何管理和调度任务的基础。
1. 进程的创建 (Process Creation)
一个新进程是如何诞生的呢?就像生物需要繁殖一样,进程通常也是由已存在的进程创建出来的。创建新进程的进程称为父进程 (Parent Process),被创建的进程称为子进程 (Child Process)。
1.1 进程创建的原因
操作系统在以下几种情况下会创建新进程:
- 系统初始化 (System Boot): 操作系统启动时,会创建一些初始进程,例如
init
(Linux/Unix) 或System
(Windows),它们是系统中所有后续进程的祖先。 - 用户请求 (User Request): 用户通过命令行输入命令(如
ls
,gcc
),或者双击应用程序图标,操作系统会为用户运行的程序创建一个新进程。 - 程序执行时创建 (Program Execution): 运行中的进程可以通过系统调用创建新的子进程,以便并行执行任务。例如,一个 Web 服务器进程可能会为每个到来的客户端请求创建一个新的子进程来处理。
- 批处理作业初始化 (Batch Job Initiation): 在批处理系统中,操作系统会为每个提交的作业创建一个进程。
1.2 进程创建的过程
当一个进程请求创建新进程时,操作系统内核会执行以下主要步骤:
- 分配唯一的进程标识符 (PID): 为新进程分配一个系统中唯一的 PID。
- 分配并初始化 PCB: 为新进程分配一个 PCB,并初始化其中的信息。这包括设置进程状态为“新建”,将父进程的 PID 记录下来,初始化寄存器和程序计数器(通常指向程序的起始地址),设置进程优先级等。
- 分配地址空间: 为新进程分配独立的内存地址空间,包括代码段、数据段、堆和栈。
- 加载程序到内存: 将要执行的程序的指令和数据从磁盘加载到分配的内存空间中。
- 建立父子关系: 在父进程和子进程的 PCB 中记录彼此的关联信息。
- 加入就绪队列: 将新创建的进程的 PCB 加入到系统的就绪队列中,等待 CPU 调度。
1.3 关键系统调用:fork()
和 exec()
(以类 Unix 系统为例)
在类 Unix 系统(如 Linux, macOS)中,进程创建通常通过两个重要的系统调用协作完成:fork()
和 exec()
系列。理解它们是理解进程创建的关键。
-
fork()
系统调用:-
功能: 创建一个当前进程的几乎完全相同的副本。新的进程被称为子进程,调用
fork()
的进程被称为父进程。 -
返回值:
fork()
返回一个整数。- 在父进程中,
fork()
返回新创建的子进程的 PID。 - 在子进程中,
fork()
返回 0。 - 如果创建失败,
fork()
返回 -1。
- 在父进程中,
-
重要特性:
- 子进程拥有父进程数据段、堆、栈的副本(通常使用写时复制/Copy-On-Write, COW 技术,只有当父子进程试图修改同一块内存时,才会真正进行复制,提高了效率)。
- 子进程继承父进程打开的文件描述符、信号处理设置等大部分资源。
- 父子进程从此独立运行,拥有各自的 PID、PPID(子进程的 PPID 是父进程的 PID)。
-
举例:
#include <stdio.h> #include <unistd.h> // for fork() #include <sys/types.h> // for pid_tint main() {pid_t pid;printf("Before fork. My PID is %d\n", getpid()); // getpid() 获取当前进程PIDpid = fork(); // 创建子进程if (pid == -1) {perror("fork failed"); // 错误处理return 1;} else if (pid == 0) { // 这里是子进程的代码printf("I am the child process. My PID is %d, my parent's PID is %d\n", getpid(), getppid());// 子进程会从 fork() 返回后继续执行} else { // 这里是父进程的代码printf("I am the parent process. My PID is %d, my child's PID is %d\n", getpid(), pid);// 父进程也会从 fork() 返回后继续执行}printf("This line is printed by both processes. PID: %d\n", getpid()); // 验证两个进程都会执行到这里return 0; }
运行这段代码,你会看到输出中,父进程和子进程都打印了各自的信息,并且父进程打印了子进程的 PID,子进程打印了父进程(即原来的父进程)的 PID。最下面的打印语句会被输出两次,一次由父进程,一次由子进程。
-
-
exec()
系列系统调用: (execl
,execv
,execlp
,execvp
, etc.)-
功能: 用一个新的程序替换当前进程的映像(代码、数据、堆、栈)。进程的 PID 不变,但其内存内容和执行代码全部被新程序覆盖。
-
为什么需要与
fork()
结合:fork()
创建了当前程序的副本,而我们通常希望子进程运行的是另一个不同的程序(比如 Shell 调用fork()
创建子进程,然后让子进程运行ls
命令)。exec()
就是用来实现“运行另一个程序”这个功能的。 -
返回值:
exec()
系列函数正常执行不会返回!如果它们返回了,那就说明发生了错误(例如找不到要执行的程序或权限问题)。 -
举例: (通常在
fork()
后的子进程中使用)#include <stdio.h> #include <unistd.h> // for fork() and execlp() #include <sys/types.h> // for pid_t #include <sys/wait.h> // for wait()int main() {pid_t pid;pid = fork();if (pid == -1) {perror("fork failed");return 1;} else if (pid == 0) { // 子进程printf("Child process (PID %d) starting to execute 'ls'...\n", getpid());// 使用 execlp 执行 /bin/ls 命令// 第一个参数是命令名 (会在PATH中查找)// 后面的参数是传递给命令的命令行参数列表,以 NULL 结束execlp("ls", "ls", "-l", "/", NULL);// 如果 execlp 返回了,说明执行失败perror("execlp failed");return 1; // 子进程通过 return 或 exit() 终止} else { // 父进程printf("Parent process (PID %d) waiting for child (PID %d)...\n", getpid(), pid);// 父进程等待子进程终止wait(NULL); // wait(NULL) 等待任意一个子进程终止printf("Child process terminated. Parent exiting.\n");}return 0; }
运行这段代码,父进程会打印它在等待子进程,子进程会打印它准备执行
ls
,然后,你会看到根目录/
下的文件列表(这是ls -l /
的输出),最后父进程会打印子进程已终止的信息。子进程中execlp
后面的perror
和return 1
通常不会被执行到,除非ls
命令执行失败。
-
总结 fork()
和 exec()
: fork()
是用来“生孩子”,复制父进程的基因(代码和数据)并拥有独立的生命;exec()
是让“孩子”换个身体,运行一个全新的程序,但这个“孩子”的身份(PID)不变。两者经常配合使用来实现“在一个新的进程中运行另一个程序”。
2. 进程的终止 (Process Termination)
进程的生命旅程总会走到终点。进程终止后,它所占用的系统资源需要被释放,以便其他进程可以使用。
2.1 进程终止的原因
进程终止可能由以下原因引起:
- 正常退出 (Normal Exit):
- 程序执行完毕,完成了任务。
- 调用
exit()
系统调用(在 C/C++ 中)。 - 从
main()
函数返回。 - 举例: 编译程序完成编译后退出;文本编辑器保存文件并关闭窗口。
- 异常退出 (Abnormal Exit):
- 程序运行时发生错误,无法继续执行。
- 出现了致命错误,如除以零、访问非法内存地址 (segmentation fault)、堆栈溢出。
- 程序收到信号(如
SIGKILL
,SIGTERM
)被强制终止。 - 举例: 尝试访问一个不存在的文件或没有权限的文件导致错误;程序逻辑错误导致死循环或无限递归耗尽资源;用户在任务管理器中结束进程。
- 父进程终止子进程:
- 父进程发现子进程出错或不再需要子进程时,可以使用系统调用(如
kill()
发送信号)来终止子进程。 - 举例: Shell 在用户按下 Ctrl+C 时会向当前前台进程发送终止信号。
- 父进程发现子进程出错或不再需要子进程时,可以使用系统调用(如
2.2 进程终止的过程
无论何种原因终止,操作系统内核都会执行以下主要步骤:
- 关闭所有打开的文件和设备: 释放进程占用的文件描述符和设备资源。
- 释放占用的内存: 回收进程的代码段、数据段、堆、栈以及 PCB 所占用的内存空间。
- 释放其他资源: 释放进程可能占有的其他资源,如锁、信号量等。
- 向父进程报告终止状态: 将进程的退出状态码(表明正常或异常退出以及具体原因)发送给其父进程。
- 将进程状态设置为“终止”: PCB 中的状态字段会被更新为终止。在某些系统中,PCB 不会立即被完全移除,而是保留一些信息(主要是 PID 和退出状态)直到父进程读取它们。这样的进程被称为僵尸进程 (Zombie Process)。
- 从系统进程列表中移除 PCB (最终): 当父进程读取了子进程的退出状态(通过
wait()
或waitpid()
系统调用)后,操作系统会完全移除子进程的 PCB。
2.3 关键系统调用:exit()
和 wait()
(以类 Unix 系统为例)
-
exit()
和_exit()
系统调用:-
功能: 使当前进程立即终止执行。
-
参数: 通常接受一个整数作为退出状态码。惯例是 0 表示成功,非 0 表示失败或特定错误。
-
区别 (
exit
vs_exit
):exit()
是 C 标准库函数,它会在调用内核的_exit()
系统调用之前执行一些清理工作,比如刷新缓冲的输出流(确保printf
输出真正显示出来)、调用通过atexit()
注册的函数等。_exit()
是更底层的系统调用,它立即终止进程,不进行这些用户空间的清理。在多线程程序中直接使用exit()
可能导致问题,通常推荐在子进程中直接使用_exit()
。 -
举例:
#include <stdlib.h> // for exit() #include <unistd.h> // for _exit()int main() {printf("Doing some work...\n");// ... 程序逻辑 ...if (/* 发生错误 */) {perror("An error occurred");exit(EXIT_FAILURE); // 使用 exit() 并返回非零状态码表示失败// 或者 _exit(1); 如果不需要C库的清理}printf("Work finished successfully.\n");exit(EXIT_SUCCESS); // 使用 exit() 并返回零状态码表示成功// 或者 _exit(0);return 0; // 从 main() 返回 0 等价于调用 exit(0) }
-
-
wait()
和waitpid()
系统调用:-
功能: 父进程调用这些系统调用来等待其某个子进程终止,并获取子进程的退出状态。这是回收子进程资源、避免僵尸进程的关键。
-
wait()
: 阻塞父进程,直到它的一个子进程终止。 -
waitpid()
: 更灵活,可以指定等待哪个子进程 (通过 PID),可以配置为非阻塞模式,可以获取更多状态信息。 -
举例: (见前面
fork()
和exec()
结合的例子,父进程中调用了wait(NULL)
)#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> // for waitpid() and macrosint main() {pid_t pid;int status; // 用于接收子进程的退出状态pid = fork();if (pid == -1) {perror("fork failed");return 1;} else if (pid == 0) { // 子进程printf("Child process (PID %d) running...\n", getpid());// 子进程执行一些任务...sleep(2); // 模拟工作printf("Child process exiting with status 42.\n");exit(42); // 子进程退出,返回状态码 42} else { // 父进程printf("Parent process (PID %d) waiting for child (PID %d)...\n", getpid(), pid);// 等待指定的子进程终止,WUNTRACED 和 WCONTINUED 选项可以用于处理子进程暂停/继续的情况// 0 参数表示阻塞等待waitpid(pid, &status, 0);printf("Child process (PID %d) terminated.\n", pid);// 检查子进程的终止状态if (WIFEXITED(status)) { // WIFEXITED 宏检查子进程是否正常退出printf("Child exited normally with status %d\n", WEXITSTATUS(status)); // WEXITSTATUS 宏获取正常退出的状态码} else if (WIFSIGNALED(status)) { // WIFSIGNALED 宏检查子进程是否被信号终止printf("Child terminated by signal %d\n", WTERMSIG(status)); // WTERMSIG 宏获取信号编号}}return 0; }
运行这段代码,父进程会等待子进程,子进程运行2秒后以状态码42退出,父进程会捕获到这个状态码并打印出来。
-
2.4 僵尸进程与孤儿进程 (Zombie and Orphan Processes)
- 僵尸进程 (Zombie Process): 当子进程终止后,其 PCB 并不会立即被操作系统完全回收,而是保留下来,直到其父进程调用
wait()
或waitpid()
来读取其退出状态。在这个等待父进程收集状态期间的子进程就被称为僵尸进程。它们不占用 CPU 和内存(除了 PCB),但在进程列表中依然可见(状态通常显示为Z
或defunct
)。过多的僵尸进程会占用 PCB 资源。如果父进程在子进程成为僵尸进程之前终止,那么这个僵尸进程会被init
进程领养,init
进程会负责调用wait()
回收其 PCB。 - 孤儿进程 (Orphan Process): 当父进程在子进程之前终止时,子进程就变成了孤儿进程。这些孤儿进程会被系统中的
init
进程(PID 为 1)领养。init
进程会负责wait()
它们的终止,避免它们成为僵尸进程。
总结
进程的创建与终止是操作系统最基础也是最重要的功能之一。新进程通过系统调用(如类 Unix 的 fork()
和 exec()
组合)由现有进程创建,形成了父子关系。进程的终止可以是正常完成任务,也可以是异常中断或被外部强制停止,最终都会通过系统调用(如 exit()
)向操作系统报告。父进程需要通过 wait()
系统调用来回收子进程的资源并获取其退出状态,避免僵尸进程的产生。