Linux/C++ 多进程篇
前言
你有没有想过,为什么你的程序只能一件事一件事地处理?就像一个忙碌的服务员,面对众多顾客,却只能一次服务一个人,其他人只能排队等待。
但在现实生活中,我们常常需要同时处理多件事情:
浏览器同时加载多个网页 服务器同时响应成百上千的客户端请求 视频播放器在播放视频的同时,还能下载后续内容
这背后,靠的就是程序的'分身术'——多进程编程。今天,我们从最底层的内存世界出发,揭开 C++ 多进程'分身术'的神秘面纱。
多进程理论基础
一、为什么要引入多进程
想象一下,你是一个人(单进程),正在厨房里做饭(处理业务)。
- 现状:你切菜、炒菜、洗碗一把抓。
- 风险:如果你不小心切到了手(程序崩溃/段错误),或者被一道复杂的菜难住死住了(死循环),整个厨房就瘫痪了,客人吃不上饭,甚至房子都可能着火(服务宕机)。
这时候,'分身术'出现了。
你喊了一声'变!',瞬间变出了另一个你(子进程)。
- 老大(父进程):负责站在门口迎客、指挥调度、监控老二的状态。
- 老二(子进程):专门负责进厨房炒菜。
核心收益:
如果老二在厨房里被油烫伤了(子进程崩溃),老大毫发无损!老大可以立刻再变出一个'老三'继续炒菜,客人甚至感觉不到中间停顿了。
这就是多进程存在的第一原动力:隔离与容错。
二、多进程相关概念
- 进程是程序的一次执行过程,有一定的生命周期,包含了创建态、就绪态、执行态、挂起态、死亡态
- 进程是计算机资源分配的基本单位,系统会给每个进程分配 0--4G 的虚拟内存,其中 0--3G 是用户空间,3--4G 是内核空间
其中多个进程中 0--3G 的用户空间是相互独立的,但是,3--4G 的内核空间是相互共享的
用户空间细分为:栈区、堆区、静态区
- 进程的调度机制:时间片轮询上下文切换机制
我们可以看到下图中,我们的 CPU 不停的向我们运行队列中的程序分配时间片,让程序轮流来完成任务,这就是我们的时间片轮询上下文切换机制。

- 并发和并行的区别
并发:针对于单核 CPU 系统在处理多个任务时,使用相关的调度机制,实现多个任务进行细化时间片轮询时,在宏观上感觉是多个任务同时执行的操作,同一时刻,只有一个任务在被 CPU 处理
并行:是针对于多核 CPU 而言,处理多个任务时,同一时间,每个 CPU 处理的任务之间是并行的,实现的是真正意义上多个任务同时执行的
三、进程的内存管理
此处我们就用一图来带大家快速了解我们进程中的内存管理。

我们先从左往右来看,我们的操作系统是会通过物理内存来进行映射产生虚拟内存,而我们的虚拟内存中又分为3g 的用户空间与1g 的内核空间。从图中也不难看出,我们的用户空间其实就是我们之前经常所讲的那些内存分配的占用空间,而内核空间其实就是我们操作其他内核代码与数据存储的地方。
- 物理内存:内存条上(硬件上)真正存在的存储空间
- 虚拟内存:程序运行后,通过内存映射单元,将物理内存映射出 4G 的虚拟内存,供进程使用
四、进程与程序的区别
- 进程:是动态的,进程是程序的一次执行过程,是有生命周期的,进程会被分配 0--3G 的用户空间,进程是在内存上存着的
- 程序:是静态的,没有所谓的生命周期,程序存储在磁盘设备上的二进制文件

