管道(Pipe):进程间通信的“水管”哲学
管道这东西,说白了就是一根虚拟的水管。一头接进程A,一头接进程B。A往里面倒数据,B从另一头接。就这么简单。
我刚开始学Linux编程时,觉得管道太小儿科了。后来在项目中遇到一个日志采集的场景,才真正体会到它的精妙。嗯,咱们今天就把管道掰开揉碎了讲清楚。
匿名管道:最朴素的IPC方式
匿名管道,名字就叫“匿名”,因为它没有名字。它只在有亲缘关系的进程间工作——通常是父子进程。
它的原理其实很直白:
- 内核维护一个缓冲区,就像个环形队列
- 一端写,一端读,数据流是单向的
- 写端关闭后,读端读到EOF
- 读端关闭后,写端收到SIGPIPE信号
核心API就四个:
pipe(int fd[2])— 创建管道,fd[0]读端,fd[1]写端read(fd[0], buf, size)— 从读端读取数据write(fd[1], buf, size)— 向写端写入数据close(fd)— 关闭端
这里有个坑,我踩过好几次。创建管道后,父子进程都会持有两个文件描述符。如果你不关掉不需要的那一端,就会出问题。
我曾经犯过的错:
父进程写完后没关读端,子进程读完后没关写端。结果子进程一直阻塞在read()上,因为写端没关,它以为还有数据要来。排查了半天,最后发现是文件描述符没关干净。
匿名管道的限制
说实话,匿名管道的限制挺多的。我列几个常见的:
| 限制项 | 说明 |
|---|---|
| 只能用于亲缘进程 | 父子、兄弟进程之间才能用 |
| 单向通信 | 数据只能从一个方向流 |
| 缓冲区有限 | Linux默认是65536字节(64KB) |
| 无名字 | 其他进程无法通过名字找到它 |
| 生命周期随进程 | 进程结束,管道就没了 |
你想想看,如果两个不相关的进程要通信,匿名管道就无能为力了。这时候就需要命名管道出场。
命名管道(FIFO):给管道一个名字
命名管道,也叫FIFO。它和匿名管道的本质一样,都是内核缓冲区。但区别在于:它在文件系统里有一个名字,像个特殊的文件。
创建FIFO有两种方式:
// 命令行
mkfifo /tmp/my_fifo
// C代码
#include <sys/types.h>
#include <sys/stat.h>
int ret = mkfifo("/tmp/my_fifo", 0666);
if (ret == -1) {
perror("mkfifo");
}
命名管道的使用场景,我总结了几种:
- 日志收集:多个进程往同一个FIFO写日志,一个进程统一读取
- 简单消息队列:生产者-消费者模式,FIFO就是那个队列
- 跨用户通信:不同用户启动的进程,通过FIFO交换数据
- 脚本间通信:Shell脚本里经常用FIFO做数据中转
我的个人习惯:
用FIFO时,我一般会在/tmp目录下创建,文件名带上进程ID或时间戳,避免冲突。用完记得unlink删除,不然下次启动会报文件已存在。
实战:用管道实现父子进程数据交换
光说不练假把式。咱们写个完整的例子:父进程生成一批随机数,通过管道发给子进程,子进程计算平均值后返回结果。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <time.h>
#define NUM_COUNT 10
int main() {
int fd1[2], fd2[2]; // 两根管道,实现双向通信
pid_t pid;
int numbers[NUM_COUNT];
int sum = 0, avg = 0;
// 创建两根管道
if (pipe(fd1) == -1 || pipe(fd2) == -1) {
perror("pipe");
exit(1);
}
pid = fork();
if (pid == -1) {
perror("fork");
exit(1);
}
if (pid == 0) {
// 子进程
close(fd1[1]); // 关掉fd1的写端
close(fd2[0]); // 关掉fd2的读端
// 从管道读取数据
read(fd1[0], numbers, sizeof(numbers));
// 计算平均值
for (int i = 0; i < NUM_COUNT; i++) {
sum += numbers[i];
}
avg = sum / NUM_COUNT;
// 把结果写回父进程
write(fd2[1], &avg, sizeof(avg));
close(fd1[0]);
close(fd2[1]);
exit(0);
} else {
// 父进程
close(fd1[0]); // 关掉fd1的读端
close(fd2[1]); // 关掉fd2的写端
srand(time(NULL));
for (int i = 0; i < NUM_COUNT; i++) {
numbers[i] = rand() % 100;
printf("生成数字: %d\n", numbers[i]);
}
// 把数据发给子进程
write(fd1[1], numbers, sizeof(numbers));
// 等待子进程计算结果
read(fd2[0], &avg, sizeof(avg));
printf("子进程计算的平均值: %d\n", avg);
wait(NULL);
close(fd1[1]);
close(fd2[0]);
}
return 0;
}
这个例子用了两根管道来实现双向通信。为什么不用一根?因为管道是单向的。一根管道只能一个方向流数据。
运行结果大概是这样的:
生成数字: 42
生成数字: 17
生成数字: 89
生成数字: 33
生成数字: 56
生成数字: 71
生成数字: 24
生成数字: 68
生成数字: 95
生成数字: 11
子进程计算的平均值: 50
避坑指南:
我曾经在项目中遇到一个诡异的问题:父进程write完数据后,子进程read不到。排查了半天,发现是父进程write的数据量超过了管道缓冲区大小(64KB),导致write阻塞了。解决办法是分多次write,或者用非阻塞模式。
管道的性能考量
说实话,管道不是最快的IPC方式。它的性能受几个因素影响:
- 上下文切换:每次read/write都可能触发系统调用
- 数据拷贝:数据要从用户空间拷贝到内核空间,再拷贝到另一个用户空间
- 缓冲区大小:太小了频繁阻塞,太大了浪费内存
我一般建议:数据量小于1MB,通信频率不高的情况下,用管道完全够用。如果追求高性能,可以考虑共享内存或消息队列。
小技巧:
可以用fcntl(fd, F_SETFL, O_NONBLOCK)把管道设置成非阻塞模式。这样read/write不会阻塞,但你要自己处理EAGAIN错误。我一般在调试阶段用阻塞模式,上线前改成非阻塞。
好了,管道这块就讲这么多。记住一句话:管道是进程间通信的“水管”,简单但够用。下一章咱们聊聊消息队列,那个比管道更灵活一些。