4、管道实战进阶:双管道实现全双工通信、管道与select/poll结合、管道与epoll结合、常见管道bug排查
管道这东西,基础用法大家都会。但真正到了实战中,你会发现单管道那点单向通信能力,根本不够用。我刚开始做多进程开发时,就吃过这个亏——两个进程互相发消息,结果死锁了,查了半天才发现是管道方向搞反了。
这一章,咱们就把管道玩透。从双管道实现全双工,到和I/O多路复用结合,再到那些让人头疼的bug排查。嗯,都是我在项目中踩过的坑。
4.1 双管道实现全双工通信
普通管道是单向的。你想让两个进程互相发消息?那就得开两条管道。说白了,一条从A到B,一条从B到A。
核心思路:父进程创建两个管道,一个用于父写子读,一个用于子写父读。这样双方都能收发数据。
我给大家看个实际例子。这个代码我当年在写一个日志采集系统时用过,两个进程互相传递控制命令和数据流:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
int main() {
int pipe1[2]; // 父写 -> 子读
int pipe2[2]; // 子写 -> 父读
pid_t pid;
char buf[256];
if (pipe(pipe1) == -1 || pipe(pipe2) == -1) {
perror("pipe");
return 1;
}
pid = fork();
if (pid == 0) {
// 子进程
close(pipe1[1]); // 关闭pipe1写端
close(pipe2[0]); // 关闭pipe2读端
// 从pipe1读,往pipe2写
read(pipe1[0], buf, sizeof(buf));
printf("子进程收到: %s\n", buf);
strcpy(buf, "收到,over");
write(pipe2[1], buf, strlen(buf) + 1);
close(pipe1[0]);
close(pipe2[1]);
} else {
// 父进程
close(pipe1[0]); // 关闭pipe1读端
close(pipe2[1]); // 关闭pipe2写端
strcpy(buf, "开始干活");
write(pipe1[1], buf, strlen(buf) + 1);
read(pipe2[0], buf, sizeof(buf));
printf("父进程收到: %s\n", buf);
close(pipe1[1]);
close(pipe2[0]);
wait(NULL);
}
return 0;
}
个人经验:我习惯在fork之后立刻关闭不需要的管道端。这样做有两个好处:一是节省文件描述符,二是避免误操作。你想想看,如果子进程还留着写端没关,父进程读数据时可能永远等不到EOF。
4.2 管道与select/poll结合
单管道读写是阻塞的。如果管道里没数据,read会一直卡着。这在多进程通信中很要命——你总不能专门开个线程去等数据吧?
这时候就需要select或poll出场了。它们能帮你同时监控多个管道,哪个有数据就读哪个。我在做一个网关程序时,同时监控了8个管道,全靠select撑起来的。
4.2.1 使用select监控管道
#include <stdio.h>
#include <unistd.h>
#include <sys/select.h>
#include <string.h>
int main() {
int pipe_fd[2];
fd_set read_fds;
struct timeval tv;
char buf[256];
pipe(pipe_fd);
pid_t pid = fork();
if (pid == 0) {
// 子进程:写数据
close(pipe_fd[0]);
sleep(2); // 模拟延迟
write(pipe_fd[1], "hello", 6);
close(pipe_fd[1]);
} else {
// 父进程:用select等数据
close(pipe_fd[1]);
FD_ZERO(&read_fds);
FD_SET(pipe_fd[0], &read_fds);
tv.tv_sec = 5;
tv.tv_usec = 0;
int ret = select(pipe_fd[0] + 1, &read_fds, NULL, NULL, &tv);
if (ret == -1) {
perror("select");
} else if (ret == 0) {
printf("超时了,没等到数据\n");
} else {
if (FD_ISSET(pipe_fd[0], &read_fds)) {
read(pipe_fd[0], buf, sizeof(buf));
printf("收到数据: %s\n", buf);
}
}
close(pipe_fd[0]);
wait(NULL);
}
return 0;
}
避坑指南:我曾经犯过一个低级错误——select之后忘记重新设置timeout。select会修改timeval结构体,把剩余时间写进去。如果你在循环里用select,每次都要重新初始化timeout。不然第二次调用时timeout可能已经是0了,直接超时返回。
4.2.2 使用poll监控管道
poll比select好一点的地方在于,它没有文件描述符数量限制。select默认只能监控1024个,poll就没这个问题。我个人现在写新代码基本都用poll了。
#include <stdio.h>
#include <unistd.h>
#include <poll.h>
#include <string.h>
int main() {
int pipe_fd[2];
struct pollfd fds[1];
char buf[256];
pipe(pipe_fd);
pid_t pid = fork();
if (pid == 0) {
close(pipe_fd[0]);
sleep(1);
write(pipe_fd[1], "world", 6);
close(pipe_fd[1]);
} else {
close(pipe_fd[1]);
fds[0].fd = pipe_fd[0];
fds[0].events = POLLIN;
int ret = poll(fds, 1, 3000); // 3秒超时
if (ret == -1) {
perror("poll");
} else if (ret == 0) {
printf("超时\n");
} else {
if (fds[0].revents & POLLIN) {
read(pipe_fd[0], buf, sizeof(buf));
printf("收到: %s\n", buf);
}
}
close(pipe_fd[0]);
wait(NULL);
}
return 0;
}
4.3 管道与epoll结合
如果你要监控的管道数量特别多,比如几百上千个,那select和poll都不够看了。这时候就得请出epoll。epoll是Linux下最高效的I/O多路复用机制,没有之一。
我记得有一次做一个消息中间件,要同时管理500多个子进程的管道通信。用poll的时候CPU占用率居高不下,换成epoll之后,直接降到了个位数。差距就是这么大。
#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <string.h>
#define MAX_EVENTS 10
int main() {
int pipe_fd[2];
int epoll_fd;
struct epoll_event ev, events[MAX_EVENTS];
char buf[256];
pipe(pipe_fd);
pid_t pid = fork();
if (pid == 0) {
close(pipe_fd[0]);
sleep(1);
write(pipe_fd[1], "epoll test", 11);
close(pipe_fd[1]);
} else {
close(pipe_fd[1]);
// 创建epoll实例
epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
perror("epoll_create1");
return 1;
}
ev.events = EPOLLIN;
ev.data.fd = pipe_fd[0];
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, pipe_fd[0], &ev) == -1) {
perror("epoll_ctl");
return 1;
}
// 等待事件,超时5秒
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, 5000);
if (nfds == -1) {
perror("epoll_wait");
} else if (nfds == 0) {
printf("超时\n");
} else {
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
read(events[i].data.fd, buf, sizeof(buf));
printf("epoll收到: %s\n", buf);
}
}
}
close(epoll_fd);
close(pipe_fd[0]);
wait(NULL);
}
return 0;
}
使用建议:epoll有两种触发模式——水平触发(LT)和边缘触发(ET)。默认是LT,和select/poll行为一致。ET模式效率更高,但使用起来要小心:你必须一次性把数据读完,否则就再也收不到事件通知了。我建议新手先用LT,等熟悉了再尝试ET。
4.4 常见管道bug排查
管道用多了,bug也就见多了。我把自己这些年踩过的坑整理一下,希望能帮你少走弯路。
4.4.1 死锁问题
最常见的就是死锁。两个进程互相等对方写数据,结果谁都不写。为什么会这样?说白了就是管道满了。
Linux管道默认缓冲区是65536字节(64KB)。如果写端一直写,读端不读,缓冲区满了之后write就会阻塞。这时候如果读端也在等写端发消息,那就死锁了。
解决方案:要么用非阻塞I/O,要么用select/poll/epoll做超时控制。我个人的习惯是,所有管道读写都加上超时,宁可超时重试,也不要死锁。
4.4.2 文件描述符泄漏
这个bug特别隐蔽。你fork之后,子进程继承了父进程的所有文件描述符。如果子进程不关掉不需要的管道端,这些fd就一直占着。时间长了,系统fd用完了,pipe()就返回-1。
我曾经在一个长期运行的服务里遇到这个问题。跑了三天,突然所有管道都创建失败了。查了半天,发现是某个子进程没关父进程的管道写端。
4.4.3 数据粘包问题
管道是字节流,没有消息边界。如果你连续写两次,读端可能一次就读走了。比如你写了"hello"和"world",读端可能读到"helloworld"。
解决办法有几种:
- 固定消息长度:每次读写固定字节数
- 添加消息头:头部包含消息长度信息
- 使用特殊分隔符:比如用'\n'作为消息结束标志
我个人推荐第二种方式,加消息头。虽然实现起来复杂一点,但最可靠。
4.4.4 SIGPIPE信号问题
如果读端关闭了管道,写端还在写,系统会发送SIGPIPE信号。默认行为是终止进程。很多新手第一次遇到这个问题时都懵了——进程莫名其妙就挂了。
避坑指南:如果你不想让进程被SIGPIPE干掉,可以忽略这个信号:signal(SIGPIPE, SIG_IGN);。这样write会返回-1,errno设置为EPIPE。你就可以自己处理这个错误了。
4.4.5 缓冲区溢出
管道缓冲区有限,写太快读太慢就会满。这时候write会阻塞。如果你用的是非阻塞模式,write会返回-1,errno是EAGAIN。
我建议的做法是:写数据前先检查管道是否可写。用select或poll监控写事件,等管道有空位了再写。这样既不会阻塞,也不会丢数据。
| 常见bug | 症状 | 解决方案 |
|---|---|---|
| 死锁 | 进程卡住不动 | 加超时、用非阻塞I/O |
| fd泄漏 | pipe()返回-1 | 及时关闭不需要的管道端 |
| 数据粘包 | 数据混在一起 | 加消息头或分隔符 |
| SIGPIPE | 进程意外退出 | 忽略SIGPIPE信号 |
| 缓冲区溢出 | write阻塞或返回EAGAIN | 监控写事件,按需写入 |
嗯,管道这东西,说简单也简单,说复杂也复杂。关键是要理解它的特性——单向、字节流、有缓冲区大小限制。把这些搞明白了,再用select/poll/epoll武装起来,基本上就能应对大部分场景了。
下一章我们会讲命名管道(FIFO),那又是另一个世界了。先消化好今天的内容,尤其是双管道和epoll的用法,后面会经常用到。