五、进程的种类
进程一共有三种:交互进程、批处理进程、守护进程
| 特性 | 交互进程 | 批处理进程 | 守护进程 |
|---|---|---|---|
| 与终端关系 | 必须关联一个终端(控制终端) | 通常不关联终端(或与终端无关) | 脱离终端,无控制终端 |
| 用户交互 | 实时交互,等待用户输入 | 无交互,按预设脚本执行 | 无直接用户交互,响应系统请求 |
| 运行方式 | 前台或后台(但前台居多) | 后台运行(作业队列) | 后台运行,常驻内存 |
| 生命周期 | 用户会话期间,用户退出则结束 | 作业执行完即结束 | 系统启动到关闭,长期运行 |
| 调度优先级 | 通常较高(响应性要求) | 可低可高,取决于作业调度 | 通常后台优先级,但重要服务可调高 |
| 例子 | bash、vim、top | at作业、batch作业、g++编译任务 | sshd、httpd、cron |
六、进程 PID
那有了进程后,我们的进程之间该怎么区分谁是大哥,谁是二弟呢?这时候就靠我们的进程号 PID 了。
- PID(Process ID): 进程号,进程号是一个大于等于 0 的整数值,是进程的唯一标识,不可能重复。
- PPID(Parent Process ID): 父进程号,系统中允许的每个进程,都是拷贝父进程资源得到的
- 在 linux 系统中的 /proc 目录下的数字命名的目录其实都是一个进程

当然,我们的 PID 肯定是有获取方法的,我们待会就会讲到。
七、特殊的进程
- 0 号进程(idle):他是由 linux 操作系统启动后运行的第一个进程,也叫空闲进程,当没有其他进程运行时,会运行该进程。他也是 1 号进程和 2 号进程的父进程
- 1 号进程(init):他是由 0 号进程创建出来的,这个进程会完成一些硬件的必要初始化工作,除此之外,还会收养孤儿进程
- 2 号进程(kthreadd):也称调度进程,这个进程也是由 0 号进程创建出来的,主要完成任务调度问题
- 孤儿进程:当前进程还正在运行,其父进程已经退出了。
说明:每个进程退出后,其分配的系统资源应该由其父进程进行回收,否则会造成资源的浪费
- 僵尸进程:当前进程已经退出了,但是其父进程没有为其回收资源
八、linux 中有关进程的指令
-
ps 指令:能够查看当前运行的进程相关属性
ps -ef: 能够显示进程之间的关系- UID:用户 ID 号
- PID:进程号
- PPID:父进程号
- STIME:开始运行的时间
- TTY:如果是问号表示这个进程不依赖于终端而存在
- CMD:名称
ps -ajx: 能够显示当前进程的状态- PGID:进程组 ID
- SID:会话组 ID
- STAT:进程的状态
ps -aux: 可以查看当前进程对 CPU 和内存的占用率- %CPU:CPU 占用率
- %MEM:内存占用率
-
top 指令:动态查看进程的相关属性
此处,表里面的内容其实是会实时变化的,所以叫做动态查看。
-
kill 指令:发送信号的指令
kill -信号号 进程号可以通过指令:
kill -l查看能够发送的信号有哪些

- 从图可知一共可以发射 62 个信号,而前 32 个是稳定信号,后面剩余的是不稳定信号
- 常用的信号
- SIGHUP:当进程所在的终端被关闭后,终端会给运行在当前终端的每个进程发送该信号,默认结束进程
- SIGINT:中断信号,当用户键入 ctrl + c 时发射出来
- SIGQUIT:退出信号,当用户键入 ctrl + \ 时发送,退出进程
- SIGKILL:杀死指定的进程
- SIGSEGV:当指针出现越界访问时,会发射,表示段错误
- SIGPIPE:当管道破裂时会发送该信号
- SIGALRM:当定时器超时后,会发送该信号
- SIGSTOP:暂停进程,当用户键入 ctrl+z 时发射
- SIGTSTP:也是暂停进程
- SIGUSR1、SIGUSR2:留给用户自定义的信号,没有默认操作
- SIGCHLD:当子进程退出后,会向父进程发送该信号
- 有两个特殊信号:SIGKILL 和 SIGSTOP,这两个信号既不能被捕获,也不能被忽略
-
pidof:查看进程的进程号
pidof 进程名

九、进程状态的切换
- 可以通过 man ps 进行查看进程的状态

