一、无名管道:进程间通信的入门课
大家好,我是你们这门课的老朋友。今天咱们聊聊进程间通信里最基础、也最经典的一个机制——无名管道。
说实话,我刚开始做嵌入式开发那会儿,对管道这东西挺不屑的。觉得它功能太简单,限制又多。直到有一次,我在一个资源受限的MCU项目里,需要让两个实时任务交换传感器数据,共享内存太复杂,消息队列又太重。最后我回头用了个管道,几行代码就搞定了。嗯,从那以后我再也不敢小看它了。
1.1 管道的概念与原理
管道(Pipe)是什么?说白了,就是内核里的一块缓冲区。它像一根水管,一头进水,一头出水。一个进程往里面写数据,另一个进程从里面读数据。
你想想看,这其实就是最朴素的「生产者-消费者」模型。写端是生产者,读端是消费者。数据在内核空间流动,用户程序不用操心同步问题——内核帮你搞定了。
管道分为两种:
- 无名管道:只能用于有亲缘关系的进程(父子、兄弟)。没有名字,用完就消失。
- 有名管道(FIFO):有路径名,可以在任意进程间通信。这个我们后面章节会讲。
今天咱们只聊无名管道。
核心要点:无名管道是半双工的。数据只能朝一个方向流动。如果你想双向通信,得开两根管道。
1.2 无名管道的创建:pipe函数
创建无名管道,靠的是 pipe() 系统调用。它的原型长这样:
#include <unistd.h>
int pipe(int pipefd[2]);
这个函数会给你返回两个文件描述符:
pipefd[0]:读端。你可以用read()从里面拿数据。pipefd[1]:写端。你可以用write()往里面塞数据。
成功返回0,失败返回-1并设置errno。
个人习惯:我写代码时,喜欢把 pipefd[0] 和 pipefd[1] 分别定义为 PIPE_READ 和 PIPE_WRITE 宏,这样代码可读性高很多。你也不妨试试。
来看一个最简单的例子:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
int fd[2];
if (pipe(fd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
printf("读端 fd: %d\n", fd[0]);
printf("写端 fd: %d\n", fd[1]);
// 用完记得关闭
close(fd[0]);
close(fd[1]);
return 0;
}
运行后你会看到两个数字,通常是3和4。为什么?因为0、1、2已经被标准输入、标准输出、标准错误占用了。
1.3 管道的读写特性
管道读写有几个关键特性,我当年踩过不少坑,今天一并告诉你:
- 数据是字节流:没有消息边界。你写100字节,读端可能一次读到100字节,也可能分两次读到50+50。别指望它像UDP那样保边界。
- 读空管道:默认情况下,读端会阻塞,直到有数据写入。
- 写满管道:默认情况下,写端会阻塞,直到读端取走数据腾出空间。
- 写端关闭后读:读端会读到文件结束符(EOF),即
read()返回0。 - 读端关闭后写:写端会收到
SIGPIPE信号,进程默认会终止。如果你不想进程挂掉,记得处理这个信号。
我曾经踩过的坑:有一次我忘了关闭写端,结果读端一直阻塞在 read() 上,因为内核认为还有进程可能往管道里写数据。排查了半天才发现是文件描述符泄漏。记住:不用的端一定要关掉!
1.4 管道的阻塞与非阻塞
管道默认是阻塞模式。但你可以通过 fcntl() 把它改成非阻塞:
#include <fcntl.h>
// 获取当前标志
int flags = fcntl(fd[0], F_GETFL, 0);
// 设置非阻塞
fcntl(fd[0], F_SETFL, flags | O_NONBLOCK);
非阻塞模式下:
- 读空管道:
read()立即返回 -1,errno 设为EAGAIN。 - 写满管道:
write()立即返回 -1,errno 设为EAGAIN。
我个人建议,初学者先用阻塞模式。非阻塞模式配合 poll() 或 select() 使用,那是进阶玩法。
1.5 父子进程间的管道通信
这才是管道的经典用法。流程很简单:
- 父进程先创建管道。
- 父进程
fork()出子进程。 - 子进程继承父进程的文件描述符表,所以它也有管道的读写端。
- 决定数据流向:如果父写子读,父进程关闭读端,子进程关闭写端。
来看代码:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
int fd[2];
pid_t pid;
char buf[1024];
if (pipe(fd) == -1) {
perror("pipe");
exit(1);
}
pid = fork();
if (pid == -1) {
perror("fork");
exit(1);
}
if (pid == 0) {
// 子进程:读数据
close(fd[1]); // 关闭写端
read(fd[0], buf, sizeof(buf));
printf("子进程收到: %s\n", buf);
close(fd[0]);
} else {
// 父进程:写数据
close(fd[0]); // 关闭读端
const char *msg = "Hello from parent!";
write(fd[1], msg, strlen(msg) + 1);
close(fd[1]);
wait(NULL); // 等待子进程结束
}
return 0;
}
运行结果:
子进程收到: Hello from parent!
关键点:fork之后,父子进程各有一份文件描述符副本。如果不关闭不用的端,会导致引用计数不为0,读端永远读不到EOF,写端永远写不进去。这是新手最容易犯的错误。
1.6 兄弟进程间的管道通信
兄弟进程通信,其实也是通过父进程搭桥。流程是:
- 父进程创建管道。
- 父进程fork两次,生成两个子进程。
- 父进程关闭读写端,只做管理。
- 子进程A关闭读端,子进程B关闭写端。
说白了,还是那套「关掉不用的端」的逻辑。兄弟进程之间没有直接血缘关系,但通过父进程创建的管道,它们就能通信了。
1.7 管道的大小与容量限制
管道缓冲区有多大?这取决于系统。你可以用 fpathconf() 来查:
long size = fpathconf(fd[0], _PC_PIPE_BUF);
printf("PIPE_BUF = %ld\n", size);
在Linux上,PIPE_BUF 通常是4096字节。但注意,这是保证原子写入的最大值。如果你一次写入超过 PIPE_BUF 的数据,内核可能会分多次写入,读端就可能读到不完整的数据。
管道的总容量(不是 PIPE_BUF)在Linux 2.6.11之后是65536字节,也就是64KB。你可以通过 fcntl(fd, F_SETPIPE_SZ, size) 来调整,但一般不建议动它。
| 属性 | 值 | 说明 |
|---|---|---|
| PIPE_BUF | 4096字节(通常) | 原子写入的最大值 |
| 总容量 | 65536字节(默认) | 可通过fcntl调整 |
| 最大容量 | 受限于系统内存 | 有上限,但一般够用 |
1.8 管道的优缺点
聊了这么多,咱们总结一下管道的优缺点。
优点:
- 使用简单。一个
pipe()加一个fork()就能搞定。 - 内核自动同步。你不用自己加锁。
- 数据流自然。适合生产者-消费者模型。
- 轻量。不涉及文件系统操作(无名管道)。
缺点:
- 半双工。只能单向流动。
- 只能用于有亲缘关系的进程。
- 数据是字节流,没有边界。应用层需要自己定义协议。
- 容量有限。大数据量传输可能阻塞。
- 多读多写时,原子性只保证到
PIPE_BUF大小。
我的建议:管道最适合做「小数据量、低频次、有亲缘关系」的进程间通信。如果你需要高频大数据量传输,或者需要任意进程间通信,可以考虑共享内存或消息队列。但作为入门,管道是理解IPC概念的最好起点。
知识体系总览
下面这张图,帮你把本章的知识点串起来:
这张图把本章的核心知识点都串起来了。从创建管道开始,到读写特性,再到阻塞控制,每一步都有对应的系统调用和注意事项。你可以把它当作学习地图,哪里不会点哪里。
好了,关于无名管道,咱们就聊到这儿。内容不少,但都是干货。你回去可以自己动手写几个demo试试,比如让父子进程互相发消息,或者试试非阻塞模式下的行为。实践出真知,代码跑一遍比看十遍都管用。
公众号:蓝海资料掘金营,微信deep3321