在学 Linux 系统编程的路上,你几乎一定会撞上这个疑问:我写了两个程序,各自跑得好好的,可它们之间怎么"说上话"?
比如你写了一个爬虫程序在后台抓数据,又写了一个分析程序想把抓到的结果读走;或者你的服务器程序派生了一堆子进程去干活,可随时要把新的任务派给它们。这些场景里,每个进程都有自己独立的一块地址空间——A 进程内存里写的变量,B 进程是看不见的。当两个进程需要交换数据、共享资源或者互相通知一声"我干完了"的时候,进程间通信(IPC,Inter-Process Communication,进程间通信)就登场了。
很多初学 Linux 的人觉得 IPC 是一团乱麻:又是管道又是共享内存又是信号量,看半天也不知道从哪下手。其实你只需要抓住一条主线:IPC 解决的是"多个进程如何协同"这件事。而所有 IPC 手段,最终都绕不开两个最朴素的工具——管道(pipe)和共享内存。这一篇,我带你把管道吃透,再带你把共享内存、消息队列、信号量这些兄弟认识一遍。你会发现,它们背后其实是同一套设计哲学。
这一课,你最好先有这些底子
在看下面的代码之前,有几样老相识请你先回忆一下,它们是你理解本课的地基:
- 文件描述符(fd,File Descriptor):Linux 里"一切皆文件",进程打开一个文件、管道、设备,内核都会给它一个非负的整数编号,这个编号就是文件描述符。
stdin是 0,stdout是 1,stderr是 2,之后新打开的资源从 3 开始编。管道操作全程都在和"描述符"打交道,所以这一条必须熟。 fork()与父子进程:fork()会复制当前进程,返回两个"分身",子进程里返回 0,父进程里返回子进程的 pid。父和子各自持有一份拷贝的内存,但文件描述符表最初是共享同一份底层的打开文件描述的——这正是管道能在父子间流通的关键。- 进程地址空间隔离:每个进程有自己独立的虚拟内存。这是"为什么必须 IPC"的根本原因,也是全文所有设计的前提。
- 阻塞与系统调用:很多系统调用(如
read、open)在没有数据时可让你的进程"睡"在那里,等条件满足再醒来,这叫阻塞(block)。管道就大量依赖这种机制。
这几条如果都清楚,那这趟 IPC 之旅你会走得很顺;如果哪条还恍惚,可以在遇到时回头翻一下。
为什么进程间要通信?
先用一句话回答开头那个疑问:因为进程的地址空间是彼此隔离的,代码里想"共享"的东西,必须借助内核这座中间桥梁显式地搬来搬去。 内核是唯一一个能被所有进程信任、且能访问所有进程数据的"大管家"。
具体到实际需求,进程间通信要解决的通常有四件事,每一件都对应一种很典型的使用场景:
- 数据传输:一个进程要把自己的数据交给另一个进程。最典型的例子:
cat file.txt | grep "hello",cat把文件内容源源不断吐给grep,中间那根竖线|,底层就是一条管道。 - 资源共享:多个进程要共同操作同一份资源,比如多个子进程都要读写同一个文件、同一片内存。共享资源用好了能省内存、提效率,但也埋着"多人抢东西打架"的隐患。
- 通知事件:一个进程要向另一个或一组进程"喊一声":发生某件事了。最典型的是子进程终止时,内核会向父进程发送一个信号,告诉它"你儿子死了"。这就是一种通知。
- 进程控制:有些进程想完全掌控另一个进程的执行,比如调试器(Debug 程序)要能拦截被调试进程的一切异常和系统调用陷、及时发现它状态的每个变化。这种"居高临下"的控制,前提也是两者之间建立了通信通道。
看到没有,这四件事几乎覆盖了所有"多进程协同"的需求。理解"为什么通信",比光记函数名重要得多——因为你会发现,后面每一种 IPC 手段,本质上都是在回答"我该用哪种方式满足上面哪一类需求"。
课堂思考
为什么两个完全独立的进程,不能像共享全局变量那样直接访问彼此的变量?
答案详解:因为 Linux 为每个进程建立了独立的地址空间。进程 A 里有个变量 x,它的虚拟地址是 0x7fff1234,这个地址只映射到 A 自己的页表里;进程 B 完全不知道这个地址对应的是谁,即使 B 硬闯,也会因为页表里没有对应映射而触发页错误(段错误)被内核杀死。虚拟内存机制保证了进程之间"井水不犯河水",从安全性上是天大的好事——一个进程崩溃不该波及另一个;但代价就是"想共享"必须显式绕道。内核是唯一能读所有进程内存的,于是所有 IPC 都通过"内核做中间人"或"内核分配一块大家都能映射的内存"来实现。隔离是安全,通信是协作,二者皆需,这就是 IPC 存在的原因。
进程间通信的发展与分类
Linux 借鉴了 Unix 几十年积累的经验,进程间通信的手段不是一天长成的,大致经历了三代演进:
- 管道:最古老、也最朴素的一批。Unix 从诞生那天起就有管道,今天你天天敲的
ls | grep用的还是它。 - System V IPC:由 AT&T 在 System V 版本的 Unix 里提出,包含共享内存、消息队列、信号量。Linux 把这套全盘接收了下来,今天的
sysvipc就是它。 - POSIX IPC:标准化组织把 IPC 再纯化、补齐的一代,包含消息队列、共享内存、信号量,还加上了互斥量、条件变量、读写锁这类更细粒度的并发原语。
我们把它们按家族列成一张表,方便对号入座:
| 家族 | 成员 | 一句话特点 |
|---|---|---|
| 管道 | 匿名管道 pipe、命名管道 FIFO | 字节流式,最简单,限定祖先关系/或靠文件路径会合 |
| System V IPC | 消息队列、共享内存、信号量 | 内核全局管理,资源用 ipcs 看、用 ipcrm 撤,生命周期随内核 |
| POSIX IPC | 消息队列、共享内存、信号量、互斥量、条件变量、读写锁 | 接口更规范统一,是现代并发编程的主力 |
注意一个区分点:System V 那套IPC 资源的生命周期随内核——进程退了,资源不一定自动消失(除非你是用 IPC_RMID 主动删,或重启系统)。这是个很容易踩的坑,我们到共享内存那节会专门讲。
由于篇幅和由浅入深的顺序,本篇把最经典、也最能讲透原理的管道作为重头戏,用完它你自然理解"进程怎么通过内核传递数据";然后介绍共享内存这个"最快的 IPC",最后带你把消息队列和信号量这两个选学知识点讲清楚。信号(signal)虽然也被算在通信手段里,但它比较特殊、独立成章,本篇不做重点。
什么是管道?
管道(pipe)是 Unix 中最古老的进程间通信形式。它的名字起得很形象——你想象一根真实的管子:一头往里倒水(写入),另一头出水(接住数据)。管道所做的,就是把从一个进程连接到另一个进程的一个数据流(data stream)抽象为一根"管子":数据从一端流进,从另一端流出,先进先出,像排队一样。
讲到这里你可能会问:这不就是一个队列吗?对,管道在内核里本质上就是一个由内核管理的环形缓冲队列,加上一对可供操作的文件描述符作为"管口"。它最大的优点是行为像文件——对你(程序员)而言,往管道写数据就是 write(fd, ...),从管道读数据就是 read(fd, ...),跟你操作普通文件毫无二致。这正是 Linux"一切皆文件"哲学最生动的体现。
根据"管口"能不能让别人找到,管道分成两类:
- 匿名管道(pipe):没名没姓,只给"有血缘关系"的进程用(通常是父子),我们用
pipe()创建。 - 命名管道(FIFO):在文件系统里有一个真实存在的名字,叫 FIFO 文件,任何进程只要能以合法权限打开它,就能通信,我们用
mkfifo()创建。
这两类是我们本课的主角,接下来先啃匿名管道。
匿名管道:pipe 函数
匿名管道的创建函数长这样:
#include <unistd.h>
// 功能:创建一个匿名管道
// 原型:int pipe(int fd[2]);
// 参数:fd 是长度为 2 的文件描述符数组
// fd[0] 表示读端(read 用)
// fd[1] 表示写端(write 用)
// 返回值:成功返回 0,失败返回 -1(并设置 errno)注意 fd[0] 和 fd[1] 的方向别搞反了——fd[0] 永远只用于读,fd[1] 永远只用于写。这个方向是管道设计上就定死的,后面我们会解释"为什么管道必须这样单向"。
第一个实例:键盘进,管道过,屏幕出
先看一个最能直观感受"管道长什么样"的例子:从键盘读一行数据,写入管道,再从管道把数据读回来,写到屏幕。
// pipe_demo.c —— 演示 pipe 最基本的读写
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
int fds[2]; // 管道文件描述符数组:fds[0] 读端,fds[1] 写端
char buf[100]; // 收发的数据缓冲
int len; // 实际读入/写出的字节数
// 创建匿名管道;成功的两个描述符分别放进 fds[0]、fds[1]
if (pipe(fds) == -1) { // pipe 失败返回 -1
perror("make pipe"); // 打印错误原因
exit(1); // 出错直接退程序
}
// 循环:从标准输入读一行 -> 写入管道 -> 从管道读回 -> 写到屏幕
while (fgets(buf, 100, stdin)) { // fgets 从键盘读到一行(含换行符)
len = strlen(buf); // 计算这一行共有多少个字节
// 往管道写端 fds[1] 写入 len 个字节
if (write(fds[1], buf, len) != len) { // 写出的字节数应等于 len
perror("write to pipe");
break;
}
memset(buf, 0x00, sizeof(buf)); // 清空缓冲,方便观察新数据
// 从管道读端 fds[0] 读数据,read 返回实际读到的字节数
if ((len = read(fds[0], buf, 100)) == -1) { // 读到 -1 表示出错
perror("read from pipe");
break;
}
// 把管道里读到的内容写到标准输出(fd 1 即屏幕)
if (write(1, buf, len) != len) {
perror("write to stdout");
break;
}
}
return 0;
}编译运行它:
gcc -o pipe_demo pipe_demo.c # 编译
./pipe_demo # 运行,键入 hello 回车试试你敲 hello 回车,屏幕上就会原样回显 hello。这个程序用到了读写两端,但它只在自己一个进程里自写自读,还没体现"多进程通信"。它真正的价值是让你看清楚一件事:管道读写用的 write/read,和你读写普通文件、读写终端,用的是同一套接口。这就是"一切皆文件"的体现。
不过要提醒你一个细节:这个单人自写自读的例子,读端和写端明明都在自己手里,数据"走了一圈"没有任何一方被阻塞。但一旦我们把读写两端分给两个进程,阻塞的问题就来了——那是下一节的内容。
这个问题很值得停下来想一想:
课堂思考
上面这个自写自读的例子,为什么 read 不会永远阻塞等不到数据?
答案详解:因为这里写端 fds[1] 始终开着,而且写和读发生在同一个进程、同一个循环里。我们先把数据写进管道(缓冲里有货了),紧接着同一进程马上 read,管道里有数据,read 立刻返回,所以不会卡死。管道"read 是否阻塞"的完整规律,我们在"管道的读写规则"那一节会系统总结。你现在先建立直觉:当且仅当管道里没有数据、同时又不满足"立刻结束"条件时,read 才会阻塞。
fork 之后:父子如何共享一条管道
单进程玩管道没意思,管道的真正威力在 fork() 之后迸发出来。
回忆一下 fork() 的关键行为:fork() 之后,父进程和子进程各自拥有一份独立的内存拷贝,但它们最初共享的是从父进程中继承下来的那套打开文件描述符(更准确地说,是共享同一批"打开文件描述"/内核对象,各自的文件描述符表都指向那批对象)。所以管道在 fork 之前创建,fork 之后父子两边同时各持有 fd[0] 和 fd[1]——这就好比一根管子,两头各有两只手(父一只、子一只),谁想用哪头取决于我们怎么分工。
请看一个经典的分工写法:
// pipe_fork.c —— 子进程写,父进程读
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
// 一个宏,打印错误信息后退出,让代码更简洁
#define ERR_EXIT(m) \
do { perror(m); exit(EXIT_FAILURE); } while (0)
int main(void)
{
int pipefd[2]; // 管道两端
if (pipe(pipefd) == -1) // 1) 父进程先创建管道
ERR_EXIT("pipe error");
pid_t pid = fork(); // 2) 再 fork,父子共享这条管道
if (pid == -1)
ERR_EXIT("fork error");
if (pid == 0) { // 子进程分支:负责"写"
close(pipefd[0]); // 子进程只用写端,先把读端关掉
write(pipefd[1], "hello", 5); // 向管道写入 5 个字节
close(pipefd[1]); // 写完关闭写端
exit(EXIT_SUCCESS); // 子进程退出
}
// 走到这里的是父进程分支:负责"读"
close(pipefd[1]); // 父进程只留读端,把写端关掉
char buf[10] = {0}; // 读缓冲,清零
read(pipefd[0], buf, 10); // 从管道读端阻塞读取数据
printf("buf=%s\n", buf); // 打印读到内容:hello
return 0;
}gcc -o pipe_fork pipe_fork.c && ./pipe_fork它的输出是 buf=hello。
这里我特别要带你看懂两行看似多余、实际上全是点睛之笔的 close:
- 子进程
close(pipefd[0]):子进程只打算写、不打算读,那把读端在自己手里盘着纯属碍事。关上它,避免"自己读自己写"造成一些诡异的互相牵制。 - 父进程
close(pipefd[1]):父进程只读不写,同样把手里的写端关掉。
为什么要关掉不需要的那一端?这恰恰是全天下新手写管道最容易翻车的点,我们留到"读写规则"里用四条铁律一次讲清。现在你先记住一个动作习惯:分工后,谁用写端谁保留 fd[1],谁用读端谁保留 fd[0],多余一端务必 close。
站在文件描述符的角度看管道
我们把上面那次 fork 前后的"文件描述符版图"画出来,你就彻底通了。
fork 之前,父进程手里有:
父进程的文件描述符表:
fd 0 -> 标准输入(键盘)
fd 1 -> 标准输出(屏幕)
fd 2 -> 标准错误(屏幕)
fd 3 -> 管道读端(读方向) <- pipefd[0]
fd 4 -> 管道写端(写方向) <- pipefd[1]
fork 之后,子进程继承了一份几乎一样的描述符表(fd 3、4 都还指向同一个管道对象):
子进程的文件描述符表(刚 fork 完):
fd 0 -> 标准输入 fd 1 -> 标准输出 fd 2 -> 标准错误
fd 3 -> 管道读端 fd 4 -> 管道写端
然后我们各自关掉不要的一端:子进程 close(3)(读端),父进程 close(4)(写端)。于是最终分工变成:
父进程:fd 3(读端)----管道对象---- 子进程:fd 4(写端)
只有父能读 只有子能写
数据流的方向就很清楚了:子进程往 fd 4 写,进入内核里的管道缓冲,父进程从 fd 3 读走。一个方向,刚刚好。这个"各自的描述符表 + 共享的底层管道对象"的图景,是你理解、也调试一切管道代码的钥匙。 看你自己的进程有多少"多余的"描述符,也是 ls -l /proc/<pid>/fd 就能查。
关于描述符编号,还有一个面试常考的细节:内核给新描述符分配号码时,总是优先给当前最小的空闲编号。所以上面 pipe 得到的是 3 和 4(因为 0、1、2 被标准流占了);如果你在一个进程里反复 open/close,描述符号会被反复重用。这一点结合 dup2 用,能解释 shell 重定向的很多行为。
站在内核的角度:管道的本质
拨开文件描述符这层"外衣",管道在内核里的真身是什么?
一句话:管道是内核维护的一块有界缓冲区(一个环形队列),并用一套"生产者--消费者"的同步机制来协调读写两端。 你把数据从 fd[1] 写进去,它就排队排在缓冲尾巴上;你从 fd[0] 读,就读走缓冲头的第一个数据。先进先出,这就是管道的字节流本质。
write 一个字节进管道,最终会调用内核例程一路落到这块缓冲里;read 从这块缓冲里取字节返回给用户。所以看待管道,就像看待一个文件:"Linux 一切皆文件"思想在这里落地生根——管道的读写接口和文件完全一致,差别只在后台的载体是"内存缓冲"而不是"磁盘块"。
这还引出一个对性能很重要的点:管道数据不走磁盘,中间不落盘,所以管道其实是相当快的一种进程间数据搬运方式。它的短板是"有界"——缓冲就那么大,满了就得等,这个我们马上讲。
一个精确但常被课件一句话带过的细节:Linux 上管道默认容量通常是 64KB(65536 字节);而"单次 write 保证原子写入"的阈值叫 PIPE_BUF,Linux 上通常是 4096 字节。这两个数你可以用 ulimit -p、或直接查 man 7 pipe 进一步确认,不同内核版本细节可能有差异。它们在我们"读写规则"里会出现。
管道样例:亲手验证一次读写
光看理论不过瘾,我们写一个能真正"跑起来、能看到阻塞与非阻塞"的小样例来验证管道的读写行为。
// pipe_rw.c —— 验证管道的阻塞读写与字节流特性
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main(void)
{
int fd[2];
char buf[16];
if (pipe(fd) == -1) { // 创建管道
perror("pipe");
exit(1);
}
// 爸爸写,儿子读,中间用 fork 分工
pid_t pid = fork();
if (pid == 0) { // 子进程:写端
close(fd[0]); // 子进程只写,关读端
for (int i = 0; i < 3; i++) { // 连写三次
char msg[32];
snprintf(msg, sizeof(msg), "第%d条消息", i + 1);
write(fd[1], msg, strlen(msg)); // 写入管道
sleep(1); // 每写一条歇 1 秒
}
close(fd[1]); // 写完了,关写端
exit(0);
}
// 父进程:读端
close(fd[1]); // 父进程只读,关写端
ssize_t n;
// 试着读,但注意:一次 read 可能只拿到"管道里现有的一部分"
while ((n = read(fd[0], buf, sizeof(buf))) > 0) {
buf[n] = 0; // 补终止符,当作字符串打印
printf("[父进程收到] %s\n", buf);
}
printf("[父进程] 读到 EOF(写端全部关闭),退出\n");
close(fd[0]);
return 0;
}gcc -o pipe_rw pipe_rw.c && ./pipe_rw你盯着输出会看到父进程把三条消息一条条收走,最后当子进程关掉写端、父进程再 read 时返回 0,退出。整个过程中,读写两端靠内核"同步+缓冲"自动配合,谁也不丢数据、也不重复读同一段数据。这就是管道的"自同步"魅力——内核帮你排队,你只管读。
课堂思考
这个程序里,父进程每次 read 一定能恰好拿到一整条消息("第N条消息")吗?
答案详解:不一定,这是字节流(stream)的天性。 管道是纯字节流,它在内核里只是一个字节队列,没有"消息边界"。read 读到多少,取决于"当时管道缓冲里有多少字节"以及"你请求读多少字节",而不是取决于写端"一次 write 写入了什么"。本例中每次消息 4-6 个字节,比缓冲小,父进程 read 请求 16 字节,通常一次就能把当时缓冲里那条消息全读走——但这只是"碰巧"加上时间差让它看起来像一次一条。如果两条消息连着写进来、中间没有 sleep,父进程可能一次 read 就把两条一起读走了。凡是"管道传输结构化消息"的需求,都得自己在数据里约定边界(比如用长度前缀、用换行分隔),这是管道用得深了必然要面对的坑。 而"不丢数据、不乱序"这一点,管道是保证的——它只是不保证"分组"。
管道的读写规则
现在终于进入管道最容易翻车、也最核心的一块:当"读空/写满/端被关闭"时,read 和 write 到底会怎样?我们把规则拆成四条铁律,务必逐条吃透。
规则一:当管道里没有数据可读时,read 的行为取决于是否设置 O_NONBLOCK(非阻塞)。
- 如果
O_NONBLOCK是关闭的(默认):read阻塞。调用进程被挂起,直到有数据被写进管道才醒来、返回。 - 如果
O_NONBLOCK被启用:read立即返回 -1,且errno被设为EAGAIN(意思是"再试一次")。调用不阻塞,但也没读到东西。
这是"读空"的情形。所谓 O_NONBLOCK,是文件描述符上的一个打开标志(open flags),可以用 fcntl 打开或关闭。它决定了"没有数据时,我是等还是不等"。
规则二:当管道写满时,write 的行为同样取决于 O_NONBLOCK。
- 关闭非阻塞:
write阻塞,直到有别的进程从管道读走了一些数据、腾出空间,写端才继续完成写入。 - 开启非阻塞:
write立即返回 -1,errno设为EAGAIN。
这是"写满"的情形。因为没有读者来消费,管道这块有界缓冲会满,写端自然就得等等。
规则三:如果所有写端对应的文件描述符都被关闭了,read 返回 0。
注意"所有"这个字。只要还有一个进程还持有打开的写端 fd,read 就还会阻塞等待;只有当连一个写端都不剩时,read 才返回 0——这在 read 的语义里就是"文件结束(EOF)"的等价信号。回想上一节 pipe_rw,子进程 close(fd[1]) 后父进程 read 返回 0,正是这条规则在起作用。
规则四:如果所有读端对应的文件描述符都被关闭了,write 会触发信号 SIGPIPE(默认杀死进程),进而可能导致写进程退出。
这是管道最狠、也最名声在外的一条坑。你想:用户那边读端全关了,"没人听了",你再往里喊话,只会把数据灌进一个根本没人收的管道——这除了浪费没任何意义,所以内核直接发一个 SIGPIPE 信号给你。SIGPIPE 的默认处置是终止进程。于是"向一个读端已关闭的管道写入"的程序,常常会莫名其妙地"死掉",而你还以为是随机崩溃。
来看一个能让你亲身体验这个坑的最小程序:
// sigpipe_demo.c —— 演示读端全关后写入被 SIGPIPE 杀死的坑
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
int fd[2];
pipe(fd); // 创建管道
close(fd[0]); // 立刻把唯一的读端关掉:没人听了
// 默认行为:向读端已关闭的管道写入
// 内核会给我们发 SIGPIPE,默认动作是杀掉进程,这行走不完
if (write(fd[1], "x", 1) < 0)
perror("write"); // 正常情况下你根本到不了这里,进程已被信号干掉
printf("我还活着吗?\n"); // 这句十有八九打印不出来
return 0;
}gcc -o sigpipe_demo sigpipe_demo.c && ./sigpipe_demo
echo "退出码: $?" # 你会看到退出码是 141,即 128 + 13(信号 13 就是 SIGPIPE)运行它,程序会直接"死"在 write 那一行附近——退出码 141,正对应"被信号 13(SIGPIPE)杀死"。
这块坑的解决方案也很明确:如果你希望程序在"读者跑了"时不采用默认的"自杀",而是优雅地处理(比如清理资源、写个日志再退出),就用 signal(SIGPIPE, SIG_IGN) 把 SIGPIPE 忽略掉,之后 write 就不会杀进程,而是返回 -1、errno 变为 EPIPE,让你从容判断"对面已经不在了"。这对那些"一边写一边要随时检查对方是否还在"的网络/管道服务尤为重要。
我自己到处强调采这条规则,是因为它在实践里造成的 bug 层出不穷——比如父进程忘记关闭写端、或子进程提前退出把读端全关掉,后台任务突然"静默死亡",一查退出码 141,多半就是 SIGPIPE。
课堂思考
为什么规则三和规则四要强调"所有"读端/写端都关闭,而不是"一个"读端关闭?
答案详解:因为fork 之后,同一个管道对象可能被多个进程的多个 fd 引用。比如父进程创建管道后 fork 了俩儿子,A、B 都持有写端 fd。如果 A 关闭了写端、但 B 还开着,那么对读端来说"还有写者",read 就该继续等,不能返回 0(否则会误判是 EOF);只有 A、B 全关了,read 才返回 0。反过来也一样:读端有父子双方各一个 fd,只关一个不够,必须全部关完,write 才触发 SIGPIPE。这也是之前反复劝你"不用的那端赶紧 close"的深层理由——不 close 掉多余端,你以为写完了别人却还"等着",或者对面以为还有读者,就会造成谁也等不着的死锁式卡顿。多进程场景下,"有没有某个方向的 fd 还开着"是很多诡异行为的根源。
管道的几大特点
把上面四规则背后的设计沉淀一下,管道攒下了这么几条"性格",它们既是你写管道时要有的约束,也是面试常被追问的点:
- 只能用于有亲缘关系的进程间通信。 匿名管道没有路径名,只有通过
fork让子进程继承 fd 才能"接上话"。通常流程是:一个进程先pipe,再fork,然后父子之间靠这条管道通信。这就界定了匿名管道的适用圈——父子(及孙辈)进程之间。 - 提供流式服务(stream service)。 数据以字节流的形式流动,先进先出,没有消息边界。我们已经反复强调过,这是它简单但需要自定协议的由来。
- 一般而言,进程退出、管道随之释放;更准确地说,管道的生命周期取决于"引用它的所有文件描述符都被关闭"的那一刻。 课件上常说"生命周期随进程",这是为了方便记忆;精确的机制是内核持有管道对象的引用计数,当最后一个 fd 关闭(进程退出也会关闭它持有的 fd)时,管道对象才被回收。
- 一般而言,内核会对管道的操作做同步与互斥。 读和写被内核协调得很好,同一时刻只有一个方向在动数据,多个进程同时读同一个管道也不会把数据读乱。你不需要自己加锁。
- 管道是半双工的(half-duplex),数据只能朝一个方向流动。 如果你需要"两边都能说"(双向通信),那就得建两条管道,一条管你→我,一条管我→你。
第 5 条值得单独拿出来深挖,因为它牵出一个高频考点。
为什么管道只能单向?
很多初学者会问:pipe 不是同时给了我 fd[0] 和 fd[1] 两个端吗?那我把 fd[1] 当做"A 发往 B 的口",把 fd[0] 反过来当成"B 发往 A 的口",不就能双向了吗?
答案是不行,原因在管道的内核实现里:管道实质上是一个有方向性的缓冲队列。fd[1] 是队列的"写入口"(队尾),fd[0] 是队列的"读出口"(队头),方向在创建时就焊死成一个方向。你可以把整个管道想象成一条单行的传送带:东西只能从这头放到传送带上、从另一头取下来,没有一条"逆向传送带"。想反方向传数据,唯一的办法是再加一条独立的管道,让第二个方向也有自己的传送带。
打个比方:一条单行道,你给 A、B 各配了一扇门,但数据永远只能从"这扇写门"进、从"那扇读门"出。A 想回复 B,得在 A 和 B 之间再修一条单行道。这就是"半双工"的本义——同一时刻只能一个方向传,而且想换方向就必须多修路。
这么做看似笨,实则必要:如果有向缓冲能"反着来",那读和写就互相交叉,字段方向、同步规则、死锁分析会立刻乱成一锅粥。用两条管道做全双工,简单、清晰、各管各的,正是 Unix 几十年的正解。
课堂思考
两个进程 T1、T2 需要随时互相发消息(全双工),用管道该怎么做?一旦只建了一条管道会出现什么后果?
答案详解:正确做法是建两条管道:pipe(fd1) 专供 T1→T2,pipe(fd2) 专供 T2→T1。T1 往 fd1 的写端写、从 fd2 的读端读;T2 反之。两条单行道合起来就是双向道。如果只建一条管道并试图"两端都能读写",则会踩两个雷:其一,半双工符号冲突——fd[0] 语义上只允许读、fd[1] 只允许写,你根本没办法通过同一根管道把数据从我这边"倒灌"回你那边;其二,即便强行在两端各 open 一套读写 fd(这其实已经是"开着两个口在同一根管道上"),读写会争抢同一块缓冲、方向混乱,极易出现一面写不出去、一面读不回来的互相卡死。所以别省那一条 pipe,双向通信用两条,各司其职。
用四种情况验证管道通信的全貌
光有规则,不如亲手把"读端/写端存在与否"的四种组合全测一遍来得深刻。这四种情况是:
- 读正常 && 写正常:一边正常读、一边正常写,数据畅通。这是最理想的状态。
- 读正常 && 写端关闭:写端全关后,正如规则三,读端会读到 0(EOF)。
- 写正常 && 读端关闭:正如规则四,写端会被 SIGPIPE 干掉(或返回 EPIPE)。
- 读端关闭 && 写端关闭:两边都关,管道对象被内核回收,一切归零。
前面代码已经覆盖了前三种:pipe_rw 覆盖"读正常"且最终"写端关闭读到 EOF";sigpipe_demo 覆盖"读关闭写被 SIGPIPE 杀"。整理成一张对照表,钉在你脑门上:
| 状态 | read 的表现 | write 的表现 |
|---|---|---|
| 有数据 | 返回数据长度 | 正常写入 |
| 无数据 && 阻塞模式 | read 阻塞等待 | —— |
| 无数据 && 非阻塞 | 返回 -1/@ EAGAIN | —— |
| 管道满 && 阻塞模式 | —— | write 阻塞等待 |
| 管道满 && 非阻塞 | —— | 返回 -1/@ EAGAIN |
| 所有写端关闭 | read 返回 0(EOF) | —— |
| 所有读端关闭 | —— | 触发 SIGPIPE(默认杀进程)/ @ EPIPE |
(表中"@ EAGAIN""@ EPIPE"表示 errno 的取值。)
这张表建议你背下来——它不仅是写好管道代码的护身符,也是各类面试最爱的送分题。
关于 PIPE_BUF 与原子性,这里把课件里的一句话补足说明白:当单次要写入的数据量不超过 PIPE_BUF 时,Linux 保证这次写入是原子的——要么这次写入的所有字节一次进队,要么一个都不进,过程中不会被其他进程的写入夹杂插入。 一旦超过 PIPE_BUF,就不再保证"一整块"了,可能被拆成多次、与别的写入交错。所以在 Linux 上,只要单次 write 的数据 ≤ 4096 字节,可以放心认为"这次写不会和别人的写混着来"。这对那些需要"整条消息不被劈开"的协议极其重要。
课堂思考
写一个程序:子进程不停往管道里写超过 PIPE_BUF 的大量数据但一直不关闭写端,父进程一直不读(或很晚才读)。会发生什么?如果父进程干脆退出、子进程还在写,又发生什么?
答案详解:前者,当写端写的速度远大于读端消费的速度时,管道那 64KB(还是可调的)有界缓冲很快被填满,之后 write 陷入阻塞(阻塞模式下),子进程"睡"在内核里等读者腾地方——于是出现"写者等读者"的停滞。这本身不是死锁,只要父进程肯读,就会继续推进,但它完美演示了规则二"写满即阻塞"。后者,父进程退出时它持有的读端 fd 随之全部关闭,管道"所有读端都关了",此时子进程再 write 就会被内核发 SIGPIPE(默认终止),子进程随之被杀死。父进程最好用 waitpid 回收子进程,避免它成为僵尸进程。这个实验把规则二、规则四连同信号机制一次全跑了一遍。
进程池:把管道用在工程调度上
把前面的单条管道放大,就得到一个很实用的工程形态——进程池(Process Pool):主进程(master)管着一大群子进程(worker/worker 进程),用管道把任务一个个派发给它们。这很像饭店的"指挥台":老板(master)把订单(任务)写进对讲机(管道),让各个厨师(worker)接手去做。
课件里用 C++ 的类封装实现了一个完整进程池。为了让你能直接编译运行、又避开 C++ 面向对象那层包装,我把它改写成一个纯 C 的极简版,但核心思路一模一样。我们的设计是:
- 主进程为每个子进程创建一条专属管道,把这条管道的写端攥在主进程手里,读端交给对应的子进程。
- 子进程把自己的读端
dup2到标准输入fd 0上,之后"从键盘读"就变成了"从管道读"——这正是 shella | b的底层原理。 - 主进程轮流往每个子进程的管道写端写一个
int任务号;子进程阻塞读到一个int,就执行对应的"模拟任务"。
// process_pool.c —— 主进程派任务给一群子进程的进程池(C 版演示)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
// 三种"模拟任务"的名字,用任务号 0/1/2 索引
static const char *task_name[3] = {
"访问数据库",
"URL 解析",
"加密计算"
};
// 子进程的主循环:不断从标准输入(已重定向为管道读端)取任务号执行
static void worker(void)
{
while (1) {
int cmd = 0; // 任务号
ssize_t n = read(0, &cmd, sizeof(cmd)); // 从管道读 4 字节
if (n == (ssize_t)sizeof(cmd)) { // 读到完整的任务号
printf("子进程 %d 正在执行<%s>\n",
(int)getpid(), task_name[cmd % 3]); // 打印执行内容
fflush(stdout);
} else if (n == 0) { // 所有写端关闭 -> EOF
printf("子进程 %d 退出\n", (int)getpid());
break;
} else { // 出错
break;
}
}
exit(0);
}
int main(int argc, char *argv[])
{
if (argc != 2) { // 需要用户传子进程数
printf("用法: %s 子进程个数\n", argv[0]);
return 1;
}
int n = atoi(argv[1]); // 子进程个数
int *wfds = calloc((size_t)n, sizeof(int)); // 保存每个管道的写端
if (!wfds) return 1;
pid_t *pids = calloc((size_t)n, sizeof(pid_t)); // 保存每个子进程 pid
if (!pids) return 1;
for (int i = 0; i < n; i++) { // 逐个创建子进程
int pipefd[2];
if (pipe(pipefd) == -1) { perror("pipe"); return 1; }
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) { // 子进程
close(pipefd[1]); // 子进程用读端,关写端
dup2(pipefd[0], 0); // 读端贴到标准输入上
close(pipefd[0]); // 粘贴后原编号可关
worker(); // 进入工作循环(不返回)
}
// 父进程:保留写端,关读端
close(pipefd[0]);
wfds[i] = pipefd[1];
pids[i] = pid;
}
// 主进程派发 10 个任务,轮询分发给各个子进程
for (int t = 0; t < 10; t++) {
int target = t % n; // 轮到第几个
int task = t % 3; // 任务号
printf("主进程派发任务<%s>给子进程 %d\n",
task_name[task], (int)pids[target]);
write(wfds[target], &task, sizeof(task)); // 写 4 字节过去
sleep(1); // 让它先干完再派下一个
}
// 关闭所有写端,让子进程的 read 读到 EOF,从而各自退出
for (int i = 0; i < n; i++) close(wfds[i]);
for (int i = 0; i < n; i++) waitpid(pids[i], NULL, 0); // 回收子进程
free(wfds);
free(pids);
printf("进程池已全部回收\n");
return 0;
}gcc -o process_pool process_pool.c
./process_pool 3 # 创建 3 个子进程并派发任务运行它会看到主进程和 3 个子进程各就各位、任务轮转发出去。这个版本虽然简陋,但已经完整包含了进程池的三个关键动作:
- 先建管道、再建进程:保证
fork后子进程手里恰好有"自己那条管道"的一端。 - 各自关端、
dup2重定向:子进程把管道读端"伪装"成标准输入,之后业务代码就只跟stdio打交道,真正做到"业务与通信解耦"。 - 写完关端、
waitpid回收:任务派发完,主进程统一关闭写端 → 子进程read返回 0 → 子进程正常退出 → 主进程waitpid收尸,一个不落。
特别提醒一个容易被忽略的细节:如果子进程执行完就 exit,但它的管道读端 fd 还没关干净,主进程那边的写会一直成功,不会触发 EOF 判断,也没有 SIGPIPE——这正是前面"所有写/读端都要关尽"那条规则在工程上的体现。 所以进程池收尾时,主进程一定要主动把写端全部 close。
课堂思考
进程池里每个子进程的管道读端被 dup2 到标准输入(fd 0)后,为什么子进程里 read(0, &cmd, sizeof(cmd)) 就等价于"从它的那把管道里读"?这条子进程还要不要手动 close 管道原编号?
答案详解:dup2(fd, 0) 的意思是"把 fd 0 这个描述符重新指向 fd 所指的那个内核对象",于是 fd 0 和原管道读端现在指向同一个管道对象。因此之后用 fd 0(标准输入)的 read 就等于从管道读,调用方甚至不需要知道管道这回事——这就是"一切皆文件 + 重定向"的威力。至于原编号(例如 fd 3),在 dup2 之后它的值其实没变,还指向同一个管道对象;不关它会造成"同一管道有两个打开的读端描述符",虽然不影响这条子进程本次的逻辑,但会留下隐患(参考规则要对"所有 fd"计数)。所以我们在 dup2 后立刻 close(pipefd[0]),保持"一个对象一份描述符"的整洁。教训:凡是 dup2 重定向过的旧描述符,用完后一定要关,否则 fd 泄漏会成为一系列神鬼 bug 的温床。
命名管道 FIFO:打开无关进程之间的大门
匿名管道好用,但它有个硬伤:只能用于有亲缘关系(共同祖先)的进程。 如果我想让两个八竿子打不着的进程(比如一个服务器进程和一个毫不相干的客户端进程)通信,匿名管道就行不通了——它们根本没有能共享的 fd。
解决办法,就是用命名管道(FIFO,First In First Out,先进先出)。它本质上仍然是一根管道,管芯里仍然是那块"有界的环形缓冲",读写语义、阻塞规则、流式服务…… 和匿名管道一模一样。唯一的区别,是它给了这根管道一个真实的名字(一条路径),任何进程只要能找到这个名字、并有权限打开它,就能加入通信。 于是它"长"成了一个特殊类型的文件——FIFO 文件。你可以想象成:匿名管道是"公用电话亭里只有一台对讲机,谁持有谁就能用";FIFO 则是"一根标了名字的管子立在文件系统的某个角落,谁来都能接"。
创建命名管道:命令行与 mkfifo
命名管道有两种创建方式。
方式一:命令行创建。 直接在 shell 里敲:
mkfifo filename1 # 创建名为 filename1 的命名管道创建成功后,你用 ls -l 看它,会看到一个以 p 打头的类型标记:
ls -l filename1
# prw-r--r-- 1 user user 0 ... filename1 <- 第一个字符是 p,表示 FIFO注意它的"文件大小"显示 0——因为 FIFO 只是文件系统里的一个入口记录,真正的数据缓冲在内核里,不在磁盘上。
方式二:程序里创建。 用库函数 mkfifo:
#include <sys/types.h>
#include <sys/stat.h>
// 原型:int mkfifo(const char *pathname, mode_t mode);
// 功能:以 mode 权限创建名为 pathname 的命名管道
// 参数:pathname 是路径;mode 是权限位(如 0644)
// 返回值:成功 0,失败 -1(并置 errno)一个最简单的小程序,创建一条名为 p2 的命名管道:
// mkfifo_demo.c —— 用 mkfifo 创建命名管道
#include <sys/types.h>
#include <sys/stat.h>
#include <stdio.h>
int main(void)
{
// 创建命名管道 p2,权限 0644(所有者可读写,组可读,其他可读)
if (mkfifo("p2", 0644) == -1) {
perror("mkfifo"); // 已存在或权限等会在这打出来
return 1;
}
printf("命名管道 p2 创建成功\n");
return 0;
}gcc -o mkfifo_demo mkfifo_demo.c && ./mkfifo_demo
ls -l p2 # 会看到以 p 开头这里要提醒两点:其一,mkfifo 创建的是文件系统里的入口文件,是个"名分",数据本体仍在内核;其二,它受进程的 umask 影响——如果程序里要精确控制权限,通常先 umask(0) 再 mkfifo(这个我们在下面 server 例子里会看到)。
匿名管道与命名管道的区别
把两兄弟放一起对比,一句话就能记住核心差异:
- 匿名管道
pipe:创建即打开。pipe()一调用,读写两端fd[0]/fd[1]同时就绪,无需额外 open。 - 命名管道
mkfifo:创建是创建,打开是打开。mkfifo只负责"立名分";真正拿去用,还得像普通文件一样用open()打开。虽然创建和打开是两步,但打开之后,它的读写、阻塞、EOF、SIGPIPE 这些语义和匿名管道完全一致。
所以课件上有句很精辟的话:FIFO(命名管道)与 pipe(匿名管道)之间唯一的区别,就体现在"创建方式"和"打开方式"上;一旦这两步做完,它们之后的操作语义相同。 想验证这点,把命名管道当普通文件一样 open 后,所有 read/write 规则与匿名管道分毫不差。
命名管道的打开规则
既然命名管道要"先创建、后 open",那 open 这一下就有讲究了——它是命名管道通信的"接头暗号"。规则还是绕不开那块内核缓冲和两端配对:
情形一:为读而打开(O_RDONLY)。
O_NONBLOCK关闭(默认):open阻塞,直到有另一个进程为写而打开了这条 FIFO,open才返回成功。等于是"我要开始读了,先确认有没有人来写"。O_NONBLOCK开启:open立刻返回成功,不等待写者。你马上可以读,读不出数据时再按"读空"的非阻塞规则处理。
情形二:为写而打开(O_WRONLY)。
O_NONBLOCK关闭(默认):open阻塞,直到有另一个进程为读而打开了这条 FIFO,open才返回。O_NONBLOCK开启:立刻返回失败,errno为ENXIO(设备/地址不存在)。因为在非阻塞模式下,如果没有读者在等着,数据写进去也没人读,干脆直接报错。
把这两情形对照着记:默认的阻塞式 open,就是"读等写、写等读"的双向配对。 它保证了一件事——一旦你用默认方式成功 open 了一条 FIFO,你就必然配对上了"对面的那一个进程"。这个"配对即握手"的特性,是命名管道做 client/server 的基础。
也是在这里,"先创建、后打开、打开要配对"的节奏,让命名管道变得比匿名管道更有"话事感":谁负责建(通常是 server),谁负责等(open 阻塞),谁先来都对得上。
课堂思考
假如只有一个进程用默认方式 open("myfifo", O_RDONLY) 打开了命名管道,紧接着想写却又发现没有其他进程来配对,会发生什么?
答案详解:调用 open(..., O_RDONLY) 的那个进程会一直阻塞在 open 调用里(默认非阻塞关闭),因为函数等的是"有进程为写打开该 FIFO"。它并不会只读不写地正常返回。也就是说,一个进程想在 open 上占住读端、等着别人来写,它自己先得"立住"让写者来配对——而写者也要 open(..., O_WRONLY),写者的 open 会阻塞直到有读者,于是两者必须分属两个不同的进程,各自 open 一侧,才能互相配对成功、双双返回。这正是命名管道 "open 阻塞配对" 的真正含义:它要求通信的两端由两个独立进程来扮演。 你如果试着在脚本或程序里串行地一路 open,就会发现卡死——这是命名管道新手最常见的第一个坑。
实战一:命名管道实现文件拷贝
把知识用起来,我们做两个程序:一个负责把磁盘文件 abc 读出来、写进命名管道 tp;另一个负责从管道 tp 读出数据、写到目标文件 abc.bak。两段各自独立编译、独立运行,靠 tp 这根"命名管道"把数据从一头搬到另一头。
程序 A:读文件,写管道。
// fifo_writer.c —— 把磁盘文件 abc 的数据写进命名管道 tp
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#define ERR_EXIT(m) do { perror(m); exit(EXIT_FAILURE); } while (0)
int main(void)
{
mkfifo("tp", 0644); // 创建命名管道(已存在则忽略失败)
int infd = open("abc", O_RDONLY); // 打开源文件 abc 准备读
if (infd == -1) ERR_EXIT("open abc");
int outfd = open("tp", O_WRONLY); // 打开命名管道(写端)
if (outfd == -1) ERR_EXIT("open tp");
// 注意:这一行会阻塞——直到有另一个进程用读端打开 tp 才返回
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0) { // 读文件一段
write(outfd, buf, n); // 写入管道这一段
}
close(infd);
close(outfd); // 关闭写端 -> 触发读端 EOF
return 0;
}程序 B:读管道,写目标文件。
// fifo_reader.c —— 从命名管道 tp 读数据,写到目标文件 abc.bak
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#define ERR_EXIT(m) do { perror(m); exit(EXIT_FAILURE); } while (0)
int main(void)
{
// 打开(不存在则创建、并清空)目标文件 abc.bak
int outfd = open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (outfd == -1) ERR_EXIT("open abc.bak");
int infd = open("tp", O_RDONLY); // 打开命名管道(读端),同样会阻塞配对
if (infd == -1) ERR_EXIT("open tp");
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0) { // 从管道读一段
write(outfd, buf, n); // 写入目标文件
}
close(infd);
close(outfd);
unlink("tp"); // 拷贝完成,删掉命名管道
return 0;
}echo "hello world" > abc # 先造一个源文件
gcc -o fifo_writer fifo_writer.c
gcc -o fifo_reader fifo_reader.c
./fifo_writer & # 后台启动写端
./fifo_reader # 前台启动读端
cat abc.bak # 验证:内容应等于 abc这里有个先后顺序值得体会:谁先跑,谁卡在 open 阻塞上。 若你先只跑 fifo_writer(没有读端),它的 open("tp", O_WRONLY) 会一直阻塞,程序"停住不走",直到你在另一个终端再跑起 fifo_reader,两者在 open 处"对上暗号"才一起继续。这就是命名管道"打开即配对的握手协议"。
课堂思考
如果只启动 fifo_writer 而忘了启动 fifo_reader,会发生什么?为什么两个程序都启动后,fifo_writer 的 open 也能顺利通过?
答案详解:只启动 fifo_writer 时,它阻塞在 open("tp", O_WRONLY) 上——因为没有读端打开这条 FIFO,写端无从配对。同理,若先启动 fifo_reader,它会阻塞在 open("tp", O_RDONLY) 上等写端。只有当两个"端"都有人占了,配对成功,双方的 open 才一起返回成功,数据才哗哗地流转。这背后正是"命名管道为读打开等写者、为写打开等读者"的配对规则。这也是写命名管道程序最常见的 bug:一端已经启动,另一端忘了启动,程序就"挂"在 open 上看似卡死——其实它是在等人。
实战二:命名管道实现 server 与 client 通信
文件拷贝是单向的,我们再来个有来有往的:client 从键盘输入一句话,通过命名管道送给 server,server 收到后打印出来。这正是课件的经典实例,也是最朴素的 client/server 骨架。
服务端 serverPipe.c:
// serverPipe.c —— 服务端:把命名管道当"收信口"
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#define ERR_EXIT(m) do { perror(m); exit(EXIT_FAILURE); } while (0)
int main(void)
{
umask(0); // 清掉进程默认的权限屏蔽
if (mkfifo("mypipe", 0644) < 0) // 创建命名管道 mypipe
ERR_EXIT("mkfifo");
int rfd = open("mypipe", O_RDONLY); // 以只读方式打开(阻塞,等写者)
if (rfd < 0) ERR_EXIT("open mypipe");
char buf[1024];
while (1) {
memset(buf, 0, sizeof(buf)); // 每轮先清空缓冲
printf("Please wait...\n");
fflush(stdout);
ssize_t s = read(rfd, buf, sizeof(buf)-1); // 从管道读消息
if (s > 0) {
buf[s] = 0; // 补结尾符
printf("client say# %s\n", buf); // 打印客户端来的话
} else if (s == 0) { // 写端全关 -> 客户端退出
printf("client quit, exit now!\n");
exit(EXIT_SUCCESS);
} else {
ERR_EXIT("read");
}
}
close(rfd);
return 0;
}客户端 clientPipe.c:
// clientPipe.c —— 客户端:把键盘输入写进命名管道
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#define ERR_EXIT(m) do { perror(m); exit(EXIT_FAILURE); } while (0)
int main(void)
{
int wfd = open("mypipe", O_WRONLY); // 以只写方式打开(阻塞,等读者)
if (wfd < 0) ERR_EXIT("open mypipe");
char buf[1024];
while (1) {
memset(buf, 0, sizeof(buf));
printf("Please Enter# "); // 提示输入
fflush(stdout); // 立刻把提示打出来
ssize_t s = read(0, buf, sizeof(buf)-1); // 从键盘读一行
if (s > 0) {
buf[s-1] = 0; // 去掉末尾换行符
write(wfd, buf, strlen(buf)); // 写入命名管道
} else if (s <= 0) {
ERR_EXIT("read stdin");
}
}
close(wfd);
return 0;
}配套的编译脚本(Makefile):
.PHONY: all clean
all: serverPipe clientPipe
serverPipe: serverPipe.c
gcc -o $@ $^
clientPipe: clientPipe.c
gcc -o $@ $^
clean:
rm -f serverPipe clientPipemake # 编译出 serverPipe 和 clientPipe
./serverPipe & # 后台启动服务端(先建管道、开着读端等)
./clientPipe # 前台启动客户端,然后输入文字回车你在客户端敲 hello 回车,服务端窗口就会打印 client say# hello;当客户端被中断退出,它持有的写端关闭,服务端 read 返回 0,打印"client quit, exit now!"并退出。整个流程走了两件大事:mkfifo 立名分 → 两端 open 配对 → 管道里跑字节流 → 关端触发 EOF 优雅退场。
课堂思考
这个 server/client 用的是"一条管道",所以它本质上是什么方向的通信?如果要让 server 也能回复 client,该怎么改?
答案详解:一条管道一定是单向的(半双工),所以本例实际只是"client 单向发往 server",data 只能 client→server 流动,server 做不到"原路返回"。要让 server 也能回话,需要再建第二条管道(例如 replypipe):server 负责创建两个 FIFO,client 向整条 mypipe 发送、从 replypipe 接收,server 从 mypipe 接收、向 replypipe 发送。两条单行道拼成全双工,正是我们前面"管道只能单向,需要两条"那节的工程落地。如果你贪图省事用一条管道硬做双向,一定会撞上前面分析过的 buff 争抢和方向混乱的死锁。
System V 共享内存:最快的 IPC
管道再快,也是"数据在内核缓冲里搬一遍、再拷回用户空间",中间过了一次内核做搬运工。每次都进内核、出内核,是有开销的。那有没有"更快"的办法?
有,这就是共享内存(shared memory)。它的威力一句话概括:共享内存区是最快的 IPC 形式。 一旦把一块内存同时映射进多个进程的地址空间,之后这堆进程传递数据就不再经过内核了——它们直接在各自地址空间里"看见"同一块物理内存,读写它就是读写共享数据,省掉了那趟"进内核搬一趟、出内核拿一次"的往返。
想一想:管道要数据先由写进程 write 进内核,再由读进程 read 出内核;共享内存则是"同一片物理内存,大家都直接映射,谁写谁可见",根本不走系统调用去拷数据。所以理论上它最快。
共享内存的数据结构
内核为每一块共享内存维护一个描述结构 struct shmid_ds,记录它的权限、大小、连接历史等:
struct shmid_ds {
struct ipc_perm shm_perm; /* 操作权限(owner/perms 等) */
int shm_segsz; /* 段大小(字节数) */
__kernel_time_t shm_atime; /* 最近一次挂接(attach)的时间 */
__kernel_time_t shm_dtime; /* 最近一次脱离(detach)的时间 */
__kernel_time_t shm_ctime; /* 最近一次改变的时间 */
__kernel_ipc_pid_t shm_cpid; /* 创建者的 pid */
__kernel_ipc_pid_t shm_lpid; /* 最近一次操作的 pid */
unsigned short shm_nattch; /* 当前挂接(attach)的次数 */
unsigned short shm_unused; /* 兼容字段(保留未用) */
void *shm_unused2; /* 保留字段(DIPC 曾使用) */
void *shm_unused3; /* 保留字段(暂未使用) */
};你不用背字段,只要知道:每块共享内存在内核里有一个"档案",记录了它是谁建的、多大、谁在用(attach 计数)、权限如何。 后面 shmctl 的很多操作(比如删除)都要通过这个结构来读取或修改元信息。
四个核心函数:shmget / shmat / shmdt / shmctl
System V 共享内存由四个函数组成一套"生命周期":创建/获取、挂接、脱离、删除。
第一步:shmget —— 创建/获取共享内存。
#include <sys/ipc.h>
#include <sys/shm.h>
// 原型:int shmget(key_t key, size_t size, int shmflg);
// 功能:创建或获取一块共享内存
// 参数:
// key : 这块共享内存的"名字"(一个整数 key)
// size : 共享内存大小(字节)
// shmflg : 权限与行为标志。主要由文件权限位+以下两个常量组合:
// IPC_CREAT —— 不存在就创建并返回;已存在就获取并返回。
// IPC_EXCL —— 配合 IPC_CREAT:不存在才创建并返回;已存在则出错(-1)。
// 返回值:成功返回一个非负整数(共享内存标识码 shmid);失败返回 -1。shmget 里那个 key 是块共享内存的"身份证"。两个进程想访问同一块,就得用相同的 key。生成 key 的手写方式是用函数 ftok(把"路径名+工程号"编码成一个整数)——key_t key = ftok(".", 0x6666);,只要两端用同一个路径和同一个工程号,就能得到相同的 key。
注意 shmflg 的两种组合含义差别很大:IPC_CREAT 是"没有就建,有了就用";IPC_CREAT|IPC_EXCL 是"必须新建,已存在就报错"。后者常用于server 侧创建(第一次建),前者常用于client 侧获取(去找已经建好的)。
第二步:shmat —— 挂接(attach)。
#include <sys/shm.h>
// 原型:void *shmat(int shmid, const void *shmaddr, int shmflg);
// 功能:把指定的共享内存段"映射"进当前进程的地址空间
// 参数:
// shmid : 由 shmget 得到的共享内存标识码
// shmaddr : 指定映射到哪个地址。通常传 NULL,让内核自动挑地址
// shmflg : 可选 SHM_RDONLY(只读挂接) 或 0
// 返回值:成功返回一个指向共享内存首字节的指针;失败返回 (void*)-1。关于 shmaddr 的正规规则:shmaddr == NULL,内核自动选一个合适的地址,最省心,几乎都用它;若 shmaddr 非空且不带 SHM_RND,就按指定地址挂接;若带了 SHM_RND,地址会向下对齐到 SHMLBA 的整数倍(shmaddr - (shmaddr % SHMLBA))。大多数情况下你只要记住"传 NULL 最省事"即可。
第三步:shmdt —— 脱离(detach)。
#include <sys/shm.h>
// 原型:int shmdt(const void *shmaddr);
// 功能:把当前进程和共享内存"解除挂接"
// 参数:shmaddr 是之前 shmat 返回的指针
// 返回值:成功 0;失败 -1注意:shmdt 只是"脱离",不是"删除"。 进程退场可以脱离,但块共享内存作为内核资源还赖在内核里,不删不散。
第四步:shmctl —— 控制(含删除)。
#include <sys/shm.h>
#include <sys/ipc.h>
// 原型:int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// 功能:对共享内存做各种控制操作
// 参数:
// shmid : 共享内存标识码
// cmd : 具体动作,常见 IPC_RMID(删除)、IPC_STAT(取信息到 buf) 等
// buf : 配合某些 cmd 使用,传 NULL 则不带返回值
// 返回值:成功 0;失败 -1smlctl(shmid, IPC_RMID, NULL) 是删除共享内存的典型写法。这里有个极易踩的坑,我们放到示例后的"坑"里专门讲。
实例:用共享内存实现 client/server 通信
我们按课件把这块拆成四个文件,方便你一行行看职责。先是一个公共头文件和它的实现:
// comm.h —— 共享内存通用接口的声明
#ifndef _COMM_H_
#define _COMM_H_
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "." // 用于 ftok 的路径(这里用当前目录)
#define PROJ_ID 0x6666 // 用于 ftok 的工程号
int createShm(int size); // 创建一块"全新"共享内存(已存在会失败)
int getShm(int size); // 获取已存在的共享内存(不存在则创建)
int destroyShm(int shmid); // 删除共享内存
#endif// comm.c —— ftok + shmget 的封装实现
#include "comm.h"
#include <stdio.h>
// 通用入口:根据 flags 决定"创建"或"获取"
static int commShm(int size, int flags)
{
// 用"路径+工程号"算出统一的 key,两端一致才共享同一块
key_t key = ftok(PATHNAME, PROJ_ID);
if (key < 0) { perror("ftok"); return -1; }
int shmid = shmget(key, size, flags); // 创建或获取共享内存
if (shmid < 0) { perror("shmget"); return -2; }
return shmid;
}
// 创建:必须"新建",用 IPC_CREAT|IPC_EXCL
int createShm(int size)
{
return commShm(size, IPC_CREAT | IPC_EXCL | 0666);
}
// 获取:允许复用已有块,用 IPC_CREAT
int getShm(int size)
{
return commShm(size, IPC_CREAT);
}
// 删除
int destroyShm(int shmid)
{
if (shmctl(shmid, IPC_RMID, NULL) < 0) { perror("shmctl"); return -1; }
return 0;
}comm.c 里藏着三个值得记的点:一是 ftok 让"不同文件能算出同一个 key"从而指向同一块共享内存;二是 createShm 用 IPC_CREAT|IPC_EXCL(坚决要新建,证明它是"通信发起者");三是 getShm 用 IPC_CREAT(Open 语义,可复用)。
服务端 server.c: 创建共享内存并读它。
// server.c —— 服务端:创建共享内存,循环读取
#include "comm.h"
#include <stdio.h>
#include <unistd.h>
int main(void)
{
int shmid = createShm(4096); // 创建 4096 字节共享内存
char *addr = (char*)shmat(shmid, NULL, 0); // 挂接到本进程地址空间
int i = 0;
while (i++ < 26) { // 连读 26 次,看共享内存里的内容变化
printf("client# %s\n", addr);
sleep(1);
}
shmdt(addr); // 脱离
destroyShm(shmid); // 删除共享内存
return 0;
}客户端 client.c: 获取共享内存并写入。
// client.c —— 客户端:获取共享内存,向里面写内容
#include "comm.h"
#include <stdio.h>
#include <unistd.h>
int main(void)
{
int shmid = getShm(4096); // 获取服务端创建的共享内存
char *addr = (char*)shmat(shmid, NULL, 0); // 挂接
int i = 0;
while (i < 26) {
addr[i] = 'A' + i; // 写入一个字母
i++;
addr[i] = 0; // 用 0 结尾使其成为字符串
sleep(1);
}
shmdt(addr);
sleep(2);
return 0;
}gcc -o server server.c comm.c # 分别编译
gcc -o client client.c comm.c
./server & # 先起服务端(创建共享内存)
./client # 再起客户端(往共享内存写)你会看到 server 打印的内容逐个字母变多:client# A、client# AB、client# ABC……因为 client 每秒钟往共享内存追加一个字母,server 每秒钟读一次,看到的就是最新的一整串。这个例子很直观地告诉你:读了、写了,但中间没有任何"拷贝"——两边直接碰同一块内存的同一个数据流。
共享内存的"坑":它自己不讲规矩
共享内存快,但它有个致命的短板,课件里标得很清楚:共享内存没有进行同步与互斥,缺乏访问控制,会带来并发问题。
什么意思?管道靠内核的"同步与互斥"把读写协调得井井有条——写满等、读空等、一次写入原子保证。而共享内存把这块内存直接塞给你,内核撒手不管:谁先写、谁后读、写了一半读端会不会读到半截数据,统统没有保护。 多个进程同时往里写,可能互相踩踏;一个进程写到一半,另一个进程就"看见"了不完整的数据——这在并发场景下叫竞态条件(race condition),是并发 bug 的温床。
所以课件里强调"共享内存没有同步与互斥,缺乏访问控制",并特意给了一个"借助管道实现访问控制版共享内存"的思路来演示什么叫"给共享内存套上规矩":用一个 FIFO 当"信号灯",client 写完共享内存就往 FIFO 写一个字节"唤醒"server,server 读到那个字节才走进"临界区"去读共享内存。于是访问共享内存的先后顺序就被 FIFO 串行化了。这本质上就是用"管道/信号量"补上"互斥与同步"这门课,让"裸奔"的共享内存变得规矩。(课件也提醒,这仍是个带着 bug 的演示,仅用于让你理解"如何让进程执行产生一定顺序性",真正的生产做法要交给下一节讲的信号量或锁。)
还有台账问题:System V IPC 资源生命周期随内核。ctrl+c 中断 server 而不删除共享内存,那块共享内存就赖在内核里不走了;下次再跑 ./server,因为它是 IPC_CREAT|IPC_EXCL(必须新建),会直接报 shmget: File exists 失败。这时就该用命令行了:
ipcs -m # 查看所有共享内存段,右侧会列出 key、shmid、bytes、nattch 等
ipcrm -m 688145 # 删除 shmid 为 688145 的那块共享内存(演示用,按实际值填)ipcs / ipcrm 是我们管理 System V IPC 资源的两把命令行小刀。特别地,ipcs -m 输出的 nattch(当前挂接次数)和 bytes(字节数)这两列值得你闲下来研究一下:nattch 变 0 说明没人 attach 了,通常是删除它的安全时机。真正的工程里,删除 IPC 资源是进程自己"该做的事"(比如 server 优雅退出时主动 IPC_RMID),而不是依赖管理员手动 ipcrm。 慢慢养成"用完即删、优雅退出必清资源"的习惯。
课堂思考
共享内存比管道快在哪?既然它最快,是不是所有场景都该优先用共享内存?
答案详解:共享内存比管道快的本质,是省掉了一次"用户态↔内核态"的整块拷贝。管道是"写进程 write 进内核缓冲,读进程 read 出内核缓冲",两趟都过内核;共享内存是"同一片物理内存直接映射到多个进程地址空间",读写各自就地发生,基本不走内核这趟搬运。所以对"大批量、高频率"的数据交换,共享内存优势极大。但不能因此无脑选它,因为:序号一,它缺同步互斥,你必须额外用信号量/锁补规矩,增加复杂度;二,它生命周期随内核,忘记删除会污染系统;三,那些"频率低、单条小、要跨机"的场景,管道/消息队列反而更省心。选型的准则是"吞吐量需求 × 并发安全成本"权衡,不是一味追求快。
消息队列:给数据贴上类型标签
(本节为选学了解,知道个大概即可。)
消息队列(message queue)提供了从一个进程向另一个进程发送一整块数据的方法。和管道的"字节流"不同,它的特点在于:每个数据块都自带一个"类型值",接收方可以按类型精准挑活干。
- 生产者把一条消息
msgsnd进队列,消息分两部分:一个长整型的type标签 + 一串数据。 - 消费者可以用
msgrcv指定"我只要某个类型(或任意类型)的消息"。
于是,一个队列里可以混着多种任务——比如 type=1 是"日志"、type=2 是"业务"、type=3 是"指令",不同的接收进程各取所需。这在"一对多、多分类"的调度场景里是管道的字节流做不到的简洁。
它的特性你要记住一句话(和共享内存同一句话的不同版本):System V IPC 资源必须主动删除,否则不会自动清除,除非重启系统;所以 System V IPC 资源的生命周期随内核。 又是"用完即删"那套规矩。
(如果你的课程进度还需要深入消息队列的 msgget/msgsnd/msgrcv/msgctl,那套 API 与共享内存的 shmget/shmat/shmdt/shmctl 结构几乎一一对应——学了共享内存,消息队列上手很快。)
信号量:同步与互斥的基石
(本节为选学了解,但概念是并发的地基,值得认真过一遍。)
先铺四个基础概念,它们也是并发编程的通识:
- 共享资源:多个执行流(进程/线程)都能看到的同一份公共资源。
- 临界资源:被保护起来的共享资源。因为不保护会被抢坏,所以要圈起来。
- 互斥(mutual exclusion):任何时刻,只允许一个执行流访问临界资源。就像厕所,一次只让一个人进,别人得排队。
- 同步(synchronization):多个执行流访问临界资源时,具有一定的先后顺序性。比如必须先生产、再消费,顺序不能乱。
再深一层:"对共享资源进行保护",本质是"对访问共享资源的代码进行保护"。你的代码 = 访问临界资源的代码(临界区)+ 不访问临界资源的代码(非临界区)。我们保护的从来不是资源本身那条虚线,而是"碰它的那段代码"。
那"信号量"是干嘛的?用课件的话:
- 特性上:IPC 资源必须删除,否则不会自动清除,生命周期随内核(又见这句话)。
- 理解上:信号量是一个计数器,像加油站里"还剩几个空位"的电子牌。
- 作用上:保护临界区。
- 本质上:信号量是对资源的预订机制——用之前先"预订"一个名额,用完再"归还"。
- 操作上:
- 申请资源:计数器减一,即 P 操作(也常称
wait/down)。 - 释放资源:计数器加一,即 V 操作(也常称
signal/up)。
- 申请资源:计数器减一,即 P 操作(也常称
打个比方,想象电影院里一块"空位计数牌":数字表示还剩多少个座位。第一个人买票(P,数值 -1)= 预订一个名额;当他离场(V,数值 +1)= 归还名额。当计数为 0 表示满座,再来人就得等(阻塞),直到有人释放。这里的"座位数"本质就决定"同一时刻允许几个执行流进入临界区"——计数初始为 1 就是量词互斥(同一资源同一时刻只能一个进程用,即试图进入的进程先做 P:计数 0 则阻塞,做 V 释放),为 n 就是允许 n 个共享——这一点正是"信号量 = 计数器"的精髓。
如果你将来用 semctl 等接口,会碰到 SETVAL 这类控制命令(SETVAL 表示把集合里第 semnum 个信号量的值 semval 显式设置为 arg.val,并更新相关的修改时间字段)——本质上就是在给这个"计数牌"定初始值。
互斥量、条件变量、读写锁
在并发编程里,POSIX 又把信号量细化成了更顺手的兄弟:**互斥量(mutex)**解决"同一时刻只有一人"(互斥);**条件变量(condition variable)**解决"某个条件具备才放行"(同步);**读写锁(rwlock)**区分"读可共享、写必须独享"。它们和信号量理念相通,是线程/进程并发的标准奏鸣曲——只是分工更细。这些属于并发进阶,本篇点到为止。
课堂思考
如果两把钥匙(初始计数值为 1))想控制"同一时刻最多两个进程能进入临界区",信号量的初始值该设成几?两个进程都进去后,第三个进程会怎样?
答案详解:初始值应设成 2。前两个进程各做一次 P 操作,计数器 2→1→0,都顺利进入临界区;第三个进程再做 P 操作时计数器已到 0,无法再减,于是它阻塞在 P 操作上,等前两人中任一人做 V(计数器 +1)释放名额才能进入。这正体现"信号量是预订机制 + 计数器"两个本质——名额由初始值决定,满额即等。如果是互斥场景(同一资源同一时刻只能一人用),初始值设 1 即可。
内核是如何组织和管理 IPC 资源的
最后,从"使用者"视角跳到"内核"视角看一眼 System V IPC 资源到底是怎么被管理起来的。参考资料是 Linux 内核 2.6.11 的源码(其他版本实现可能有差别;内核 2.6.18 与 2.6.11 在这块变化也不大),课件只要求你理解下列主线:
- 每个 IPC 资源(共享内存/消息队列/信号量)都对应一个内核对象,对象里有
ipc_perm(权限结构,含 key、uid、权限位等)和各自的元数据(如共享内存的shmid_ds)。 - 内核对每种 IPC 维护一张"表"(id 数组/哈希),用数字标识码(shmid/…)索引。 你拿到的
shmid就是查这张表的"编号",而key是"命名",系统通过key去匹配、查找或创建,再把key映射成一个id。 - 资源的增删查改,都由
*ctl(如shmctl)这套统一命令驱动,借助IPC_CREAT、IPC_EXCL、IPC_RMID、IPC_STAT等命令字完成。 - 整个系统可以通过
ipcs命令"一网打尽"地查看所有 System V IPC 资源;ipcrm命令可以按 id 删除它们。 这也是"生命周期随内核"在运维层面的对应物——你亲眼能看到的.NET 资源台账。
这一整套"命名(key) → 创建/获取(get) → 操作/挂接(其他) → 删除(ctl+RMID,或命令行 ipcrm)"的节奏,和前面共享内存的四个函数完全同构。理解了共享内存,你基本就理解了 System V IPC 全家族的组织方式。
ipcs # 同时列出消息队列、共享内存、信号量三类资源
ipcs -m # 只看共享内存 ipcs -q 只看消息队列 ipcs -s 只看信号量
ipcrm -m 123 # 按 id 删除 123 号共享内存(-q 删消息队列 -s 删信号量)好了,到这里我们把这根"从最小到最大"的进程间通信地图走完了。你可能没想到,一切的起点是那么朴素的一根"管子":pipe() 给你一对 fd,fork() 之后父子各执一端,数据就这么在内核的环形缓冲里安安静静地流动——先进先出、写满则等、读空也等、端之一方全关则收到 EOF 或迎来 SIGPIPE。看懂了这一个方向、两个端、三块缓冲(数据缓冲 + 两个"端存在与否"的状态),你已经吃下了 Linux 进程间通信里最核心的直觉。
之后的一切都是在这根管子的思想上做的权衡:
- 觉得"只能亲缘进程用"不方便,就把名字挂到文件系统上,于是有了命名管道 FIFO——创建与打开分离,
open时"读等写、写等读"的配对即是握手。 - 觉得"过一趟内核"太慢,就干脆把内存直接映射进多个进程,于是有了最快的共享内存——但它把同步互斥的责任全部还给了你。
- 想要"按类型精准投递一整块消息",于是有了消息队列;想要给共享资源立规矩、协调并发节奏,于是有了信号量和那一整套互斥量/条件变量/读写锁。
你会发现,贯穿全课的其实就三根支柱:缓冲(怎么放)、配对/同步(怎么协调)、生命周期(怎么生、怎么死)。 管道管"字节流",FIFO 管"跨进程配对",共享内存管"零拷贝",信号量管"秩序",消息队列管"分类"——Linux 把进程间协作这件事,拆成了一副各司其职的积木。万事求快、求全都不对,选对工具才是正道。
如果你把文中每一个代码块都亲手敲过、编译过、故意踩过 SIGPIPE 和 open 阻塞的坑,再回头看这张图,你会发现自己已经能把"为什么管道只能单向""为什么要 close 掉多余端""为什么共享内存要自己加锁""为什么 IPC 资源要用完了删"一串问题对答如流。下一课,我们就可以把这些 IPC 手段搬进更完整的网络和并发编程里去实战了。但在此之前——先去把你的 shell 里那根小小竖线 | 背后的管道,亲手写一遍吧。
还没有评论 — 第一条由你来留。