进程主状态:
D uninterruptible sleep (usually IO) 不可中断的休眠态,通常是 IO 操作
R running or runnable (on run queue) 运行态
S interruptible sleep (waiting for an event to complete) 可中断的休眠态
T stopped by job control signal 停止态
t stopped by debugger during the tracing 调试时的停止态
W paging (not valid since the 2.6.xx kernel) 已经弃用的状态
X dead (should never be seen) 死亡态
Z defunct ("zombie") process, terminated but not reaped by its parent 僵尸态附加态:
< high-priority (not nice to other users) 高优先级进程
N low-priority (nice to other users) 低优先级进程
L has pages locked into memory (for real-time and custom IO) 锁在内存中的进程
s is a session leader 会话组组长
l is multi-threaded (using CLONE_THREAD, like NPTL pthreads do) 包含多线程的进程
- is in the foreground process group 前台运行的进程
- 状态切换的实例

- 进程主要状态的转换(五态图)

多进程的实现
进程的创建过程,是子进程通过拷贝父进程得到的,新进程的创建直接拷贝父进程的资源,只需改变很少部分的数据即可,保留了父进程的大部分的数据信息(遗传基因),所以这个拷贝过程,系统通过一个函数 fork 来自动完成
📖 进程的创建:fork
| 函数原型 | pid_t fork(void); |
|---|---|
| 头文件 | unistd.h |
| 功能 | 通过拷贝父进程得到一个子进程 |
| 参数说明 | 无 |
| 返回值 | 成功在父进程中得到子进程的 pid,在子进程中的到 0,失败返回 -1 并置位错误码 |

那如果有多个 fork 呢?大家可以先猜一下,下图程序运行后我们的进程总共有多少个,会是 4 个吗?如果有人觉得是 4 个,那不妨想想,我们的程序是不是从第 9 行开始创建新的进程,那么新进程将会从第 10 行开始运行。那么这时候,你还会觉得是 4 个进程吗?此时我们的进程其实就是 8 个。


如果不关注返回值的话,有 n 个 fork,会产生 2^n 个进程
那么,fork 函数的返回值在上面的表格中我们也说过了,现在我们来看看关注返回值的情况
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <unistd.h>
using namespace std;
int main(int argc, const char *argv[]){
pid_t pid = -1; //对于父进程而言会得到大于 0 的数字,对于子进程而言会得到 0
pid = fork(); //创建一个子进程,父进程会将返回值赋值给父进程中的 pid 变量
//子进程会将返回值赋值给子进程中的 pid 变量
printf("pid = %d\n", pid);
//对 pid 进程判断
if(pid > 0){ //父进程要做执行的代码
printf("我是父进程\n");
}else if(pid == 0){ //子进程要执行的代码
printf("我是子进程\n");
}else{
perror("fork error");
return -1;
}
while(1);
return 0;
}
对于上述的程序,我们的结果如下

那我们现在就可以进行父子进程的并发执行案例了
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <unistd.h>
using namespace std;
int main(int argc, const char *argv[]){
pid_t pid = -1;
pid = fork(); //创建一个子进程,父进程会将返回值赋值给父进程中的 pid 变量
//子进程会将返回值赋值给子进程中的 pid 变量
//对 pid 进程判断
if(pid > 0){
while(1){ //父进程要做执行的代码
printf("我是父进程 1111\n");
sleep(1);
}
}else if(pid == 0){
while(1){ //子进程要执行的代码
printf("我是子进程\n");
sleep(1);
}
}else{
perror("fork error");
return -1;
}
return 0;
}
值得一提的是,我们可以看看我们执行的结果:

