Linux I/O 等待机制:从阻塞管道到 poll 多路复用
1. 学习目标与实验环境
本文通过匿名管道实验,说明以下概念:
- 阻塞与非阻塞 I/O 的区别;
- 等待时间与 CPU 执行时间的区别;
EAGAIN、EOF 与就绪事件的关系;poll()的参数、返回值和事件处理;- 一个父进程如何同时处理两条管道。
实验环境:Linux、C11、cc、strace。
本文主要讨论普通匿名管道,且 read() 请求长度大于 0。相关结论不能不加区分地套用到所有文件类型。
2. 阻塞与非阻塞:改变的是等待方式
2.1 管道读取的基本行为
| 管道状态 | 阻塞读取 | 非阻塞读取 |
|---|---|---|
| 缓冲区有数据 | 返回实际读取的字节数 | 返回实际读取的字节数 |
| 缓冲区为空,仍有写端引用 | 等待数据或其他结束等待的条件 | 返回 -1,errno 为 EAGAIN |
| 缓冲区为空,所有写端引用已关闭 | 返回 0,表示 EOF |
返回 0,表示 EOF |
关键区别:
EAGAIN:暂时没有数据,之后仍可能有;- EOF:剩余数据读完,并且已不存在写端引用;
- 请求读取的长度是上限,不要求必须读满才返回。
非阻塞模式并不会使数据提前到达,只是不让本次读取为了等待数据而持续阻塞。
2.2 阻塞不等于持续占用 CPU
阻塞读取等待数据时,调用线程通常进入睡眠状态,让出 CPU。条件满足后,线程被唤醒,重新获得执行机会,再完成系统调用。
一次实验的时间统计如下:
real 0m3.011s
user 0m0.008s
sys 0m0.003s
| 指标 | 含义 |
|---|---|
real |
实际经过的时间 |
user |
被计入的用户态 CPU 时间 |
sys |
被计入的内核态 CPU 时间 |
程序经过约 3 秒,但被计入的 CPU 时间只有约 11 毫秒。结合 trace,可以判断大部分时间用于等待。
系统调用尚未返回,不代表线程一直在执行内核代码。
另外,strace -T 显示的是系统调用经过的时间,不是该调用独占 CPU 的时间。
3. 三种等待方式的对照
| 方式 | 暂时无数据时的处理 | 主要特点 |
|---|---|---|
阻塞 read() |
在读取调用中等待 | 简单,适合当前线程只负责该对象的情况 |
非阻塞 read() + sleep() |
返回后定时重试 | 避免忙循环,但存在额外检查和响应延迟 |
非阻塞 read() + poll() |
等待 FD 状态变化,再尝试读取 | 可以统一等待多个 FD |
非阻塞模式下,如果收到 EAGAIN 后立即无限重试,仍然可能形成忙循环。
使用 sleep(1) 可以降低检查频率,但数据可能在睡眠刚开始时到达,程序仍要等到下一次检查。
poll() 的作用是:没有可处理状态时等待,出现相关状态后返回。
4. poll():等待状态,不负责读取数据
4.1 接口
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
| 参数 | 含义 |
|---|---|
fds |
需要观察的 FD 条目数组 |
nfds |
数组中的条目数量,不是最大 FD 编号 |
timeout |
等待上限,单位为毫秒 |
timeout 的常用取值:
-1:不设置超时上限;0:只检查当前状态,不等待;- 正数:最多等待指定时长。
超时是上限,不是必须等待的时长。已有事件时,poll() 可以立即返回。
4.2 events 与 revents
struct pollfd {
int fd;
short events;
short revents;
};
| 字段 | 用途 |
|---|---|
fd |
需要观察的描述符 |
events |
应用程序填写的关注事件 |
revents |
poll() 返回时报告的实际事件 |
必须根据 revents 判断结果:
if (pfd.revents & POLLIN) {
/* 处理读取就绪。 */
}
不能使用 events 代替结果。关注了 POLLIN,并不表示当前已经可读。
事件可能组合出现,因此一般使用按位与,而不是只判断相等。
4.3 返回值
| 返回值 | 含义 |
|---|---|
-1 |
调用失败,检查 errno |
0 |
超时,没有报告事件 |
| 大于 0 | 有事件的数组条目数量 |
返回值不是读取字节数,也不是就绪条目的下标。
例如,返回 1 只说明一个条目有事件,仍然需要检查数组中的 revents,确定是哪一个。
4.4 常见事件
| 事件 | 含义 |
|---|---|
POLLIN |
读取就绪 |
POLLHUP |
挂断;对本实验的管道读端,表示所有写端引用已关闭 |
POLLERR |
错误状态 |
POLLNVAL |
FD 无效 |
对管道来说,写端关闭时,缓冲区可能仍有数据,因此可能同时出现:
POLLIN | POLLHUP
不能看到 POLLHUP 就直接丢弃读取端。应先读取剩余数据,最终根据 read() == 0 确认 EOF。
4.5 为什么与非阻塞读取配合?
就绪信息不是永久承诺。例如,另一个读取者可能先取走数据。
因此常见模式是:
poll()等待并报告状态;- 非阻塞
read()尝试读取; - 读到数据则处理;
EAGAIN则重新等待;- EOF 或不可恢复错误则结束相应处理。
poll() 可以阻塞,具体 I/O 可以非阻塞,两者并不矛盾。
5. 双管道实验设计
5.1 进程分工
创建两条管道和两个子进程:
| 进程 | 保留的管道端点 | 工作 |
|---|---|---|
| 父进程 | A 读端、B 读端 | 用 poll() 同时等待并接收 |
| 子进程 A | A 写端 | 延迟 5 秒发送 |
| 子进程 B | B 写端 | 延迟 1 秒发送 |
所有进程都必须关闭自己不使用的管道端点。
特别是:
- A 必须关闭继承的 B 写端;
- B 必须关闭继承的 A 写端;
- 父进程必须关闭两个写端。
否则,多余的写端引用可能导致读取者迟迟收不到 EOF。
5.2 验证目标
即使 A 排在观察数组的第一个位置,父进程也应先处理更早就绪的 B。
父进程的正常执行顺序为:
- 关闭不用的写端;
- 设置两个读端为非阻塞;
- 同时等待并接收;
- 两条管道都 EOF 后,结束接收;
- 回收子进程;
- 关闭读端。
不应在开始接收前就阻塞等待 A 退出。
6. 完整实验代码
保存为 poll_two_pipes.c:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <poll.h>
#include <fcntl.h>
#include <errno.h>
int main(void)
{
int pipe_a[2];
int pipe_b[2];
if (pipe(pipe_a) == -1) {
perror("pipe A");
return EXIT_FAILURE;
}
if (pipe(pipe_b) == -1) {
perror("pipe B");
close(pipe_a[0]);
close(pipe_a[1]);
return EXIT_FAILURE;
}
/* 子进程 A:仅保留 A 写端。 */
pid_t pid_a = fork();
if (pid_a == -1) {
perror("fork A");
close(pipe_a[0]);
close(pipe_a[1]);
close(pipe_b[0]);
close(pipe_b[1]);
return EXIT_FAILURE;
}
if (pid_a == 0) {
close(pipe_a[0]);
close(pipe_b[0]);
close(pipe_b[1]);
sleep(5);
const char message[] = "hello from A\n";
ssize_t written = write(pipe_a[1], message,
sizeof(message) - 1);
if (written == -1) {
perror("write A");
close(pipe_a[1]);
return EXIT_FAILURE;
}
if ((size_t)written != sizeof(message) - 1) {
fprintf(stderr, "short write A\n");
close(pipe_a[1]);
return EXIT_FAILURE;
}
close(pipe_a[1]);
return EXIT_SUCCESS;
}
/* 只有父进程会继续创建 B。 */
pid_t pid_b = fork();
if (pid_b == -1) {
perror("fork B");
if (waitpid(pid_a, NULL, 0) == -1) {
perror("wait A");
}
close(pipe_a[0]);
close(pipe_a[1]);
close(pipe_b[0]);
close(pipe_b[1]);
return EXIT_FAILURE;
}
/* 子进程 B:仅保留 B 写端。 */
if (pid_b == 0) {
close(pipe_a[0]);
close(pipe_a[1]);
close(pipe_b[0]);
sleep(1);
const char message[] = "hello from B\n";
ssize_t written = write(pipe_b[1], message,
sizeof(message) - 1);
if (written == -1) {
perror("write B");
close(pipe_b[1]);
return EXIT_FAILURE;
}
if ((size_t)written != sizeof(message) - 1) {
fprintf(stderr, "short write B\n");
close(pipe_b[1]);
return EXIT_FAILURE;
}
close(pipe_b[1]);
return EXIT_SUCCESS;
}
/* 父进程:仅保留两个读端。 */
printf("pipe A: read_fd=%d, write_fd=%d\n",
pipe_a[0], pipe_a[1]);
printf("pipe B: read_fd=%d, write_fd=%d\n",
pipe_b[0], pipe_b[1]);
close(pipe_a[1]);
close(pipe_b[1]);
int result = EXIT_SUCCESS;
struct pollfd fds[2] = {
{
.fd = pipe_a[0],
.events = POLLIN,
.revents = 0
},
{
.fd = pipe_b[0],
.events = POLLIN,
.revents = 0
}
};
const char *names[2] = {"A", "B"};
for (int i = 0; i < 2; i++) {
int flags = fcntl(fds[i].fd, F_GETFL);
if (flags == -1 ||
fcntl(fds[i].fd, F_SETFL,
flags | O_NONBLOCK) == -1) {
perror("fcntl");
result = EXIT_FAILURE;
break;
}
}
int remaining = 2;
while (remaining > 0 && result == EXIT_SUCCESS) {
int ready = poll(fds, 2, -1);
if (ready == -1) {
if (errno == EINTR) {
continue;
}
perror("poll");
result = EXIT_FAILURE;
break;
}
for (int i = 0; i < 2; i++) {
if (fds[i].fd < 0) {
continue;
}
if (fds[i].revents & (POLLERR | POLLNVAL)) {
fprintf(stderr, "poll error on pipe %s\n",
names[i]);
result = EXIT_FAILURE;
break;
}
if (!(fds[i].revents & (POLLIN | POLLHUP))) {
continue;
}
char buffer[64];
ssize_t nread = read(fds[i].fd, buffer,
sizeof(buffer) - 1);
if (nread == -1) {
if (errno == EAGAIN ||
errno == EWOULDBLOCK ||
errno == EINTR) {
continue;
}
perror("parent read");
result = EXIT_FAILURE;
break;
}
if (nread == 0) {
printf("parent: pipe %s EOF\n", names[i]);
fds[i].fd = -1;
remaining--;
continue;
}
buffer[nread] = '\0';
printf("parent received from %s: %s",
names[i], buffer);
}
}
/* 接收结束后回收子进程。 */
if (waitpid(pid_a, NULL, 0) == -1) {
perror("wait A");
result = EXIT_FAILURE;
}
if (waitpid(pid_b, NULL, 0) == -1) {
perror("wait B");
result = EXIT_FAILURE;
}
/* 实际 FD 统一在这里关闭。 */
close(pipe_a[0]);
close(pipe_b[0]);
return result;
}
7. 编译、运行与观察
7.1 编译运行
cc -std=c11 -Wall -Wextra -Werror -g -O0 \
poll_two_pipes.c -o poll_two_pipes \
&& timeout 10s ./poll_two_pipes
正常调度下,输出类似:
pipe A: read_fd=3, write_fd=4
pipe B: read_fd=5, write_fd=6
parent received from B: hello from B
parent: pipe B EOF
parent received from A: hello from A
parent: pipe A EOF
实验已观察到 B 先被接收、A 后被接收。具体 FD 编号与时间受运行环境影响。
约 1 秒时 B 发送,约 5 秒时 A 发送。父进程不需要等 A 准备好,才能处理 B。
这里的消息由父进程读取管道后打印,不是子进程直接向终端打印。
7.2 系统调用观察
timeout 10s strace -f -tt -T \
-e trace=pipe,pipe2,fcntl,poll,ppoll,read,write,close,wait4 \
./poll_two_pipes
观察重点:
fcntl()为两个读端设置O_NONBLOCK;poll()同时观察两个读端;- B 写入后,B 对应条目的
revents出现读取就绪; - 父进程读取 B,不对尚未就绪的 A 执行阻塞等待;
- B 到达 EOF 后被移出观察;
- 父进程继续等待 A;
- 接收结束后回收子进程并关闭读端。
strace -T 的耗时包含等待,不等于 CPU 时间。
8. 接收循环的关键设计
8.1 遍历数组,不等于逐个阻塞等待
poll(fds, 2, -1) 是一次统一等待。
返回后遍历数组,只是在检查哪些条目有事件:
- A 没有事件:跳过;
- B 有事件:处理 B。
如果 A、B 同时就绪,本示例按数组顺序处理。但不会因为 A 尚未就绪,就阻止 B 被处理。
8.2 EOF 后必须停止观察该条目
fds[i].fd = -1;
remaining--;
poll() 忽略 FD 为负数的条目。
如果 EOF 后仍持续观察,POLLHUP 可能让 poll() 反复立即返回,形成无意义循环。
注意:
修改 fds[i].fd 只是修改用户态数组,不等于关闭内核 FD。
本例仍通过 pipe_a[0]、pipe_b[0] 保存实际编号,并在末尾统一关闭。
8.3 POLLHUP 与 EOF 不能混为一谈
POLLHUP 表示写端引用已经全部关闭,但缓冲区可能还有数据。
处理顺序是:
- 根据
POLLIN或POLLHUP尝试读取; - 有数据则处理;
read()返回 0 后,才认定该管道接收结束。
8.4 不能先 waitpid() 再开始接收
如果父进程先等待 A 退出:
- 会失去及时处理 B 的机会;
- 数据量较大时,子进程可能因管道写满而等待父进程读取;
- 父进程又在等待子进程退出,可能形成相互等待。
本例正常路径先完成接收,再回收子进程。
9. 常见错误与修正
| 错误 | 问题 | 正确处理 |
|---|---|---|
检查 events |
检查的是关注要求,不是返回结果 | 检查 revents |
把 poll() 返回值当作数组下标 |
返回值是有事件的条目数量 | 遍历数组查看 revents |
只判断 revents == POLLIN |
可能漏掉组合事件 | 使用按位与 |
EAGAIN 后立即无限重试 |
可能形成忙循环 | 回到就绪等待 |
看到 POLLHUP 就停止读取 |
可能丢弃尚未读取的数据 | 继续读,直到 EOF |
| EOF 后仍观察原条目 | 可能持续收到 POLLHUP |
设置负 FD 或移除条目 |
| 忘记关闭继承的多余写端 | 其他管道可能迟迟没有 EOF | 按进程职责关闭无用端点 |
把 fds[i].fd = -1 当成 close() |
内核引用仍然存在 | 明确安排实际关闭 |
| 把系统调用耗时当作 CPU 时间 | 混淆等待与执行 | 结合 time 等统计判断 |
10. 示例边界
本代码是固定短消息的机制实验,不是完整的生产级 I/O 框架:
- 子进程发送固定的 13 字节消息,短写直接报告失败,未实现通用循环写入;
- 管道是字节流,不保证一次写入对应一次读取,本例打印的是读取片段;
poll()和父进程read()处理了EINTR,其他调用的重试策略尚未完整实现;waitpid(..., NULL, ...)只等待并回收,未检查子进程退出状态;- 部分关闭和标准输出错误未处理,
SIGPIPE使用默认行为; - 接收提前失败后的等待策略只适用于本例的有限短消息。通用程序必须设计取消、排空或终止与回收流程,避免子进程因写满管道而无法退出。
普通磁盘文件通常不会因为设置 O_NONBLOCK 就获得与管道、socket 相同的非阻塞效果,不应直接照搬本例的等待模型。
结论
非阻塞 I/O 防止具体读写因等待数据而占住执行流程;poll() 统一等待多个 FD 的状态变化;应用程序根据返回事件执行实际 I/O,并独立处理 EOF、错误和资源清理。
多路复用并不意味着一个线程同时执行多段业务代码,而是使它能够及时选择当前可处理的对象。