Linux进程控制四大核心操作

本文把视角从"程序"提升到"进程"--- 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 父进程与子进程的执行顺序

父子进程的执行顺序是不确定的 ,由调度器决定。如果想让某一方先执行,需要用 sleepwait 或信号来同步。

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 数组 显式指定(系统调用)

最常用的是 execlpexecvp

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 正常终止的几种方式

  • return from main

  • 调用 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 已经能执行大多数外部命令,也支持 cdexit 这样的内建命令。它完美演示了 fork + exec + wait 的经典三连。


7. 进程控制全景图

复制代码
用户输入命令
     │
     ▼
┌─────────┐
│  shell  │
└────┬────┘
     │ fork()  ← 创建子进程
     ▼
┌─────────────┐
│  子进程      │
│  execvp()   │ ← 替换为要执行的程序
└──────┬──────┘
       │ 程序运行
       ▼
┌─────────────┐
│  程序退出    │
│  exit/_exit │
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  父进程      │
│  waitpid()  │ ← 回收子进程,获取退出状态
└─────────────┘

8. 常见误区与注意事项

  1. fork 后父子进程执行顺序不确定 ,不要假设谁先谁后。需要同步就用 wait 或信号。

  2. 子进程一定要用 _exit 而不是 exit,避免刷新父进程的 stdio 缓冲区。

  3. 僵尸进程必须由父进程回收kill 对僵尸无效。

  4. exec 成功后不返回,如果 exec 返回了,说明失败了,需要处理错误。

  5. 不要在 fork 出的子进程中调用复杂库函数,因为 fork 后只有当前线程被复制,多线程环境下可能导致死锁(只能调用异步信号安全的函数)。

  6. 文件描述符会继承,子进程不用的 fd 应该关闭,否则可能造成资源泄漏。

  7. wait 可能被信号中断 ,返回 -1 且 errno == EINTR 时应该重新调用。


9. 小结

操作 关键函数 作用
创建 fork 复制当前进程,产生子进程
等待 wait / waitpid 回收子进程,获取退出状态,避免僵尸
替换 exec 家族 用新程序替换当前进程的地址空间
终止 exit / _exit / abort 结束进程,返回退出码或被信号终止

四者的经典组合:fork 创建子进程 → 子进程 exec 执行新程序 → 父进程 wait 回收子进程。这就是 shell 执行命令的全部秘密,也是 Linux 进程模型的核心。

相关推荐
nazisami1 小时前
操作系统进程状态
linux·进程状态
Brilliantwxx1 小时前
【STM32】 SPI Flash 驱动开发实战:阻塞模式驱动 W25Q64(含工程代码)
开发语言·stm32·单片机·嵌入式硬件
xbzb1 小时前
Linux 文件查找命令 locate 与 find 完全指南
linux·运维
ITxiaobing20231 小时前
IP 定位服务选型指南:从准确率到工程落地的技术考察
linux·服务器·网络
wuyk5551 小时前
【Socket 进阶之路】第 9 章 Linux 网络服务量产稳定性优化|心跳保活、TIME_WAIT、SO_LINGER、内存池、断线重连、完整异常防护框架
linux·服务器·开发语言·网络·物联网
Julien20041 小时前
Docker 容器存储原理(一)
linux·运维·服务器·ssh·学习方法
半仙白桑1 小时前
内核篇第四讲:wait () 函数
linux·wait函数
zhangrelay2 小时前
《机器人控制器设计与编程》课程习题资料-2026
linux·笔记·学习·ubuntu
邪修king2 小时前
Re:Linux 系统篇(三十四):进程间通信开篇Chapter1 —— 匿名管道 pipe 深度拆解与代码实战
android·linux·运维·开发语言