📖 父子进程号的获取:getpid、getppid
| 函数原型 | pid_t getpid(void); | pid_t getppid(void) |
|---|---|---|
| 头文件 | sys/types.h unistd.h | sys/types.h unistd.h |
| 功能 | 获取当前进程的进程号 | 获取当前进程的父进程 pid 号 |
| 参数说明 | 无 | 无 |
| 返回值 | 当前进程的进程号 | 当前进程的父进程 pid |
📖 进程退出:exit/_exit
上述两个函数都可以完成进程的退出,区别是在退出进程时,是否刷新标准 IO 的缓冲区
exit属于库函数,使用该函数退出进程时,会刷新标准 IO 的缓冲区后退出
_exit属于系统调用(内核提供的函数),使用该函数退出进程时,不会刷新标准 IO 的缓冲区
| 函数原型 | void exit(int status); | void _exit(int status); |
|---|---|---|
| 头文件 | stdlib.h | stdlib.h |
| 功能 | 退出当前进程,并刷新当前进程打开的标准 IO 的缓冲区 | 退出当前进程,不刷新当前进程打开的标准 IO 的缓冲区 |
| 参数说明 | 进程退出时的状态,会将改制 与 0377 进行位与运算后,返回给回收资源的进程 | 进程退出时的状态,会将改制 与 0377 进行位与运算后,返回给回收资源的进程 |
| 返回值 | 无 | 无 |
📖 进程资源回收:wait、waitpid
有两个函数可以完成对进程资源的回收
wait:阻塞回收任意一个子进程的资源函数
waitpid:可以阻塞,也可以非阻塞完成对指定的进程号进程资源回收
| 函数原型 | wait | waitpid |
|---|---|---|
| 头文件 | sys/types.h sys/wait.h | sys/types.h sys/wait.h |
| 功能 | 阻塞回收子进程的资源 | 可以阻塞也可以非阻塞回收指定进程的资源 |
| 参数说明 | 接收子进程退出时的状态,获取子进程退出时的状态与 0377 进行位与后的结果,如果不愿意接收,可以填 NULL | 参数 1:进程号 >0:表示回收指定的进程,进程号位 pid(常用) =0:表示回收当前进程所在进程组中的任意一个子进程 =-1:表示回收任意一个子进程(常用) <-1:表示回收指定进程组中的任意一个子进程,进程组 id 为给定的 pid 的绝对值 参数 2: 接收子进程退出时的状态,获取子进程退出时的状态与 0377 进行位与后的结果,如果不愿 意接收,可以填 NULL 参数 3:是否阻塞 0:表示阻塞等待 WNOHANG:表示非阻塞 |
| 返回值 | 成功返回回收资源的那个进程的 pid 号,失败返回 -1 并置位错误码 | >0: 返回的是成功回收的子进程 pid 号 =0:表示本次没有回收到子进程 =-1:出错并置位错误码 |
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main(int argc, const char *argv[]){
pid_t pid = -1; //定义用于存储进程号的变量
//创建进程
pid = fork();
if(pid > 0) { //父进程程序代码
//输出当前进程号、子进程号、父进程号
printf("self pid=%d, child pid = %d, parent pid = %d\n", getpid(), pid, getppid());
sleep(8); //休眠 3 秒
//wait(NULL); //回收子进程资源,只有回收了子进程资源后,父进程才继续向后执行
waitpid(-1, NULL, WNOHANG); //非阻塞回收子进程资源
printf("子进程资源已经回收\n");
} else if(pid == 0) { //子进程程序代码
//输出当前进程的进程号、父进程进程号
printf("self pid = %d, parent pid = %d\n", getpid(), getppid());
//提出子进程
printf("11111111111111111111111111111111111");
//没有加换行,不会自动刷新
sleep(3);
exit(EXIT_SUCCESS); //刷新缓冲区并退出进程
//_exit(EXIT_SUCCESS); //不刷新缓冲区退出进程
} else {
perror("fork error");
return -1;
}
while(1); //防止进程退出
return 0;
}
下图为运行效果

📖 僵尸进程和孤儿进程
- 孤儿进程:当前进程还正在运行,其父进程已经退出了。
每个进程退出后,其分配的系统资源应该由其父进程进行回收,否则会造成资源的浪费
我们不妨来看看,父进程不回收资源会发生什么
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main(int argc, const char *argv[]){
pid_t pid = fork();
if(pid > 0){
sleep(5);
exit(EXIT_SUCCESS);
}else if(pid == 0){
while(1){
printf("我是子进程\n");
sleep(1);
}
}else{
perror("fork error");
return -1;
}
return 0;
}

