Linux 进程核心系统调用

目录

[一、进程创建:fork () /vfork () /exec 系列](#一、进程创建:fork () /vfork () /exec 系列)

[1.1 fork () ------ 创建子进程](#1.1 fork () —— 创建子进程)

第一层:定义级

第二层:原理级

第三层:对比级

fork基础语法

[1.2 vfork () ------ 过时的 "轻量 fork"](#1.2 vfork () —— 过时的 "轻量 fork")

第一层:定义级

第二层:原理级

[第三层:对比级 ------ fork vs vfork](#第三层:对比级 —— fork vs vfork)

[Demo :vfork 的危险行为](#Demo :vfork 的危险行为)

[1.3 exec 系列 ------ 替换进程映像](#1.3 exec 系列 —— 替换进程映像)

第一层:定义级

第二层:原理级

第三层:对比级

[二、进程终止与等待:exit () /wait () /waitpid ()](#二、进程终止与等待:exit () /wait () /waitpid ())

[2.1 exit () 系列 ------ 进程终止](#2.1 exit () 系列 —— 进程终止)

第一层:定义级

第二层:原理级

第三层:对比级

[2.2 wait () ------ 等待子进程终止](#2.2 wait () —— 等待子进程终止)

第一层:定义级

第二层:原理级

[第三层:对比级 ------ wait vs waitpid](#第三层:对比级 —— wait vs waitpid)

[2.3 waitpid () ------ 更灵活的等待](#2.3 waitpid () —— 更灵活的等待)

第一层:定义级

第二层:原理级

[第三层:对比级 & 项目实战场景](#第三层:对比级 & 项目实战场景)

三、整体对比总结(面试速查表)


一、进程创建:fork () /vfork () /exec 系列

1.1 fork () ------ 创建子进程

第一层:定义级

是什么fork() 是 Linux 中创建新进程的系统调用,调用一次返回两次。 有什么用 :从一个已存在的进程(父进程)复制出一个几乎完全相同的新进程(子进程),父子进程从 fork() 返回处继续执行。

返回值:

  • 子进程中返回 0
  • 父进程中返回 子进程的 PID(>0)
  • 失败返回 -1

第二层:原理级

底层实现

  1. 分配新的 task_struct:内核为子进程分配进程描述符
  2. 复制进程资源 (写时复制 COW):
    • 早期 fork 是完整复制父进程地址空间,效率极低
    • 现代 Linux 采用 写时复制(Copy-On-Write) :父子进程共享物理页,页表标记为只读;当任一方尝试写入时,才触发缺页异常,真正复制一页物理内存
  3. 资源继承与差异化
    • 子进程继承:地址空间、文件描述符表、信号处理方式、当前工作目录、用户 / 组 ID
    • 子进程独有:PID、PPID(设为父进程 PID)、挂起信号集合被清空、文件锁不继承、资源利用统计清零
  4. 放入运行队列:子进程被调度器接管,开始竞争 CPU

第三层:对比级

对比维度 fork()
地址空间 写时复制共享,子进程有独立地址空间
返回语义 调用一次返回两次
执行起点 从 fork 返回处继续执行
典型用途 需要子进程独立运行、保留父进程上下文的场景
  • fork 之后父子进程谁先执行?A: 不确定,由调度器决定,可通过信号同步
  • fork 后文件描述符共享吗?A: 共享文件表项(文件偏移量、文件状态标志共享),但每个进程有自己独立的文件描述符表
  • 多线程下 fork 会怎样?A: 子进程中只有调用 fork 的那个线程存在,其他线程都消失,且锁状态可能残留导致死锁 ------ 多线程环境慎用 fork

fork基础语法

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
    int num = 100;
    pid_t pid = fork();

    if (pid == -1) {
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // ===== 子进程 =====
        printf("[子进程] PID=%d, 父PID=%d, num=%d\n", getpid(), getppid(), num);
        num = 200;  // 触发写时复制
        printf("[子进程] 修改后 num=%d\n", num);
        _exit(0);
    } else {
        // ===== 父进程 =====
        printf("[父进程] PID=%d, 子PID=%d, num=%d\n", getpid(), pid, num);
        wait(NULL);  // 等待子进程结束
        printf("[父进程] 子进程结束后 num=%d (互不影响)\n", num);
    }
    return 0;
}

1.2 vfork () ------ 过时的 "轻量 fork"

第一层:定义级

是什么vfork() 创建子进程,但子进程直接共享父进程的地址空间 (不复制),且子进程先运行,直到子进程调用 exec_exit 后父进程才恢复运行。 有什么用:历史上为了解决 fork + exec 场景下 fork 全复制的浪费问题而设计。

第二层:原理级

底层实现

  1. 不复制页表,子进程直接使用父进程的内存空间
  2. 父进程被挂起阻塞 ,直到子进程执行 exec_exit
  3. 子进程在父进程地址空间里跑,修改数据父进程能直接看到

为什么这么设计,又为什么被淘汰

  • 设计初衷:在 COW 出现之前,vfork 比 fork 轻量得多
  • 被淘汰原因:
    1. 语义危险:子进程随便改变量都会影响父进程,极易出 bug
    2. 现代 fork 有 COW,开销已经很低,vfork 的性能优势不再明显
    3. POSIX 标准已将 vfork 标记为过时,推荐用 fork 替代

第三层:对比级 ------ fork vs vfork

对比项 fork() vfork()
地址空间 COW 共享,写时复制 完全共享,不复制
父子执行顺序 调度器决定,并发执行 子进程先跑,父进程阻塞
子进程修改数据 不影响父进程 直接修改父进程数据
安全性 安全,进程隔离 危险,容易踩坑
标准状态 标准核心 API POSIX 标记为 obsolete(过时)

vfork 本质是 "历史遗留产物",COW 出现后 vfork 存在意义不大 ,且 vfork 子进程不能随便 return,必须用 _exit() 退出

Demo :vfork 的危险行为

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int global = 10;

int main() {
    pid_t pid = vfork();

    if (pid == -1) {
        perror("vfork");
        return 1;
    } else if (pid == 0) {
        // 子进程:直接修改父进程的变量!
        global = 999;
        printf("[子进程] global = %d\n", global);
        _exit(0);  // 必须用 _exit,不能用 return 或 exit()
    } else {
        // 父进程:vfork 保证子进程先跑完才到这里
        printf("[父进程] global = %d (被子进程改了!)\n", global);
        wait(NULL);
    }
    return 0;
}

父进程的 global 变量被子进程直接改掉了 ------ 这就是地址空间共享的证明。

1.3 exec 系列 ------ 替换进程映像

第一层:定义级

是什么exec 不是一个函数,是一族函数 (execl/execlp/execle/execv/execvp/execvpe),作用是用一个全新的程序替换当前进程的地址空间 ,从新程序的 main 开始执行。 有什么用:fork 出子进程后,通常紧接着调用 exec,让子进程去跑另一个程序(shell 执行命令的经典模式:fork + exec)。

六个函数的记忆法:

  • l = list(参数列表逐个传)
  • v = vector(参数用数组传)
  • p = path(自动在 PATH 环境变量中查找程序)
  • e = environment(传入自定义环境变量)
函数 参数形式 找程序方式 环境变量
execl 列表 路径全名 继承
execlp 列表 PATH 搜索 继承
execle 列表 路径全名 自定义
execv 数组 路径全名 继承
execvp 数组 PATH 搜索 继承
execvpe 数组 PATH 搜索 自定义

第二层:原理级

底层执行流程

  1. 验证权限:检查文件是否可执行、ELF 格式是否合法
  2. 释放旧地址空间:释放原进程的代码段、数据段、堆、栈、共享库映射
  3. 加载新程序
    • 读取 ELF 文件头,建立新的虚拟地址空间映射
    • 代码段、数据段按段映射到虚拟地址(按需调页,不会全部加载到内存)
    • 分配新的用户栈
  4. 重置进程状态
    • 信号处理重置为默认(SIG_DFL)
    • 清除挂起信号
    • 关闭设置了 FD_CLOEXEC 的文件描述符
    • 线程属性、内存映射等全部重置
  5. 跳转到新程序入口 :把 PC 指针指向 _start(C 库入口,最终调用 main)

为什么 exec 不创建新进程

  • 进程是 "资源容器",exec 只换 "内容" 不换 "容器"------PID 不变、PPID 不变、打开的文件描述符(没设 CLOEXEC 的)保留
  • 这样设计的好处:fork 负责 "创建容器",exec 负责 "装新内容",职责分离,组合灵活

第三层:对比级

fork + exec 组合 vs 直接创建进程

  • Windows 的 CreateProcess 是 "一步到位" 创建并加载新程序
  • Unix 哲学是两步走:fork(复制容器)+ exec(换装内容)
  • 两步走的优势:fork 和 exec 之间可以做很多预处理------ 修改文件描述符(重定向)、修改用户权限、设置信号屏蔽、调整环境变量等

二、进程终止与等待:exit () /wait () /waitpid ()

2.1 exit () 系列 ------ 进程终止

第一层:定义级

是什么 :终止当前进程,把退出状态码返回给父进程。 相关函数

  • exit(status):标准 C 库函数,正常终止,会做清理工作
  • _exit(status) / _Exit(status):系统调用,直接进入内核,不做 C 库清理
  • return:从 main 返回,编译器会自动调用 exit

有什么用:正常结束进程、返回退出码供父进程判断执行结果。

第二层:原理级

exit () 的完整执行路径

cpp 复制代码
用户调用 exit()
  ↓
1. 调用所有用 atexit() / on_exit() 注册的退出处理函数
2. 刷新并关闭所有打开的标准 I/O 流(printf 缓冲区被刷出)
3. 删除临时文件(tmpfile 创建的)
  ↓
调用 _exit() 进入内核
  ↓
4. 关闭所有打开的文件描述符
5. 释放地址空间、释放内存
6. 进程进入 **僵尸状态(Zombie)**:保留 task_struct 和退出状态,等待父进程收尸
7. 给父进程发 SIGCHLD 信号

为什么会有僵尸进程

  • 子进程先于父进程结束,内核不能直接把 task_struct 全释放 ------ 因为父进程可能还需要通过 wait 拿退出状态
  • 所以保留最小信息(PID、退出状态、资源使用统计),等父进程 wait 后才彻底释放
  • 父进程不 wait → 僵尸进程越积越多 → PID 耗尽

_exit () vs exit () 的区别

  • _exit 是系统调用,直接进内核,不处理 C 层清理
  • exit 是库函数,先做 C 层清理再调 _exit
  • vfork 子进程必须用 _exit,否则会把父进程的 stdio 缓冲区也刷了

第三层:对比级

终止方式 触发场景 是否清理 C 库 是否进入僵尸
return 0 main 正常返回 是(编译器插 exit)
exit() 代码主动调用
_exit() 需要跳过 C 清理时
被信号杀死(如 SIGKILL) 外部终止

Demo :atexit + _exit 对比

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void cleanup1() { printf("清理函数1 被调用\n"); }
void cleanup2() { printf("清理函数2 被调用\n"); }

int main() {
    atexit(cleanup1);
    atexit(cleanup2);  // 注册顺序与调用顺序相反

    printf("printf 缓冲区里的内容");  // 故意不加 \n

    // 试试分别注释下面两行,观察区别:
    exit(0);    // 会调用清理函数 + 刷新缓冲区
    // _exit(0);  // 直接进内核,啥清理都不做
}

_exit 时,那句没换行的 printf 直接消失了 ------ 缓冲区没被刷新。

2.2 wait () ------ 等待子进程终止

第一层:定义级

是什么wait(&status) 阻塞等待任意一个 子进程终止,把子进程的退出状态存入 status。 有什么用:父进程回收子进程资源,避免僵尸进程;同时获取子进程的退出信息判断执行结果。

返回值:成功返回收尸的子进程 PID,失败返回 -1。

第二层:原理级

底层做了什么

  1. 检查当前进程有没有已终止的子进程(僵尸)
    • 有 → 立刻返回那个子进程的 PID 和状态,释放 task_struct
    • 没有 → 父进程进入可中断睡眠状态(TASK_INTERRUPTIBLE),挂起等待
  2. 当有子进程终止时,内核给父进程发 SIGCHLD 信号,唤醒父进程执行收尸
  3. 解析子进程的退出原因,填充 status(用宏判断):
    • WIFEXITED(status):正常退出?→ 用 WEXITSTATUS(status) 取退出码
    • WIFSIGNALED(status):被信号杀死?→ 用 WTERMSIG(status) 取信号编号
    • WIFSTOPPED(status):被暂停(如 SIGSTOP)?

第三层:对比级 ------ wait vs waitpid

特性 wait() waitpid()
等待对象 任意子进程 可指定 PID / 进程组
阻塞行为 一定阻塞 可设 WNOHANG 非阻塞
功能 基础收尸 还能等待暂停的子进程(WUNTRACED)
灵活性 高,生产环境基本都用 waitpid

2.3 waitpid () ------ 更灵活的等待

第一层:定义级

是什么waitpid(pid, &status, options) ------ wait 的增强版。 参数

  • pid > 0:等待指定 PID 的子进程
  • pid = -1:等待任意子进程(等价于 wait)
  • pid = 0:等待和当前进程同组的任意子进程
  • pid < -1:等待进程组 ID 等于 pid 绝对值的任意子进程
  • options
    • WNOHANG:非阻塞,没有子进程结束就立刻返回 0
    • WUNTRACED:子进程暂停也返回(用于调试器)
    • WCONTINUED:子进程从暂停恢复也返回

第二层:原理级

  • 父进程不能一直傻等子进程,自己也要干活
  • 非阻塞轮询模式:定期检查一下有没有子进程挂了,有就收,没有就继续干自己的

SIGCHLD + waitpid 的经典模式

  • 子进程终止 → 内核发 SIGCHLD 给父进程
  • 信号处理函数里循环调用 waitpid(-1, &status, WNOHANG) 收尸
  • 必须循环调用:因为信号不排队,多个子进程同时退出可能只触发一次信号

第三层:对比级 & 项目实战场景

  • 并发服务器:主进程 fork 多个 worker,用 waitpid + SIGCHLD 回收死掉的 worker 并重启
  • Shell:前台命令阻塞 waitpid,后台命令不阻塞(& 符号)
  • 进程池:父进程管理多个子进程,指定 PID 等待特定子进程

Demo 5: WNOHANG 非阻塞回收

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

#define CHILD_NUM 3

int main() {
    pid_t pids[CHILD_NUM];

    // 创建 3 个子进程,各自睡不同时间
    for (int i = 0; i < CHILD_NUM; i++) {
        pids[i] = fork();
        if (pids[i] == 0) {
            printf("子进程 %d 启动,休眠 %d 秒\n", getpid(), i + 1);
            sleep(i + 1);
            printf("子进程 %d 退出\n", getpid());
            _exit(i + 1);  // 退出码 = i+1
        }
    }

    // 父进程:非阻塞轮询回收
    int reaped = 0;
    while (reaped < CHILD_NUM) {
        int status;
        pid_t pid = waitpid(-1, &status, WNOHANG);

        if (pid > 0) {
            reaped++;
            if (WIFEXITED(status)) {
                printf("回收子进程 %d,退出码 = %d\n", pid, WEXITSTATUS(status));
            }
        } else if (pid == 0) {
            // 没有子进程结束,父进程干点别的
            printf("父进程工作中... 暂无子进程退出\n");
            sleep(1);
        } else {
            perror("waitpid");
            break;
        }
    }
    printf("所有子进程回收完毕\n");
    return 0;
}

父进程不会卡住,每隔 1 秒检查一次,子进程按休眠时长依次被回收。

三、整体对比总结(面试速查表)

系统调用 核心作用 关键特性 常见坑
fork 创建子进程 COW、一次调用两次返回 多线程 fork 锁问题、文件偏移共享
vfork 轻量创建子进程 共享地址空间、父阻塞 已过时、必须 _exit 退出、不能改栈
exec 替换进程映像 PID 不变、换装不换壳 成功不返回、参数必须 NULL 结尾
exit 正常终止进程 调 atexit、刷 IO 缓冲区 vfork 子进程不能用
_exit 直接进内核终止 不做 C 层清理 缓冲区数据丢失
wait 阻塞收尸任意子进程 简单、阻塞 无法指定 PID、无法非阻塞
waitpid 灵活收尸 指定 PID、WNOHANG 非阻塞 SIGCHLD 处理里必须循环调用
相关推荐
2601_960567966 小时前
电商套图自动化效率的工程量化分析——从逐张生成到批量套图的架构演进
运维·架构·自动化
吳所畏惧7 小时前
宝塔面板Redis密码修改指南:SSH命令修改 vs 面板UI界面修改,哪个更靠谱?
运维·服务器·数据库·redis·缓存·ssh
DFT计算杂谈7 小时前
无 Root 权限在 Tesla K80 零门槛部署 DeepSeek 大模型
linux·服务器·网络·数据库·机器学习
HiDev_8 小时前
【非标自动化】2、认识元器件(确定目标)
运维·自动化
维天说8 小时前
CLI-Switch 2026年3月版历史设计:Hook、TTY 隔离与 JSON 状态
java·服务器·json
Zhang~Ling8 小时前
从 fopen 到 struct file:从零开始拆解 Linux 文件 I/O
linux·运维·服务器
DeeplyMind8 小时前
Linux 深入 per-VMA lock:Linux 缺页路径如何摆脱 mmap_lock
linux·per-vma lock
爱写代码的阿森8 小时前
鸿蒙三方库 | harmony-utils之PreferencesUtil首选项数据监听详解
服务器·华为·harmonyos·鸿蒙·huawei
爱写代码的森9 小时前
蒙三方库 | harmony-utils之FileUtil文件重命名与属性查询详解
linux·运维·服务器·华为·harmonyos·鸿蒙·huawei
中微极客9 小时前
2026主流AI Agent框架技术选型与性能对比
运维·网络·人工智能