Linux 进程创建与控制详解
目录
1. 什么是进程?—— 深入理解进程模型
a. 唯一的进程ID (PID)
b. 独立的虚拟地址空间
c. 进程控制块 (PCB - Process Control Block)
d. 文件描述符表
2. 经典的 fork() - exec() - wait() 模式
a. fork(): 克隆一个新进程
b. exec() 家族: 变身为一个新程序
c. wait() 和 waitpid(): 为子进程“收尸”
3. 进程的终止: exit() 和 _exit()
总结
1. 什么是进程?—— 深入理解进程模型
在 Linux 中,一个进程(Process)并不仅仅是“一个正在运行的程序”,它是一个更为丰富和具体的概念。操作系统为了管理和隔离这些运行中的程序,为每一个进程都创建了一个完整、独立的运行环境。这个环境主要由以下几个部分构成:
a. 唯一的进程ID (PID)
每个进程都有一个独一无二的非负整数标识符,称为 PID。同时,每个进程(除了系统启动的第一个进程 init
)都有一个父进程,其 PID 被称为 PPID (Parent Process ID)。这个父子关系构成了系统中的进程树。
b. 独立的虚拟地址空间
这是进程隔离的核心机制。每个进程都“认为”自己独占了整个系统的内存。这个地址空间通常被划分为几个区域:
-
文本段 (.text): 存放程序的可执行代码,只读。
-
数据段 (.data): 存放已初始化的全局变量和静态变量。
-
BSS 段 (.bss): 存放未初始化或初始化为零的全局变量和静态变量。
-
堆 (Heap): 用于动态内存分配(例如,通过
malloc()
),从低地址向高地址增长。 -
栈 (Stack): 存放局部变量、函数参数和返回地址,从高地址向低地址增长。
由于每个进程都有自己的虚拟地址空间,一个进程的内存操作(如指针访问)无法直接影响到另一个进程,从而保证了系统的稳定性。
c. 进程控制块 (PCB - Process Control Block)
这是操作系统内核中用于描述和管理进程的一个数据结构(在 Linux 中是 task_struct
)。它包含了关于进程的所有关键信息,可以看作是进程的“身份证”:
-
进程状态(运行、睡眠、僵尸等)
-
PID 和 PPID
-
CPU 寄存器的值(如程序计数器、栈指针)
-
虚拟内存映射信息
-
文件描述符表
-
用户和组 ID
当内核需要进行进程切换时,它会保存当前进程的 PCB 信息,并加载下一个要运行进程的 PCB 信息。
d. 文件描述符表
每个进程都有一张表,用于记录它打开的文件。表中的每一项是一个整数(文件描述符),指向一个内核中代表打开文件的对象。这张表的前三项通常被预留给:
-
0
: 标准输入 (stdin) -
1
: 标准输出 (stdout) -
2
: 标准错误 (stderr)
2. 经典的 fork()
- exec()
- wait()
模式
这是 UNIX/Linux 系统中创建和管理新任务的基石。几乎所有的程序启动,包括你在 Shell 中执行的每一条命令,都遵循这个模式。
a. fork()
: 克隆一个新进程
fork()
是一个非常独特的系统调用,它的作用是创建一个新进程(子进程),这个子进程几乎是调用 fork()
的进程(父进程)的完整副本。
-
返回值是关键:
fork()
被调用一次,但会返回两次。-
在父进程中,
fork()
返回新创建的子进程的 PID。 -
在子进程中,
fork()
返回0
。 -
如果创建失败,
fork()
在父进程中返回-1
。 通过检查返回值,程序可以确定当前代码是在父进程中执行还是在子进程中执行。
-
-
写时复制 (Copy-on-Write, COW):
fork()
之后,内核并不会立即复制父进程的整个物理内存空间给子进程,因为这非常低效。相反,父子进程暂时共享相同的物理内存页。只有当其中一个进程尝试写入某个内存页时,内核才会为该进程复制那个页,让它拥有自己的副本。这种优化策略使得fork()
的执行速度非常快。 -
继承了什么?:子进程继承了父进程的虚拟地址空间、文件描述符表、用户和组 ID 等大部分内容。但 PID、PPID 和某些资源(如内存锁)是独有的。
【代码示例:fork()
的使用】
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h> // 引入 wait() 所需的头文件int main() {pid_t pid = fork();if (pid < 0) {// fork 失败fprintf(stderr, "Fork Failed\n");return 1;} else if (pid == 0) {// 这里是子进程的代码printf("I am the child process.\n");printf("My PID is %d, my parent's PID is %d.\n", getpid(), getppid());printf("Child process is finishing.\n");} else {// 这里是父进程的代码printf("I am the parent process.\n");printf("My PID is %d, I created a child with PID %d.\n", getpid(), pid);// 等待子进程结束,进行回收,防止产生僵尸进程wait(NULL); printf("Parent process finished waiting and is finishing.\n");}return 0;
}
b. exec()
家族: 变身为一个新程序
fork()
只是创建了父进程的一个副本,如果想让子进程执行一个全新的程序(比如 /bin/ls
),就需要 exec()
函数族。
-
核心作用:
exec()
会用一个全新的程序镜像替换当前进程的内存空间(包括代码、数据和堆栈)。一旦调用成功,原有的程序代码就完全被覆盖了,exec()
调用之后的代码将永远不会被执行。 -
没有新进程:
exec()
不会创建新进程。它只是在当前进程(通常是fork()
出来的子进程)的上下文中加载并运行一个新程序。进程的 PID 保持不变。 -
函数族:
exec()
有多个变体,命名规则可以帮助区分它们:-
l
(execl
,execlp
): 参数以列表(list)形式逐个列出,以NULL
结尾。 -
v
(execv
,execvp
): 参数以字符串指针数组(vector)形式传递。 -
p
(execlp
,execvp
): 会在系统的PATH
环境变量指定的目录中搜索要执行的程序。 -
e
(execle
,execve
): 允许你手动传入一个环境变量数组。
-
【代码示例:结合 fork()
和 execvp()
】
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>int main() {pid_t pid = fork();if (pid < 0) {fprintf(stderr, "Fork Failed\n");return 1;} else if (pid == 0) {// 子进程printf("Child process is about to run 'ls -l'.\n");char *args[] = {"ls", "-l", NULL}; // execvp 的参数数组execvp(args[0], args);// 如果 execvp 成功,下面的代码永远不会执行perror("execvp failed"); // 如果 execvp 失败,它会返回,我们可以打印错误exit(1);} else {// 父进程printf("Parent process is waiting for the child to complete.\n");wait(NULL); // 等待子进程结束printf("Child process has finished.\n");}return 0;
}
c. wait()
和 waitpid()
: 为子进程“收尸”
进程管理中一个重要环节是资源回收。
-
僵尸进程 (Zombie Process):如果一个子进程已经终止,但其父进程还没有调用
wait()
或waitpid()
来获取它的终止状态,那么这个子进程的进程控制块(PCB)会一直保留在内核中。这个已经死亡但 PCB 仍存在的进程就被称为“僵尸进程”。它不占用内存,但会占用一个 PID,如果大量出现会耗尽系统的 PID 资源。 -
wait()
:-
阻塞父进程,直到它的任何一个子进程终止。
-
返回终止的子进程的 PID。
-
通过一个整数指针参数,可以获取子进程的退出状态。
-
-
waitpid()
:-
功能更强大,是
wait()
的超集。 -
可以等待一个指定的子进程。
-
可以通过选项参数(如
WNOHANG
)实现非阻塞等待。
-
通过调用 wait()
或 waitpid()
,父进程完成了对子进程的“收尸”(reaping),内核可以安全地释放子进程的 PCB,从而避免了僵尸进程的产生。
3. 进程的终止: exit()
和 _exit()
一个进程可以通过多种方式终止,最常见的是调用 exit()
函数。
-
退出状态码:每个进程终止时都会向其父进程返回一个 0 到 255 之间的整数,称为退出状态码。按照惯例,
0
表示成功,非0
表示发生了某种错误。父进程通过wait()
可以获取这个状态码。 -
exit()
vs_exit()
:-
exit()
是 C 标准库函数。在终止进程前,它会执行一系列清理工作:-
调用
atexit()
注册的函数。 -
刷新并关闭所有标准 I/O 库的流 (stream)(比如
stdout
的缓冲区)。 -
最后调用
_exit()
系统调用来终止进程。
-
-
_exit()
是一个系统调用。它会立即终止进程,不会进行任何清理工作。
-
为什么需要 _exit()
? 在 fork()
之后,子进程中通常建议使用 _exit()
而不是 exit()
。因为子进程复制了父进程的 I/O 缓冲区,如果调用 exit()
,父子进程可能会重复刷新和关闭相同的流,导致不可预料的行为。而在 exec()
之后,由于整个内存空间都被替换了,这个问题就不存在了。
总结
Linux 的进程创建与控制是一个优雅而强大的模型:
-
fork()
负责“生”,创建一个与父进程几乎一样的子进程。 -
exec()
负责“养”,让子进程变身为一个全新的程序,去执行新的任务。 -
wait()
负责“葬”,父进程在子进程完成后回收其资源,维持系统整洁。 -
exit()
负责“死”,进程完成任务后正常退出,并向父进程报告结果。
理解了这个生命周期,你就掌握了 Linux 多任务编程的基石。