可以看到在 Linux 中,孤儿不会流落街头。它们会被系统的'福利院院长'—— init 进程 (PID 1) 收养。init 进程会定期帮它们收尸,所以孤儿进程通常无害。
- 僵尸进程:当前进程已经退出了,但是其父进程没有为其回收资源
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main(int argc, const char *argv[]){
pid_t pid = fork();
if(pid > 0){
while(1){
printf("我是父进程\n");
sleep(1);
}
}else if(pid == 0){
sleep(5);
exit(EXIT_SUCCESS);
}else{
perror("fork error");
return -1;
}
return 0;
}
效果如下:

深度解析:fork 中的黑魔法
那么学完应用方面,再给大家提一点点细枝末节。
我相信很多初学者以为 fork() 是把整个程序从头到尾复制了一遍,就像复印机一样。
**那你就错了,**如果每次分身都完整复制几 GB 的内存,电脑早就卡死了。
操作系统(OS)使用了一个极其聪明的机制:写时复制(Copy-On-Write, COW)。
通俗解释 COW:
- 分身瞬间:
- 父进程和子进程共享同一块物理内存。
- OS 只是把这块内存标记为'只读',并给父子进程各自发了一本新的'地图'(页表)。
- **此时,没有发生任何数据拷贝!**速度极快,几乎瞬间完成。
- 读取数据时:
- 父子进程去读变量
int a = 10;。 - 因为大家读的都是同一块只读内存,完全没问题,继续共享。
- 父子进程去读变量
- 修改数据时(关键点!):
- 假设子进程说:'我要把
a改成 20'。 - OS 拦截了这个操作(触发缺页中断)。
- OS 心想:'你要改?行,那我单独给你复印一份这一页内存,你在你的副本上随便改,别影响你爸。'
- 只有在这时,真正的内存拷贝才发生。
- 假设子进程说:'我要把

那么现在再来想这么一个问题:fork 之后,父子进程的内存关系吗?如果我在 fork 前定义了一个全局变量,fork 后子进程改了它,父进程会变吗?
这个问题的核心就在于理解 Copy-On-Write (写时复制) 机制。
首先,当调用 fork() 时,操作系统并没有立即在物理内存中复制一份父进程的数据。父子进程在初始阶段是共享同一块物理内存页的,只不过这些页被标记为了只读。此时,如果父子进程都只是读取那个全局变量,它们访问的是同一个物理地址,效率极高。
其次,关键在于写入。当子进程尝试修改这个全局变量时,CPU 会触发一个缺页异常(Page Fault)。操作系统内核捕获到这个异常后,意识到子进程要'写'了,于是它会真正地为子进程分配一块新的物理内存,将原数据复制过去,然后让子进程在新的内存页上修改。而父进程的页表依然指向原来的物理内存。
所以我们的结论就出来了:读操作:父子进程共享物理内存,互不影响(因为都没改)。写操作:一旦发生写操作,内存即刻分离。子进程修改变量,绝对不会影响父进程的值,因为它们此刻已经指向了不同的物理地址。
这种机制既保证了进程间的隔离性(数据安全),又极大优化了性能(避免了不必要的拷贝)。这也是为什么在 C++ 后端中,即使创建大量进程,系统开销也是可控的原因。
结语
今天我们揭开了 C++ 多进程'分身术'的面纱:
fork()是咒语。- COW 是省钱的秘诀。
- 返回值 是身份的证明。
- wait 是负责任的态度。
但这只是开始。两个独立的进程,如果想互相传个话(比如父进程告诉子进程'该下班了'),或者传个大文件,该怎么办?它们不能直接读对方的内存,那就要用到更有趣的 IPC(进程间通信)技术了:管道、消息队列、共享内存,我们下一篇文章将会带来这些内容的讲解


