1. 前言
前面章节反复提到:进程之间内存是相互隔离的,一个进程修改自己的变量,另一个进程无法感知。隔离保证了安全性,但也带来了新的问题——进程之间需要传递数据时怎么办?这就是进程间通信(IPC,Inter-Process Communication)要解决的问题。
本章节主要目标:
- 理解进程间通信为什么必须依赖"双方都能访问的公共资源"。
- 掌握匿名管道的特性与使用,分析 shell 管道符
|的实现。 - 掌握共享内存的四个接口,理解它为什么是最快的 IPC 以及使用时需要注意的问题。
进程间通信的本质前提是:存在一块两个进程都能访问到的公共资源。管道访问的是内核中的缓冲区,共享内存访问的是同一块物理内存,这是两种方案最根本的区别。
2. 匿名管道
2.1 pipe 接口
pipe系统调用在内核中创建一段缓冲区,返回两个文件描述符:
#include <unistd.h> int pipe(int pipefd[2]); // pipefd[0]:读端 // pipefd[1]:写端 // 数据从写端进入,从读端取出,是单向的2.2 匿名管道的特性
- 基于内存,不是磁盘文件:数据在内核缓冲区中传输,不涉及磁盘 IO
- 单向通信:数据只能沿一个方向流动,需要双向通信时应创建两根管道
- 只能用于有亲缘关系的进程:原因在下文的实验中分析
- 自带同步与互斥机制:管道为空时 read 阻塞等待,管道写满时 write 阻塞等待,不需要调用者额外加锁
- 生命周期随进程:所有使用该管道的进程都退出后,管道自动销毁
2.3 实验:父子进程通过管道通信
// test_pipe.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/wait.h> int main() { int pipefd[2]; if (pipe(pipefd) < 0) { perror("pipe"); return 1; } // 第一步:创建管道 pid_t id = fork(); // 第二步:fork if (id == 0) { // 子进程:关闭读端,向写端写入数据 close(pipefd[0]); const char *msg = "hello father, 我是子进程"; write(pipefd[1], msg, strlen(msg)); close(pipefd[1]); exit(0); } // 父进程:关闭写端,从读端读取数据 close(pipefd[1]); char buf[128] = {0}; ssize_t s = read(pipefd[0], buf, sizeof(buf) - 1); printf("父进程读到 %ld 字节: %s\n", s, buf); close(pipefd[0]); wait(NULL); return 0; }gcc test_pipe.c -o test_pipe ./test_pipe # 父进程读到 27 字节: hello father, 我是子进程注意代码的顺序:先 pipe 再 fork。这就是"只能用于亲缘进程"的原因:管道没有名字,它的两个 fd 只存在于创建者进程的 fd 表中;fork 时子进程复制父进程的 fd 表,因此继承到管道的两端。没有亲缘关系的进程无法获得这两个 fd,自然无法使用该管道。
2.4 shell 管道符|的实现
ls | wc -l能够把 ls 的输出交给 wc 处理,底层原理与上述实验一致,只是 bash 代为完成了管道创建与 fd 修改的工作:
1. bash 调用 pipe(p) 创建管道 2. fork 子进程 A:dup2(p[1], 1),将标准输出指向管道写端,然后 exec ls 3. fork 子进程 B:dup2(p[0], 0),将标准输入指向管道读端,然后 exec wc -l 4. bash 调用 wait 回收两个子进程这里用到了上一篇的重定向知识:管道配合 dup2,就把两个独立的命令连接成了一条数据流水线。
3. 共享内存
3.1 原理
使用管道通信时,数据需要从进程 A 写入内核、再从内核读到进程 B,存在两次拷贝。 共享内存的思路是:在物理内存中开辟一块公共区域,将它同时映射到两个进程的地址空间中,双方直接读写这块内存,内核不参与数据传输。整个通信过程没有数据拷贝(零拷贝),因此共享内存是速度最快的 IPC 方式。
3.2 四个接口
| 函数 | 功能 |
|---|---|
shmget | 向内核申请一块共享内存,返回标识 shmid |
shmat | 将共享内存挂接到进程的地址空间,返回起始地址 |
shmdt | 去关联,解除当前进程与共享内存的映射关系 |
shmctl | 控制操作,配合IPC_RMID删除共享内存 |
3.3 实验:通过共享内存通信
// test_shm.c:父进程创建共享内存,子进程写入,父进程读取 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/ipc.h> #include <sys/shm.h> #include <sys/wait.h> int main() { // 1. 申请 4096 字节的共享内存 int shmid = shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0666); if (shmid < 0) { perror("shmget"); return 1; } pid_t id = fork(); if (id == 0) { char *addr = (char *)shmat(shmid, NULL, 0); // 子进程挂接 strcpy(addr, "hello father, 我写在共享内存里"); // 直接写内存,没有 write shmdt(addr); // 解除映射 exit(0); } wait(NULL); // 等待子进程写完 char *addr = (char *)shmat(shmid, NULL, 0); // 父进程挂接 printf("父进程读到: %s\n", addr); // 直接读内存,没有 read shmdt(addr); shmctl(shmid, IPC_RMID, NULL); // 显式删除共享内存 return 0; }gcc test_shm.c -o test_shm ./test_shm # 父进程读到: hello father, 我写在共享内存里可以看到,整个通信过程没有出现 read/write,只有 strcpy 和 printf——shmat 挂接之后,这块共享内存对进程来说就是自己的内存,可以直接访问。这就是"零拷贝、速度最快"的直观体现。
3.4 生命周期:随内核,不随进程
注意:共享内存的生命周期随内核,而不是随进程。进程退出、调用 shmdt 解除映射,共享内存都不会被释放,它会一直保留在内核中等待下一次挂接,直到有进程显式调用shmctl(shmid, IPC_RMID, NULL)将其删除(或者系统重启)。 如果程序忘记删除,多次运行后内核中会残留大量共享内存。这一点与管道形成鲜明对比:管道在最后一个使用者退出后自动销毁。
3.5 同步问题
共享内存本身不提供任何同步互斥机制:内核不会协调读写双方,写进程写到一半时读进程来读取,读到的就是不完整的数据。实际使用中需要配合信号量(semaphore)来保证"写完再读"。上面的实验用wait(NULL)等待子进程写完,只是最简单的处理方式。
4. 命名管道 FIFO
匿名管道没有名字,只能用于亲缘进程。通过mkfifo命令或函数可以在文件系统中创建一个带名字的管道文件,即命名管道(FIFO),这样任意两个进程都可以通过路径名打开它进行通信。数据仍然在内核缓冲区中传输,不落盘。实际工程中共享内存配合信号量使用得更多,FIFO 了解基本概念即可。
5. 本章小结
- 进程内存相互隔离,IPC 的前提是存在双方都能访问的公共资源:管道是内核缓冲区,共享内存是物理内存。
- 匿名管道基于内存、单向、自带同步互斥、生命周期随进程、只能用于亲缘进程(fd 表通过 fork 继承)。
- shell 的
|通过 pipe + 两次 fork + dup2 + exec 实现。 - 共享内存将同一块物理内存映射到两个进程的地址空间,零拷贝,是最快的 IPC;四个接口为 shmget/shmat/shmdt/shmctl。
- 共享内存生命周期随内核,必须显式删除;且不提供同步机制,需要配合信号量使用。很多当时靠记忆背下来的结论,亲手写实验验证一遍之后理解会深刻很多。接下来继续学习多线程(pthread、互斥锁、条件变量),学完之后会继续更新。如果本篇文章有什么不足欢迎指正。