Linux系统编程之多进程编程-----------一文详解IPC
Linux系统编程之多进程编程
1.进程的概念
在 Linux 中,进程是资源分配的最小单位。每个进程都有自己独立的:
- 虚拟地址空间(代码、数据、堆栈)。
- 文件描述符表。
- 进程 ID (PID)。
2.进程的生命周期管理
exec系列函数:让子进程“变身”。子进程可以用全新的程序(比如跑一个 SLAM 模块)替换掉当前的镜像。使用这个函数能让子程序立刻变成另一个全新的可执行程序。通常fork()会搭配exec()使用。wait() / waitpid():父进程必须为子进程“收尸”。如果子进程退出了,父进程不调用wait,子进程就会变成僵尸进程 (Zombie),占用系统资源。
3.进程间通信 (IPC)
因为进程间内存是隔离的,所以它们不能像线程那样直接改全局变量。常见的通讯方式:
3.1管道 (Pipe):最基础的单向通讯。
- 匿名管道原理:内核开辟一段内存作为缓冲区。一端只能写,一端只能读(半双工)。数据量有限(通常 64KB),且存在两次内核拷贝。
- 代码示例:
#include <unistd.h>
#include <cstring>
int main() {
int pipefd[2]; // 0是读端, 1是写端
pipe(pipefd);
if (pipe(pipefd) == -1)
{
perror("创建管道失败\n");
exit(EXIT_FAILURE);
}
if (fork() == 0) { // 子进程
close(pipefd[1]); // 关闭写端
char buf[100];
read(pipefd[0], buf, sizeof(buf)); // 阻塞等待指令
std::cout << "[子进程] 收到指令: " << buf << std::endl;
} else { // 父进程
close(pipefd[0]); // 关闭读端
const char *cmd = "START_3DGS_RENDER";
write(pipefd[1], cmd, strlen(cmd));
}
}
- 有名管道原理:有名管道(FIFO)就像是一个“公共电话亭”:只要进程知道这个管道的名字(路径),任何进程都能过来通话,哪怕它们之间完全没关系。
- 文件化:它在文件系统中表现为一个真实的文件(通常以 .fifo 结尾),但它不占用磁盘空间,数据只存在于内核缓冲区。
- 双向变单向:虽然它是一个文件,但它严格遵循 FIFO(先进先出)。
- 生命周期:除非你手动删除它,否则它一直存在。
- 有名管道的创建:
- 在命令行创建:
mkfifo /tmp/my_robot_fifo
2. 在 C++ 代码中创建:
#include <sys/types.h>
#include <sys/stat.h>
// 创建一个权限为 0666 的有名管道
if (mkfifo("/tmp/my_robot_fifo", 0666) == -1) {
perror("mkfifo failed"); // 如果文件已存在会报错,通常需要处理
}
有名管道的通信:
1. 发送端:
#include <iostream>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
int main() {
const char* fifo_path = "/tmp/my_robot_fifo";
// 1. 打开管道(以只写方式)
// 注意:如果读端没打开,open 这里会“阻塞”住,直到有人来读
int fd = open(fifo_path, O_WRONLY);
const char* msg = "3DGS_RENDER_START";
while(true) {
write(fd, msg, strlen(msg));
std::cout << "[发送端] 已下发指令: " << msg << std::endl;
sleep(2);
}
close(fd);
return 0;
- 接收端:
#include <iostream>
#include <fcntl.h>
#include <unistd.h>
int main() {
const char* fifo_path = "/tmp/my_robot_fifo";
// 1. 打开管道(以只读方式)
int fd = open(fifo_path, O_RDONLY);
char buffer[1024];
while(true) {
// 2. 阻塞式读取
ssize_t len = read(fd, buffer, sizeof(buffer) - 1);
if (len > 0) {
buffer[len] = '\0';
std::cout << "[接收端] 捕获到指令: " << buffer << std::endl;
}
}
close(fd);
return 0;
}
- 同步阻塞:如果你执行 open(…, O_WRONLY),而此时没有进程在 open(…, O_RDONLY),你的程序会死等在那一行代码,直到读端开启。并且为了防止一个进程有可能读取到自己发送的数据,通常只以O_WRONLY和O_RDONLY打开。
此时有个问题:为什么不直接用一个普通 .txt 文件通讯?有以下几点原因:
- 性能:普通文件要写磁盘,有名管道数据在内存中流动。
- 原子性:管道内部有锁机制,保证数据块的完整。
- 自动通知:读端在 read 时会自动挂起,等有数据了由内核唤醒。而读普通文件你需要用死循环去轮询(极其浪费 CPU)。
3.2信号 (Signal):比如 kill -9 发出的就是信号。
- 原理:内核通过向进程发送一个预定义的数字(信号),强制中断当前程序去执行一个预定义的处理函数。响应最快,系统级中断。但是不能传递大数据,只能发一个通知。
- 代码示例:
#include <csignal>
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
// 1. 定义闹钟响了之后要做什么
void signal_handler(int signum) {
std::cout << "\n[子进程] 叮铃铃!收到父进程的信号 " << signum << ",我要收工回家了!" << std::endl;
exit(0);
}
int main() {
pid_t pid = fork(); // 创建子进程
if (pid == 0) {
// --- 子进程逻辑 ---
std::cout << "[子进程] 我的 PID 是 " << getpid() << ",我开始进入死循环睡觉了..." << std::endl;
// 关键:告诉内核,收到 SIGUSR1 就去跑 signal_handler
signal(SIGUSR1, signal_handler);
while(true) {
std::cout << "[子进程] 呼呼大睡中..." << std::endl;
sleep(1);
}
}
else {
// --- 父进程逻辑 ---
sleep(3); // 让子进程先睡 3 秒
std::cout << "[父进程] 时间到!我要给子进程 (PID: " << pid << ") 发信号了!" << std::endl;
// 关键:父进程通过内核,向子进程发送自定义信号;该函数表示向进程pid发送自定义信号SIGUSR1
kill(pid, SIGUSR1);
wait(NULL); // 等待子进程收尸
std::cout << "[父进程] 子进程已退出,任务完成。" << std::endl;
}
return 0;
}
3.3共享内存 (Shared Memory):效率最高,适合传输图像、点云数据。
- 原理:直接将一块物理内存映射到两个进程的虚拟地址空间。零拷贝。数据写入即读出,处理 4K 图像或点云的唯一选择。但是不会自动同步,必须配合“信号量”。适合传大数据(图像)。
- 生产者代码逻辑:
// 1. 问管理员要钥匙 (Key)
// pathname: 必须是系统中存在的一个真实文件路径
// proj_id: 随便给一个 8 位的数字(通常写个字符如 'A' 或 65)
key_t key = ftok("/tmp/mem_key", 65);
// 2. 告诉管理员:我要开一个 1MB 的储物箱,后续的操作全靠这个 ID
// key: 刚才算出来的钥匙
// size: 你需要的字节数(比如 1024*1024)
// shmflg: 权限标志,IPC_CREAT 表示如果没有就创建,0666 是读写权限
int shmid = shmget(key, 1024*1024, 0666|IPC_CREAT);
// 3. 把这个箱子“接”到我自己的房间里,“挂载”(Attach)到你当前进程的虚拟地址上
// shmid: 刚才获取的 ID
// shmaddr: 指定映射到哪,传 NULL 表示让系统自动分配最优地址
// shmflg: 0 表示可读可写,SHM_RDONLY 表示只读
char *ptr = (char*) shmat(shmid, NULL, 0);
// 4. 往里放东西
sprintf(ptr, "这是来自进程 A 的图像数据");
// 5. 分离(我不看了,但箱子还在地下室)
// 分离不等于删除! 这仅仅意味着当前进程不再访问它了,这块内存在系统中依然存在,里面的数据也还在,其他进程依然可以访问。
shmdt(ptr);
- 消费者代码逻辑:
// 1. 拿同样的钥匙 (Key)
key_t key = ftok("/tmp/mem_key", 65);
// 2. 告诉管理员:我要找那个钥匙对应的储物箱
int shmid = shmget(key, 1024*1024, 0666);
// 3. 把这个箱子也“接”到我自己的房间里
char *ptr = (char*) shmat(shmid, NULL, 0);
// 4. 拿走东西
printf("进程 B 读到了: %s\n", ptr);
// 5. 分离
shmdt(ptr);
销毁共享内存:
- 当所有进程都用完了,通常由最后一个退出的进程(或者负责管理的父进程)来执行销毁。
// shmid: 共享内存的 ID
// cmd: 操作命令,IPC_RMID (Remove ID) 表示删除
// buf: 获取状态的结构体指针,删除操作传 NULL 即可
shmctl(shmid, IPC_RMID, NULL);
- 运行机制:调用 IPC_RMID 后,系统并不是立刻销毁它,而是把它标记为“待删除”。只有当所有挂载过它的进程都调用了 shmdt 脱离关系后,内核才会真正释放这块物理内存。
- 也可以在终端手动清理:
- 查看当前系统里遗留的共享内存:
ipcs -m - 删除指定的共享内存:
ipcrm -m <指定的shmid>
- 查看当前系统里遗留的共享内存:
注意,共享内存不会自动保证同步,如果多个进程同时读写,就会产生竞态条件。常用的解决办法是使用互斥锁(Mutex):
pthread_mutex_lock(&shm->mutex);
shm->data = 100;
pthread_mutex_unlock(&shm->mutex);
或者信号量(Semaphore):
sem_wait(sem); // P操作,加锁
shm->data++;
sem_post(sem); // V操作,解锁
3.4Socket:不仅可以跨机,也可以在本地(Loopback)用于进程间通讯。
- 原理:虽然叫 Socket,但它不走网络协议栈,直接在内存中拷贝数据。支持全双工(同时读写),可以传输复杂结构体,非常稳定。但是比共享内存慢,因为涉及数据拷贝。
3.5信号量 (Semaphore) —— “红绿灯”同步(类似多线程的互斥锁,按此理解,有不同但是可以这么理解)
原理:内核维护的一个计数器,用于协调多个进程对共享资源的访问。
- 注意:在共享内存项目中,没有信号量会导致“画面撕裂”(写了一半就被读走了)。
配合逻辑:- 写进程:P操作(申请锁) -> 写入图像 -> V操作(释放锁)。
- 读进程:P操作(等待锁) -> 读取图像 -> V操作(结束读取)。
3.6消息队列 (Message Queue) —— 异步“邮箱”
原理:内核维护的一个带类型的消息链表。
- 优点:发送者和接收者完全解耦。
- 可以给每条消息打个类型标签,接收者可以只收“类型为2”的消息。
- 异步性:发送者把消息丢进队列就可以跑了,不需要等接收者。
- 自带同步:内核帮你管理队列,你不需要自己写信号量或互斥锁。
- 消息结构:
- 发送端:
#include <sys/msg.h>
#include <iostream>
#include <cstring>
//
struct msg_data {
long type; // 自定义消息类型(必须 > 0),这是接收方的“过滤器”
char text[128]; // 消息正文(可以是任何数据类型,包括结构体)
};
int main() {
key_t key = ftok(".", 'a');
int msgid = msgget(key, IPC_CREAT | 0666); // msgflg: IPC_CREAT | 0666
msg_data msg1 = {1, "Low Priority Task"};
msg_data msg2 = {2, "URGENT STOP"}; // 紧急消息
msgsnd(msgid, &msg1, sizeof(msg1.text), 0);// msgflg: 0 表示阻塞发送,IPC_NOWAIT 表示队列满了立刻返回
msgsnd(msgid, &msg2, sizeof(msg2.text), 0); // 连发两条
std::cout << "消息已入队。" << std::endl;
return 0;
}
- 接收端:
#include <sys/msg.h>
#include <iostream>
struct msg_data {
long type;
char text[128];
};
int main() {
key_t key = ftok(".", 'a');
int msgid = msgget(key, 0666);
msg_data recv;
// 重点:我们只想要类型为 2 的紧急消息!
// msgtyp:
// 0: 获取队列中第一条消息
// >0: 只获取类型为 msgtyp 的第一条消息(非常强大!)
msgrcv(msgid, &recv, sizeof(recv.text), 2, 0);
std::cout << "收到紧急消息: " << recv.text << std::endl;
return 0;
}
尽管从理论上来讲消息队列是全双工的支持双向通信,但通常不会这么做,会导致消息混乱。为了实现全双工通信,我们会使用两条消息队列,分别负责两个方向的通信,类似于管道。
4. 核心函数:fork()
在 Linux 中创建新进程几乎只有一种方式:调用 fork()。
- 奇特之处:调用一次,返回两次。
- 父进程:返回子进程的 PID。
- 子进程:返回 0。
#include <iostream>
#include <unistd.h> // fork 所在的头文件
int main() {
pid_t pid = fork(); //pid_t是int的别名一种返回值类型
//(1) 在父进程中 返回子进程的 PID
//(2) 在子进程中 返回 0
//(3) 发生错误 返回-1
if (pid < 0) {
// 创建失败
perror("fork failed");
} else if (pid == 0) {
// 这里是子进程
std::cout << "我是子进程,PID: " << getpid() << std::endl;
} else {
// 这里是父进程
std::cout << "我是父进程,刚才创建了子进程: " << pid << std::endl;
}
return 0;
}
5.写时拷贝
在调用fork()时,Linux内核会把父进程的内存、文件描述符表、寄存器状态等全部克隆一份给子进程。
- 此时两个完全独立的程序都在运行,且都运行到了
fork()返回的那一行。 - 身份区分:
- 在父进程的地址空间里,
fork()返回的是子进程的 PID(即 1002),所以它执行 else 分支。 - 在子进程的地址空间里,
fork()返回的是 0,所以它执行else if (pid == 0)分支。
但是请注意,表面上是把父进程所有东西都克隆给子进程,但是实际上为了节省资源,linux不会真的复制所有内存:
- 在父进程的地址空间里,
- 初始状态:父子进程暂时共享同一块物理内存。
- 触发时刻:只要其中任何一个进程尝试修改变量,内核就会立刻为该进程复制一个新的内存页。
- 结果:这就是为什么子进程改了变量,父进程却毫无察觉。也就是上面的代码一个pid变量会有两个值,实际上是不同进程运行的结果。
6.核心函数:execve()
exec 系列函数可以在同一个进程中跳转执行另外一个程序。execve 的本质:灵魂替换。execve 的作用不是“创造”,而是“替换”:
- PID 不变:调用
execve前后,进程的 ID (PID) 是完全一样的。 - 代码和数据全换:它会把当前进程的所有代码段、数据段、堆、栈全部清空,然后从磁盘加载一个新程序来填充这块内存。
- 不复返:一旦成功,旧程序的代码就不存在了,所以你代码里
execve之后的printf永远不会执行。
#include <iostream>
#include <unistd.h> // fork, execve
#include <sys/wait.h> // waitpid
#include <vector>
int main() {
std::cout << "[主控] 启动,当前 PID: " << getpid() << ",准备加载渲染模块..." << std::endl;
// 1. 创建分身
pid_t pid = fork();
if (pid < 0) {
perror("Fork 失败");
return -1;
}
else if (pid == 0) {
// --- 这里是子进程的空间 ---
std::cout << "[子进程] 我出生了,PID: " << getpid() << ",现在开始变身为渲染引擎!" << std::endl;
// 2. 准备变身参数 (execve)
// 参数1: 要运行的可执行文件路径
char *path = (char*)"/home/atguigu/process_test/render_engine";
// 参数2: 传给新程序的命令行参数,第一个必须是程序名本身,最后一个必须是 NULL
char *args[] = {(char*)"render_engine", (char*)"--mode=3DGS", (char*)"--fps=60", NULL};
// 参数3: 环境变量,最后一个必须是 NULL
char *envs[] = {(char*)"LD_LIBRARY_PATH=/usr/local/cuda/lib64", NULL};
// 3. 执行变身
execve(path, args, envs);
// 如果 execve 成功,下面这行永远不会执行
perror("变身失败!检查路径是否存在");
exit(1);
}
else {
// --- 这里是父进程的空间 ---
std::cout << "[主控] 我还在运行,我创建了子进程 PID: " << pid << std::endl;
std::cout << "[主控] 我现在可以继续处理 Socket 通讯或 Modbus 电机控制了。" << std::endl;
// 在机器人项目中,父进程通常不会立即退出,而是等待或监控
int status;
waitpid(pid, &status, WNOHANG); // 非阻塞地检查一下子进程状态
// 模拟主控在工作
for(int i = 0; i < 5; i++) {
std::cout << "[主控] 正在发送电机心跳包..." << std::endl;
sleep(1);
}
}
return 0;
}
这段代码中执行了execve(path, args, envs);这一句后 ,后续的代码就不执行了,改为执行"/home/atguigu/process_test/render_engine"这个可执行文件的内容了。
其模板为:
char *__path: 需要执行程序的完整路径名char *const __argv[]: 指向字符串数组的指针 需要传入多个参数
(1) 需要执行的程序命令(同*__path)
(2) 执行程序需要传入的参数
(3) 最后一个参数必须是NULLchar *const __envp[]: 指向字符串数组的指针 需要传入多个环境变量参数
(1) 环境变量参数 固定格式key=value
(2) 最后一个参数必须是NULLreturn: 成功就回不来了 下面的代码都没有意义- 失败返回-1
7.核心函数:waitid()
Linux 中父进程除了可以启动子进程,还要负责回收子进程的状态。如果子进程结束后父进程没有正常回收,那么子进程就会变成一个僵尸进程——即程序执行完成,但是进程没有完全结束,其内核中 PCB 结构体没有释放。
pid_t waitpid(pid_t pid, int *status, int options);
参数解释:pid:
- 0:等待特定的子进程。
- -1:等待任何一个子进程(最常用)。
*status: - 一个整数指针,内核会把子进程的退出状态(正常退出、被信号杀死等)填进去。
options: - 0:阻塞等待(父进程停在这,直到子进程结束)。
- WNOHANG:非阻塞等待。父进程看一眼子进程没挂就立刻返回 0。这在机器人开发中非常重要,因为父进程还要跑心跳包,不能死等渲染进程。
return: - 成功等到子进程停止,返回pid;
- 没等到并且没有设置WNOHANG,一直等;
- 没等到,但是设置了WNOHANG,返回0;
- 出错返回**-1**
waitid 提供了比 waitpid 更细粒度的控制:
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
idtype:可以是 P_PID(等特定进程)或 P_ALL(等所有)。infop:这是一个结构体,能告诉你子进程挂掉的具体原因(比如是被哪个信号杀死的)。options:支持 WEXITED(等退出)、WSTOPPED(等停止)等。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)