本文把视角从"程序"提升到"进程"--- Linux 中进程控制 的四个核心操作:创建、等待、替换和终止。
这是系统编程的心脏地带。理解它们,就能明白 shell 是如何执行敲下的每一条命令的,也能写出自己的进程管理工具。
1. 进程与程序的区别
先厘清一个基本概念:
-
程序:磁盘上的可执行文件,是静态的指令集合。
-
进程:正在运行的程序实例,是动态的,拥有独立的地址空间、打开的文件、信号处理等资源。
同一个程序可以对应多个进程(比如打开多个终端运行同一个 bash)。内核为每个进程维护一个 PCB(进程控制块) ,Linux 中对应 task_struct,记录进程的所有状态。
每个进程都有一个唯一的 PID(Process ID) ,还有 PPID(Parent PID) 表示父进程。用 ps 命令可以查看:
ps -ef # 查看所有进程
echo $$ # 查看当前 shell 的 PID
2. 进程创建:fork
2.1 fork 的基本语义
fork 是 Linux 创建进程的唯一方式。它从当前进程"分裂"出一个几乎完全相同的子进程。
#include <unistd.h>
#include <sys/types.h>
pid_t fork(void);
返回值是关键:
-
父进程中返回子进程的 PID(> 0)
-
子进程中返回 0
-
出错返回 -1
2.2 最简单的 fork 示例
#include <iostream>
#include <unistd.h>
int main() {
std::cout << "fork 之前,只有一个进程,PID = " << getpid() << "\n";
pid_t pid = fork();
if (pid < 0) {
std::cerr << "fork 失败\n";
return 1;
} else if (pid == 0) {
// 子进程
std::cout << "我是子进程,PID = " << getpid()
<< ",我的父进程 PID = " << getppid() << "\n";
} else {
// 父进程
std::cout << "我是父进程,PID = " << getpid()
<< ",我创建的子进程 PID = " << pid << "\n";
}
std::cout << "这句会被父子进程各执行一次\n";
return 0;
}
运行结果:
fork 之前,只有一个进程,PID = 12345
我是父进程,PID = 12345,我创建的子进程 PID = 12346
这句会被父子进程各执行一次
我是子进程,PID = 12346,我的父进程 PID = 12345
这句会被父子进程各执行一次
2.3 fork 之后发生了什么?
fork 成功后,子进程获得父进程地址空间的副本(写时复制,Copy-On-Write,即读时共享,写时才真正复制页面),以及父进程打开的文件描述符副本。但以下几点不同:
-
PID 不同
-
PPID 不同(子进程的父进程是调用 fork 的那个进程)
-
未决信号和定时器被清空
-
资源使用计数归零
写时复制(COW) 是一个非常精妙的设计:fork 时并不立即复制整个地址空间,而是让父子进程共享同一份物理内存页,只有任一方尝试写入时,才复制那一页。这让 fork 变得非常高效。
2.4 父进程与子进程的执行顺序
父子进程的执行顺序是不确定的 ,由调度器决定。如果想让某一方先执行,需要用 sleep、wait 或信号来同步。
2.5 文件描述符的继承
子进程会继承父进程所有打开的文件描述符,它们指向相同的文件表项(共享文件偏移量)。这解释了很多经典现象。
因为父子进程共享文件偏移量,两次写入不会互相覆盖,而是先后追加。
3. 进程等待:wait 与 waitpid
3.1 为什么需要等待?
子进程退出后,它的资源(如 PCB)不会立即释放,需要父进程回收。如果父进程不回收,子进程会变成僵尸进程(zombie),占用系统资源。
等待的另一个目的是:获取子进程的退出状态,知道它是正常结束还是异常崩溃。
3.2 wait
#include <sys/wait.h>
pid_t wait(int* status);
-
阻塞等待任意一个子进程退出。
-
返回退出的子进程 PID,
status中存放退出信息。 -
没有子进程时返回 -1。
3.3 waitpid
pid_t waitpid(pid_t pid, int* status, int options);
更灵活:
-
pid > 0:等待指定 PID 的子进程。 -
pid == -1:等待任意子进程(等价于 wait)。 -
pid == 0:等待与调用者同组的任意子进程。 -
options常用WNOHANG:非阻塞,没有子进程退出时立即返回 0。
3.4 解析退出状态
status 是一个整数,需要用宏解析:
| 宏 | 含义 |
|---|---|
WIFEXITED(status) |
是否正常退出(调用了 exit 或 return) |
WEXITSTATUS(status) |
获取正常退出的退出码 |
WIFSIGNALED(status) |
是否被信号终止 |
WTERMSIG(status) |
获取终止信号的编号 |
WIFSTOPPED(status) |
是否被暂停 |
WCOREDUMP(status) |
是否产生了核心转储 |
3.5 完整示例
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:睡一会然后退出
std::cout << "子进程 " << getpid() << " 开始运行\n";
sleep(2);
std::cout << "子进程即将退出,退出码 42\n";
_exit(42);
} else if (pid > 0) {
// 父进程:等待子进程
int status;
pid_t ret = waitpid(pid, &status, 0);
std::cout << "父进程:等待到子进程 " << ret << "\n";
if (WIFEXITED(status)) {
std::cout << "子进程正常退出,退出码 = "
<< WEXITSTATUS(status) << "\n";
} else if (WIFSIGNALED(status)) {
std::cout << "子进程被信号 "
<< WTERMSIG(status) << " 终止\n";
}
}
return 0;
}
3.6 僵尸进程与孤儿进程
-
僵尸进程(Zombie) :子进程已退出,但父进程还没调用
wait回收它的 PCB。用ps看到状态为Z。 -
孤儿进程(Orphan) :父进程先退出,子进程被
init(PID 1)或systemd收养。
僵尸进程无法被 kill,只能通过父进程调用 wait 或父进程自身退出(交给 init 回收)来清除。
4. 进程替换:exec 家族
4.1 exec 的作用
fork 创建的子进程和父进程执行的是同一份代码。如果我们想让子进程执行另一个程序,就需要 exec。
exec 会用新程序完全替换 当前进程的地址空间(代码段、数据段、堆、栈全部换掉),但 PID 保持不变。exec 成功不返回,失败返回 -1。
4.2 exec 家族
exec 有 6 个变体,命名规则是 exec + l/v + p/e:
| 函数 | 参数形式 | 是否搜索 PATH | 是否传环境变量 |
|---|---|---|---|
execl |
列表(list) | 否 | 继承 |
execlp |
列表 | 是 | 继承 |
execle |
列表 | 否 | 显式指定 |
execv |
数组(vector) | 否 | 继承 |
execvp |
数组 | 是 | 继承 |
execve |
数组 | 否 | 显式指定(系统调用) |
最常用的是 execlp 和 execvp。
4.3 execlp 示例
#include <iostream>
#include <unistd.h>
int main() {
std::cout << "准备执行 ls 命令\n";
// 参数列表以 nullptr 结尾,第一个参数是程序名
execlp("ls", "ls", "-l", "/home", nullptr);
// 只有 exec 失败才会执行到这里
std::cerr << "exec 失败\n";
return 1;
}
4.4 execvp 示例(参数从数组传入)
#include <iostream>
#include <unistd.h>
int main() {
char* args[] = {(char*)"ls", (char*)"-l", (char*)"/tmp", nullptr};
execvp("ls", args);
std::cerr << "exec 失败\n";
return 1;
}
4.5 execle 显式传环境变量
char* envp[] = {(char*)"MY_VAR=hello", (char*)"PATH=/usr/bin", nullptr};
execle("./myapp", "myapp", nullptr, envp);
4.6 fork + exec 的组合
这是 Linux 最经典的模式:
pid_t pid = fork();
if (pid == 0) {
// 子进程:替换成新程序
execlp("ls", "ls", "-l", nullptr);
_exit(1); // exec 失败才会到这里
} else {
// 父进程:等待子进程结束
wait(nullptr);
}
为什么要先 fork 再 exec? 因为 exec 会替换整个进程,直接调用会让自己消失。先 fork 出子进程,让子进程去 exec,父进程就能继续保留。
5. 进程终止:exit 与 _exit
5.1 正常终止的几种方式
-
returnfrommain -
调用
exit(int status) -
调用
_exit(int status)或_Exit(int status) -
最后一个线程从启动例程返回
5.2 exit 与 _exit 的区别
| 特性 | exit |
_exit |
|---|---|---|
| 头文件 | <cstdlib> |
<unistd.h> |
| 刷新 stdio 缓冲区 | 是 | 否 |
调用 atexit 注册的函数 |
是 | 否 |
| 关闭文件描述符 | 是(通过 atexit 机制) | 否 |
| 是否系统调用 | 否,是库函数 | 是系统调用 |
关键点 :exit 会刷新缓冲区,_exit 不会。在 fork 出的子进程中,通常应该用 _exit,避免刷新父进程缓冲区导致输出重复。
看一个经典陷阱:
#include <iostream>
#include <unistd.h>
int main() {
std::cout << "Hello"; // 没有换行,留在缓冲区
if (fork() == 0) {
_exit(0); // 正确:子进程用 _exit,不刷新缓冲区
}
return 0; // 父进程正常退出,刷新缓冲区
}
如果子进程用了 exit(0),缓冲区里的 "Hello" 会被刷新一次,加上父进程的刷新,终端会看到两个 Hello。
5.3 异常终止
-
调用
abort():产生SIGABRT信号 -
被信号终止:如
SIGSEGV(段错误)、SIGKILL(强制杀死)
5.4 退出码
退出码是一个 0-255 的整数,0 表示成功,非 0 表示失败。shell 可以通过 $? 获取上一条命令的退出码:
ls /tmp
echo $? # 输出 0
ls /notexist
echo $? # 输出 2
6. 综合示例:迷你 shell
把 fork、exec、wait 组合起来,实现一个能执行命令的迷你 shell:
#include <iostream>
#include <string>
#include <sstream>
#include <vector>
#include <unistd.h>
#include <sys/wait.h>
int main() {
std::string line;
while (true) {
std::cout << "mysh> " << std::flush;
if (!std::getline(std::cin, line)) break; // Ctrl+D 退出
if (line.empty()) continue;
// 解析成参数数组
std::istringstream iss(line);
std::vector<std::string> tokens;
std::string token;
while (iss >> token) tokens.push_back(token);
if (tokens[0] == "exit") break;
if (tokens[0] == "cd") { // 内建命令,父进程执行
if (tokens.size() > 1) chdir(tokens[1].c_str());
continue;
}
// 转成 char* 数组
std::vector<char*> args;
for (auto& t : tokens) args.push_back(const_cast<char*>(t.c_str()));
args.push_back(nullptr);
pid_t pid = fork();
if (pid == 0) {
// 子进程:执行命令
execvp(args[0], args.data());
std::cerr << "命令未找到: " << tokens[0] << "\n";
_exit(127);
} else if (pid > 0) {
// 父进程:等待
int status;
waitpid(pid, &status, 0);
} else {
std::cerr << "fork 失败\n";
}
}
return 0;
}
这个小 shell 已经能执行大多数外部命令,也支持 cd 和 exit 这样的内建命令。它完美演示了 fork + exec + wait 的经典三连。
7. 进程控制全景图
用户输入命令
│
▼
┌─────────┐
│ shell │
└────┬────┘
│ fork() ← 创建子进程
▼
┌─────────────┐
│ 子进程 │
│ execvp() │ ← 替换为要执行的程序
└──────┬──────┘
│ 程序运行
▼
┌─────────────┐
│ 程序退出 │
│ exit/_exit │
└──────┬──────┘
│
▼
┌─────────────┐
│ 父进程 │
│ waitpid() │ ← 回收子进程,获取退出状态
└─────────────┘
8. 常见误区与注意事项
-
fork 后父子进程执行顺序不确定 ,不要假设谁先谁后。需要同步就用
wait或信号。 -
子进程一定要用
_exit而不是exit,避免刷新父进程的 stdio 缓冲区。 -
僵尸进程必须由父进程回收 ,
kill对僵尸无效。 -
exec 成功后不返回,如果 exec 返回了,说明失败了,需要处理错误。
-
不要在 fork 出的子进程中调用复杂库函数,因为 fork 后只有当前线程被复制,多线程环境下可能导致死锁(只能调用异步信号安全的函数)。
-
文件描述符会继承,子进程不用的 fd 应该关闭,否则可能造成资源泄漏。
-
wait 可能被信号中断 ,返回 -1 且
errno == EINTR时应该重新调用。
9. 小结
| 操作 | 关键函数 | 作用 |
|---|---|---|
| 创建 | fork |
复制当前进程,产生子进程 |
| 等待 | wait / waitpid |
回收子进程,获取退出状态,避免僵尸 |
| 替换 | exec 家族 |
用新程序替换当前进程的地址空间 |
| 终止 | exit / _exit / abort |
结束进程,返回退出码或被信号终止 |
四者的经典组合:fork 创建子进程 → 子进程 exec 执行新程序 → 父进程 wait 回收子进程。这就是 shell 执行命令的全部秘密,也是 Linux 进程模型的核心。