【Linux 网络编程】五种 I/O 模型与非阻塞 I/O:从“等数据”到正确处理 EAGAIN

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位:在进入 select、poll、epoll 之前,先把 I/O 模型这张地图看清楚。

💡 学习目标 :理解五种 I/O 模型的差异;分清同步、异步与阻塞、非阻塞;正确设置 O_NONBLOCK;写出能区分数据、EOF、暂不可读与真正错误的读取循环。

📌 阅读范围:以 Linux 上普通、带缓冲的套接字接收为主要模型,以标准输入与本地流套接字完成实验。多路复用先解释工作方式,具体接口留待后续展开。文中的时间线表示先后关系,不表示真实耗时比例。

写过 TCP 服务端之后,我们通常都见过这样的代码:

cpp 复制代码
ssize_t n = recv(sock, buffer, sizeof(buffer), 0);

如果客户端迟迟没有发数据,这一行会怎样?

在普通的阻塞套接字上,调用可能一直等下去。此时,负责这个连接的线程不能继续执行下一行。假如这个线程还要照顾其他连接,那么一个沉默的客户端,就可能挡住后面本来已经有数据的客户端。

很自然,我们会想到:能不能先看一眼,有数据就读,没有就先做别的?能不能一次等很多连接?能不能等数据好了再通知我?甚至,能不能把接收工作交出去,完成以后再领取结果?

这些问题,正好对应不同的 I/O 组织方式。

不过,真正容易把人绕进去的,并不是五个名字,而是"等待""就绪""完成"被混在了一起。先把一次接收拆开,再比较模型,概念就会清楚得多。


文章目录

  1. 一次接收,先分清两个阶段
  2. 阻塞与非阻塞:没数据时怎么办
  3. [I/O 多路复用:把多个连接放到一起等待](#I/O 多路复用:把多个连接放到一起等待)
  4. [信号驱动与异步 I/O:通知的是就绪还是完成](#信号驱动与异步 I/O:通知的是就绪还是完成)
  5. 同步、异步、阻塞、非阻塞怎么区分
  6. [fcntl:正确打开 O_NONBLOCK](#fcntl:正确打开 O_NONBLOCK)
  7. 读返回值:五类结果不能混在一起
  8. 实验一:非阻塞读取标准输入
  9. [实验二:用 socketpair 观察完整状态变化](#实验二:用 socketpair 观察完整状态变化)
  10. 从轮询走向事件循环
  11. 常见误区与自测

一、一次接收,先分清两个阶段

1.1 数据到达内核,不等于程序已经拿到数据

假设客户端发送了一段字节流,服务端要把它读到自己的 buffer 中。为了理解模型,可以把普通套接字接收简化成两个阶段:

  1. 等待数据可用:数据经过网络与协议栈处理,进入套接字的内核接收缓冲区,满足本次接收的条件。
  2. 取回数据:执行接收操作,把可交付的数据从内核缓冲区复制到调用者提供的用户缓冲区,返回本次读取结果。

例如,recv() 的第二个参数 buffer,是应用程序提供的地址。对端的数据已经进入内核接收缓冲区时,不能据此认定这个 buffer 已经自动填好了。应用仍要执行接收操作,或者事先提交由异步机制完成的接收请求。

这就形成两个不同的状态:

  • 就绪:相应操作现在有条件取得进展;对读事件来说,也可能是能够读到 EOF 或错误。
  • 完成:某一次已发起的操作已经结束,并产生了字节数或错误等结果。

"这条连接可读了"和"这次请求的接收操作完成了",不是同一句话。

1.2 两阶段模型有使用范围

这里讨论的是普通缓冲接收的教学模型,不是在宣称所有 I/O 都必须按照相同路径完成两次固定动作。内存映射、零拷贝、直接 I/O、设备 I/O 等有各自的路径与约束。

同样,也不能说任何场景中等待都必然远大于拷贝。低速网络中的等待可能非常明显,大吞吐系统中的拷贝、协议处理、缓存失效和业务计算也可能成为主要成本。

优化 I/O 的一个重要目标,是减少线程在无效等待和无效探测上浪费的资源,让它在其他连接可处理时能够继续工作。这并不会让网络传播时间凭空消失,也不会保证一条连接的单次延迟必然降低。

1.3 阻塞的是当前调用线程

多线程程序里,一个线程阻塞在 recv(),不表示整个进程的所有线程都停止了。其他可运行线程仍然可以获得 CPU。

还要区分两件事:线程在内核等待条件满足,和线程运行一个空循环不断检查条件。前者通常会让出 CPU;后者可能持续消耗 CPU。看见程序"没往下走",不能直接判断它正在占满处理器。


二、阻塞与非阻塞:没数据时怎么办

2.1 阻塞 I/O:把等待交给这一次调用

在没有显式启用非阻塞模式、没有其他特殊选项的普通套接字上,接收通常按阻塞方式工作。应用调用 recv() 后,如果暂时没有可接收数据,就在这次调用中等待。

正常取得数据时,内核完成本次数据传递,调用返回实际字节数,应用才继续处理。

不过,"阻塞接收"不等于"保证读满指定长度"。请求最多读 1024 字节,当前有 30 字节可用,普通接收可以返回 30。调用也可能因为对端关闭、信号或错误等条件结束,不能理解成只能等到业务消息完整才返回。Linux recv(2) 手册

阻塞方式的优势是控制流程直接:调用、取得结果、处理结果。对连接数量有限、任务可由独立线程承担的场景,它完全可以是合理选择。问题出在等待方式与程序结构不匹配,而不是"阻塞"两个字本身代表落后。

2.2 非阻塞 I/O:暂时没有数据就返回

把套接字设置为非阻塞后,如果当前没有数据且操作会等待,接收调用通常返回 -1,并把 errno 设置为 EAGAIN 或 EWOULDBLOCK。

注意返回值与错误码的层次:

text 复制代码
函数返回值:-1
errno:EAGAIN 或 EWOULDBLOCK
含义:现在做不了,需要稍后重试

不是 recv() 直接返回了一个名为 EAGAIN 的正整数,也不是连接因此一定坏了。

等数据真正可读后,再次调用 recv(),仍要完成这次读取的数据拷贝并返回结果。非阻塞改变了"当前不能完成时如何处理",没有把成功读取自动变成后台异步任务。

"立即返回"也只是强调不等待相应的就绪条件,不应理解成耗时严格为零,或具备硬实时保证。系统调用、拷贝和调度都可能有成本。

2.3 非阻塞不等于必须忙轮询

最直观的写法,是在循环中不断尝试:

text 复制代码
尝试读取 → 暂无数据 → 再尝试 → 暂无数据 → 再尝试......

这个循环如果没有等待策略,会反复进入内核,消耗 CPU,却没有产生业务进展。

加入 sleep() 可以降低空转,但引入新的权衡:睡得久,响应延迟增加;睡得短,探测开销仍然较多。比如每 200 毫秒检查一次,即使数据刚好在睡眠开始后到达,也要等到下一次检查;这只是额外等待的大致量级,真实调度可能更晚。

更常见的服务器组织方式是:非阻塞套接字配合就绪通知,再在收到通知后读写。 因此,不应把"非阻塞 I/O"和"高频空循环"当作同义词。


三、I/O 多路复用:把多个连接放到一起等待

3.1 一个一个等,为什么容易挡住别人

假设同一个线程按 A、B、C 的顺序处理三个连接:A 没数据,B、C 已经有数据。如果首先对 A 执行阻塞读取,这个线程就可能无法及时走到 B、C。

把三个连接都改成非阻塞,再逐个尝试,确实能避免卡在 A 上,但连接数量一多,大量尝试可能仍得到"暂时没有数据"。

多路复用提供另一种组织方式:告诉内核"我关心这一批描述符的可读或可写状态",然后集中等待。select()、poll()、epoll 都属于这一类机制,但它们管理集合和返回事件的方法不同。

这里的重点是"等多个",不是"完全不等"。没有事件时,调用 select()、poll() 或 epoll_wait() 的线程仍然可以阻塞;也可以根据超时参数立即返回或定时返回。

3.2 等待返回之后,还需要读取

概念流程如下:

text 复制代码
登记关注的描述符与事件
        ↓
等待就绪通知
        ↓
找到本次可处理的连接
        ↓
调用 read / recv,取得数据
        ↓
解析并处理业务

就绪等待接口不会因为"报告可读",就把应用协议报文填入你的业务缓冲区。真正取得内容,仍需要后续读取。select(2) 手册也将其明确描述为同步 I/O 多路复用。Linux select(2) 手册

这解释了一个常见问题:既然多路复用可能阻塞,为什么还常常配合非阻塞套接字?因为等待和读取是两个动作。状态可能变化,其他执行者可能取走数据,一次通知后多读几次也可能把当前数据读空。非阻塞模式能让这些读取以"暂不可读"收尾,避免事件线程意外卡住。

3.3 可读事件也可能指向结束状态

如果流连接的对端关闭发送方向,已经到达的数据全部读完后,下一次接收会返回 0。对就绪机制来说,这也是读操作无需继续等待的一种状态。

所以事件循环不能只写"收到可读通知就一定有业务数据"。收到通知后,必须调用读取函数,并根据真实返回值作出判断。事件告诉你值得去看,读取结果告诉你实际发生了什么。


四、信号驱动与异步 I/O:通知的是就绪还是完成

4.1 信号驱动 I/O:可以读的时候提醒我

信号驱动 I/O 的基本思路是:应用配置相应的通知机制,然后继续执行其他工作;当 I/O 状态满足条件时,系统通过信号等机制告知应用。

在 Linux 套接字的传统信号驱动方式中,会涉及 O_ASYNC、信号接收者设置,以及通常使用的 SIGIO。仅仅给文件描述符设置 O_NONBLOCK,并不会自动开启这些通知。Linux socket(7) 手册

收到就绪通知后,应用仍需要发起读取。因此,在这里采用的经典五模型分类中,它仍属于同步 I/O。

信号还有自己的处理约束。普通信号可能合并,不能把一次信号机械地当成一条业务消息;处理函数中也不能随意调用不满足异步信号安全要求的函数。入门时先理解通知语义,不必为了演示信号而把全部业务逻辑塞进处理函数。Linux signal-safety(7) 手册

4.2 异步 I/O:先提交操作,之后取得完成结果

异步接收的概念流程则是:应用提交一个操作,说明读什么、读到哪个缓冲区;提交被接受后,操作可以继续在后台推进;应用稍后通过完成通知或结果查询取得状态。

关键差别是:通知关联的是已经提交的操作完成了,而不只是"现在有条件再来调用一次读取"。

异步接口的提交动作本身,也有"提交成功或失败"的即时结果。提交成功不等于数据操作成功,最终完成结果仍可能是错误或少于请求长度的字节数。

在完成之前,应用还要保证缓冲区和相关请求对象满足接口规定的生命周期,不能提交后立刻释放或随意改写。异步方式把控制流拆开了,同时也把资源管理变得更重要。

4.3 不要把抽象模型等同于某一种实现

教学图常常把异步过程统一画在"系统侧",但这不是对每个 API 内部实现的承诺。Linux 上的 POSIX AIO 在 glibc 中可通过用户态线程实现;它与 Linux 原生 AIO、io_uring 也不是同一个接口家族。不能因为图上写了异步,就推断所有工作都由某个内核函数直接完成。Linux aio(7) 手册

同理,框架给你一个回调,并不能单凭回调外观判断底层是信号驱动、多路复用还是异步操作完成机制。先确定讨论的是底层 I/O、库接口,还是业务任务,再使用"同步""异步"这两个词。


五、同步、异步、阻塞、非阻塞怎么区分

5.1 两组词回答的问题不同

在本文的套接字 I/O 语境下,可以先问两个问题:

问题一:这次操作暂时不能取得结果时,调用线程要不要留在调用中等待?

这个问题帮助区分阻塞和非阻塞。

问题二:实际数据操作由本次调用完成并交回结果,还是先提交操作、之后另行获得完成结果?

这个问题帮助区分同步 I/O 与异步 I/O。

同步非阻塞完全成立:一次 recv() 现在没有数据,返回 -1/EAGAIN;下一次有数据,就在该次调用中读取并返回。第一次返回得快,并不意味着后台留着一个自动帮你完成的接收请求。

5.2 五种模型放在同一张表里

模型 暂时没有数据时 数据如何进入应用缓冲区 关键认识
阻塞 I/O 等在接收调用中 本次读取调用完成传递 控制流程简单
非阻塞 I/O 返回暂不可读,之后重试 成功的读取调用完成传递 快速失败不等于异步完成
I/O 多路复用 集中等待多个描述符 就绪后另行调用读取 通知就绪,不代替读写
信号驱动 I/O 配置通知后继续工作 收到信号后另行读取 信号提示就绪
异步 I/O 提交后可继续工作 已提交的操作在后台推进 另行取得完成结果

这张表描述的是经典模型的关键差异,不表示五种技术在工程上必须互斥使用。非阻塞描述符经常与多路复用组合;一个应用也可以同时使用不同类型的 I/O 接口。

5.3 为什么不建议硬背"四象限"

"同步一定不返回,异步一定立即返回"会把同步非阻塞排除掉,也无法解释异步接口的提交失败。

反过来,异步操作的调用者也可以选择等待某个完成结果。这个等待行为,并不会把此前提交的操作机制改写为普通同步读取。

还有另一种常见的"同步":线程之间用锁、条件变量、信号量协调执行顺序。那是在讨论并发协作关系,与这里的 I/O 结果交付方式不是同一分类维度。读到"同步"时,先找它的主语和语境,比背口号更可靠。


六、fcntl:正确打开 O_NONBLOCK

6.1 先获取,再按位添加,再写回

fcntl() 可以执行多种描述符控制操作,第三个参数的含义随命令变化。设置非阻塞模式时,我们关注的是文件状态标志:

cpp 复制代码
#include <fcntl.h>
int fcntl(int fd, int cmd, ...);

F_GETFL 取得当前状态,F_SETFL 更新可修改的状态位。不要与管理描述符标志的 F_GETFD/F_SETFD 混淆,后者常见用途是管理 FD_CLOEXEC。

实现一个可以检查结果的辅助函数,保存为 nonblock.hpp,后面的两个实验共用这个头文件:

cpp 复制代码
#pragma once
#include <fcntl.h>

// Returns 0 on success and -1 on failure; errno is left for the caller.
inline int SetNonblocking(int fd, int* old_flags = nullptr) {
    const int flags = ::fcntl(fd, F_GETFL);
    if (flags == -1) return -1;
    if (::fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) return -1;
    if (old_flags != nullptr) *old_flags = flags;
    return 0;
}

这里有两个关键点。

第一,使用 flags | O_NONBLOCK,保留已有状态。直接把 O_NONBLOCK 作为完整状态写回,可能清掉其他可修改的状态位。

第二,两次调用都要检查。第一次读取状态成功,不代表第二次设置必然成功。函数返回 -1 后,调用者应处理错误,而不是继续以为描述符已经处于非阻塞模式。Linux F_GETFL/F_SETFL 手册

如果只想清掉非阻塞位,常见形式是先获取最新状态,再使用 flags & ~O_NONBLOCK。如果教学程序临时修改自己独占管理的状态,也可以记录原状态,在退出时恢复。

6.2 标志可能被多个描述符共享

O_NONBLOCK 关联的是打开文件描述(open file description),不是每个整数编号各自持有的一份独立设置。通过 dup() 或 fork() 等共享同一打开文件描述时,一个描述符改变状态,另一个也会观察到相应变化。

因此,不要随手修改借来的描述符,再以为影响只在当前函数里。多线程环境中,"获取旧值---修改---写回"也不自动构成防竞争事务;最好明确由哪个组件负责配置状态。

对于套接字,MSG_DONTWAIT 可以让某次 recv() 或 send() 按非阻塞方式操作,而不持久修改共享的 O_NONBLOCK 状态。是否采用它,要看接口和程序设计,不能拿它替换所有文件类型上的非阻塞设置。

6.3 文件类型也会影响效果

不能把"描述符是非阻塞的"理解成"通过它访问任何设备都绝不等待"。Linux 对普通文件、块设备上的 O_NONBLOCK 有不同于管道、终端和套接字的限制;它不能把普通磁盘文件读取自动变成真正的异步磁盘请求。Linux open(2) 手册

接下来选择标准输入和本地流套接字,是为了清晰观察暂不可读、读取成功与 EOF,而不是借普通磁盘文件证明一切 I/O 都能立即返回。


七、读返回值:五类结果不能混在一起

7.1 先看 n,再决定要不要看 errno

对于本文的读取实验,缓冲区长度始终大于 0,可以按下表处理:

返回情况 含义 常见动作
n > 0 本次取得 n 个字节 处理这 n 个字节
n == 0 文件/管道到达 EOF;流套接字接收方向结束 结束读取或推进连接状态
n == -1,EINTR 本次调用被信号中断 按退出策略重试或停止
n == -1,EAGAIN/EWOULDBLOCK 目前不能继续读取 稍后重试或重新等待就绪
n == -1,其他错误 读取失败 记录并按错误类型处理

errno 只在返回值指示错误时才有解释价值。一次成功调用通常不会帮你把旧错误码清零,所以不能读到正数后,再用残留的 errno 宣布读取失败。Linux errno(3) 手册

7.2 EOF 不是"现在没数据"

对于管道,写端还开着但当前没有数据,非阻塞读可能返回 -1/EAGAIN;所有写端都关闭并且剩余数据被读完后,读才返回 0。

对于 TCP 等流套接字,先把已经到达的数据读完,再看到对端发送方向的正常结束。接收返回 0,并不必然说明自己再也不能向对端发送;半关闭允许两个方向的生命周期不同。

也不能把"返回 0 就是断连"无条件推广到 UDP:零长度数据报本身就是合法消息。另一个容易遗漏的条件是读取长度为 0 的调用;它也不能用作判断连接结束的可靠探针。本文的表格建立在正长度读取、并区分文件类型的前提下。Linux read(2) 手册

7.3 为什么同时比较两个错误名

Linux 上 EAGAIN 与 EWOULDBLOCK 通常具有相同数值,但可移植代码不应依赖二者永远相等。直接写:

cpp 复制代码
if (errno == EAGAIN || errno == EWOULDBLOCK) {
    // 稍后重试,不把它当成连接损坏。
}

这也避免了把错误码硬编码成数字,例如写 errno == 11。名字表达语义,数字只反映特定环境的定义。

7.4 字节流不是自动补零的字符串

read() 和 recv() 返回的是有效字节数,不保证给数据补 \0。若直接执行 printf("%s", buffer),却没有正确终止字符串,可能越过有效内容读取内存。

如果只演示文本,可以预留一个字节并手动补零;如果要忠实输出收到的任意字节,按长度调用 fwrite() 更直接。后面的标准输入实验采用后一种方式,也能保留内嵌的 NUL 字节。


八、实验一:非阻塞读取标准输入

8.1 这个实验要观察什么

第一个实验不依赖网络服务。程序临时把标准输入设置为非阻塞:有数据就原样输出;没数据就提示一次并短暂休息;遇到 EOF 正常退出;正常退出或捕获 SIGINT/SIGTERM 后恢复原来的状态标志。

代码文件为 code/stdin_nonblock.cpp,与前面的 code/nonblock.hpp 放在一起。

cpp 复制代码
#include "nonblock.hpp"
#include <cerrno>
#include <cstdio>
#include <csignal>
#include <unistd.h>
#include <time.h>

namespace {
volatile sig_atomic_t stop_requested = 0;
void OnStop(int) { stop_requested = 1; }

void PauseBriefly() {
    timespec delay{0, 200000000}; // 200 ms, teaching polling only
    while (!stop_requested && ::nanosleep(&delay, &delay) == -1) {
        if (errno != EINTR) break;
    }
}
}

int main() {
    struct sigaction action{};
    action.sa_handler = OnStop;
    ::sigemptyset(&action.sa_mask);
    if (::sigaction(SIGINT, &action, nullptr) == -1 ||
        ::sigaction(SIGTERM, &action, nullptr) == -1) {
        std::perror("sigaction");
        return 1;
    }

    int original_flags = 0;
    if (SetNonblocking(STDIN_FILENO, &original_flags) == -1) {
        std::perror("SetNonblocking");
        return 1;
    }

    int result = 0;
    bool announced_wait = false;
    while (!stop_requested) {
        char buffer[1024];
        const ssize_t n = ::read(STDIN_FILENO, buffer, sizeof(buffer));
        if (n > 0) {
            announced_wait = false;
            // Treat input as bytes, including any embedded NUL.
            if (std::fwrite(buffer, 1, static_cast<size_t>(n), stdout)
                    != static_cast<size_t>(n) || std::fflush(stdout) == EOF) {
                std::perror("stdout");
                result = 1;
                break;
            }
        } else if (n == 0) {
            std::fprintf(stderr, "[eof]\n");
            break;
        } else {
            const int error = errno;
            if (error == EINTR) continue;
            if (error == EAGAIN || error == EWOULDBLOCK) {
                if (!announced_wait) {
                    std::fprintf(stderr, "[wait] no input yet\n");
                    announced_wait = true;
                }
                PauseBriefly();
                continue;
            }
            errno = error;
            std::perror("read");
            result = 1;
            break;
        }
    }

    // This teaching program exclusively owns changes to this status word.
    if (::fcntl(STDIN_FILENO, F_SETFL, original_flags) == -1) {
        std::perror("restore flags");
        result = 1;
    }
    return result;
}

代码里的信号处理函数只设置 sig_atomic_t 标志,具体输出和恢复工作放回正常控制流。EINTR 分支重新进入循环后,也会先检查停止标志。

这个示例只改变输入的等待方式,fwrite()、fflush() 和 200 毫秒休眠仍可能等待。因此,不要把它描述成"整个程序的每个操作都是非阻塞的"。它用于观察行为,不是完整的高性能事件服务器。

8.2 编译与交互运行

在 Linux 终端进入文章包的 code/ 目录:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 \
    stdin_nonblock.cpp -o stdin_nonblock
./stdin_nonblock

没有输入时,标准错误会显示一次:

text 复制代码
[wait] no input yet

输入一行文字并回车,程序把读到的字节写到标准输出。一般终端本身也会回显键盘输入,所以同一段文字可能看起来出现两次:一次来自终端回显,一次来自程序输出。

默认规范模式下,终端通常按行交付输入。仅仅设置 O_NONBLOCK 不会关闭规范模式,也不会让每次按键都立刻成为一次可读取输入。没有按回车就看到"暂无输入",并不说明设置失败。Linux termios(3) 手册

在空行处按 Ctrl+D,可让这次终端读取得到 EOF;输入了文字再按它,可能先交付已有文字,需要再次在空行按下才能观察退出。Ctrl+C 会请求停止,程序离开循环后恢复输入状态。

恢复逻辑覆盖正常控制流,不覆盖 SIGKILL、进程崩溃等无法执行收尾的情况。实验时让这个程序独占相关输入状态,避免其他执行者同时修改同一个打开文件描述。

8.3 用延迟管道排除终端回显的干扰

bash 复制代码
{ sleep 1; printf 'hello\n'; sleep 1; printf 'world\n'; } \
    | ./stdin_nonblock

写端仍开着、尚未写入时,可观察到暂不可读;每次写入后,程序读到对应字节;写端结束且数据读完后,显示 [eof] 并退出。

要把数据输出和状态提示分开,可以临时将标准错误重定向到终端之外,或者按下面的方式单独查看标准输出:

bash 复制代码
printf 'hello\n' | ./stdin_nonblock 2>/dev/null

这里应只得到一行 hello。程序不会给输入额外增加换行,也不会把状态文字混进标准输出。

8.4 为什么一定要处理 n == 0

如果把 0 和普通成功都送进"输出字符串"的分支,EOF 之后就可能不断得到 0、不断打印空内容,循环永远停不下来。

可以用立即结束的输入验证:

bash 复制代码
./stdin_nonblock </dev/null

正确行为是显示一次 [eof] 并结束。这里用 /dev/null 检查的是 EOF 分支,不是在证明普通文件具备异步读取能力。


九、实验二:用 socketpair 观察完整状态变化

9.1 为什么选一对本地流套接字

终端实验容易受到规范模式和回显影响。socketpair(AF_UNIX, SOCK_STREAM, ...) 可以在本机创建一对已经连接的流套接字,让我们直接控制另一端何时发送、何时关闭写方向。

它是 UNIX 域流套接字,不是 TCP 连接,也没有经过 IP 网络。这里用它验证非阻塞接收、字节流拆分和 EOF 的行为,不用它推断 TCP 报文分段、拥塞控制或真实网络时延。

code/socketpair_nonblock.cpp 的操作顺序是:

  1. 把接收端设为非阻塞,先读一次,确认得到暂不可读。
  2. 另一端发送 hello,接收端每次最多读取 2 字节。
  3. 读完后再次收到 EAGAIN/EWOULDBLOCK,此时对端仍未关闭。
  4. 关闭对端写方向,再次读取,确认返回 0。

把下面的完整代码保存为 socketpair_nonblock.cpp,与 nonblock.hpp 放在同一目录。这里只把接收端 fd[0] 设为非阻塞;发送端 fd[1] 使用默认阻塞模式,发送固定的 5 字节教学数据。

cpp 复制代码
#include "nonblock.hpp"
#include <cerrno>
#include <cstdio>
#include <sys/socket.h>
#include <unistd.h>

int main() {
    int fd[2];
    if (::socketpair(AF_UNIX, SOCK_STREAM, 0, fd) == -1) {
        std::perror("socketpair");
        return 1;
    }
    auto finish = [&](int status) {
        ::close(fd[0]);
        ::close(fd[1]);
        return status;
    };
    if (SetNonblocking(fd[0]) == -1) {
        std::perror("SetNonblocking");
        return finish(1);
    }

    char buffer[2];
    ssize_t n = ::recv(fd[0], buffer, sizeof(buffer), 0);
    if (!(n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK))) {
        std::fprintf(stderr, "expected initial would-block\n");
        return finish(1);
    }
    std::puts("1. empty: EAGAIN/EWOULDBLOCK");

    const char message[] = "hello";
    size_t sent = 0;
    while (sent < sizeof(message) - 1) {
        n = ::send(fd[1], message + sent, sizeof(message) - 1 - sent,
                   MSG_NOSIGNAL);
        if (n > 0) sent += static_cast<size_t>(n);
        else if (n == -1 && errno == EINTR) continue;
        else {
            std::perror("send");
            return finish(1);
        }
    }

    size_t total = 0;
    for (;;) {
        n = ::recv(fd[0], buffer, sizeof(buffer), 0);
        if (n > 0) {
            total += static_cast<size_t>(n);
            std::printf("2. recv %zd bytes: %.*s\n", n,
                        static_cast<int>(n), buffer);
        } else if (n == -1 && errno == EINTR) {
            continue;
        } else if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
            std::printf("3. drained: EAGAIN/EWOULDBLOCK, total=%zu\n", total);
            break;
        } else {
            std::fprintf(stderr, "unexpected receive state\n");
            return finish(1);
        }
    }
    if (total != sizeof(message) - 1) return finish(1);
    if (::shutdown(fd[1], SHUT_WR) == -1) {
        std::perror("shutdown");
        return finish(1);
    }
    do {
        n = ::recv(fd[0], buffer, sizeof(buffer), 0);
    } while (n == -1 && errno == EINTR);
    if (n != 0) {
        std::fprintf(stderr, "expected EOF\n");
        return finish(1);
    }
    std::puts("4. peer write closed: recv=0 (EOF)");
    return finish(0);
}

编译和运行:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 \
    socketpair_nonblock.cpp -o socketpair_nonblock
./socketpair_nonblock

本次 Linux 环境中的实测输出:

text 复制代码
1. empty: EAGAIN/EWOULDBLOCK
2. recv 2 bytes: he
2. recv 2 bytes: ll
2. recv 1 bytes: o
3. drained: EAGAIN/EWOULDBLOCK, total=5
4. peer write closed: recv=0 (EOF)

9.2 一次发送,不保证对应一次接收

这里发送 5 字节,接收缓冲区只有 2 字节,所以出现多次接收。真实流连接中,拆分和合并还会受到到达时序、读取时机和缓冲区大小等因素影响。

不要把某次演示中的 2、2、1 顺序写成所有环境都必须遵守的协议。可靠的不变量是:单次不超过接收缓冲区容量,字节顺序保持一致,成功读取的总长度应为 5。

如果应用协议定义"一行是一条消息",那就要自己累积字节、查找换行;如果采用长度字段,也要等完整长度到齐。非阻塞模式不会替你解决消息边界问题。

9.3 最值得记住的是最后两步

读完 hello 后,另一端还可以继续发送,所以此时返回暂不可读。只有在另一端结束发送、并且已有数据已经读尽后,接收端才观察到 EOF。

空缓冲区是暂时状态,流的结束是方向状态。 把这两者区分开,才不会在连接暂时安静时误删连接,也不会在连接已经结束后继续无意义地读。


十、从轮询走向事件循环

10.1 把"什么时候再读"交给就绪机制

标准输入例子用 200 毫秒休眠,只是为了让暂不可读容易观察。面对大量连接,更合理的基本组织是:先等待事件,再处理当前能够推进的连接,最后回到等待阶段。

概念伪代码如下,省略了具体接口和连接管理细节:

text 复制代码
等待一批就绪事件
对每个可读连接:
    尝试非阻塞读取
    得到字节 → 累积并解析,按调度预算决定是否继续读
    暂不可读 → 保留连接,回到等待机制
    得到 EOF → 标记读方向结束,按协议决定后续行为
    遇到错误 → 分类处理并释放需要释放的资源

如果采用边沿触发的 epoll,通常需要非阻塞读写,并把当前可处理数据消费到 EAGAIN。如果为了公平性提前停止,就必须另外维护待处理队列等调度安排,不能假定一定还会获得一个新边沿。水平触发下也要避免单个繁忙连接长期独占事件线程。Linux epoll(7) 手册

10.2 写操作也有"暂时做不了"

对非阻塞套接字,发送缓冲区空间不足时,send() 也可能返回 EAGAIN/EWOULDBLOCK;一次成功调用也可能只接收部分待发送字节。

正确思路是保存"还没发送的那一段"及其偏移,等待可写后继续,而不是重发整个应用缓冲区。否则可能重复发送已经被接受的前缀。

并且,send() 成功只说明相应字节被本机发送路径接受,不等于对端业务代码已经收到并处理。业务确认需要协议自己定义。Linux send(2) 手册

10.3 模型解决调度,协议解决含义

I/O 模型告诉我们如何组织等待和读写,应用协议告诉我们字节意味着什么。两者不能互相代替。

一个事件循环即使没有阻塞读,如果每次收到数据都在回调里执行数秒的计算,其他连接仍然会被拖慢。设计服务器时,要一起考虑 I/O 等待、任务耗时、缓冲区容量、连接生命周期和处理公平性。

到这里,再看 select、poll、epoll,就不会只看到一组新函数:它们是在帮助我们安排"下一步值得处理哪个描述符"。


十一、常见误区与自测

11.1 八个容易说错的结论

容易说错 更准确的表达
非阻塞就是异步 非阻塞读取通常仍在本次成功调用中交付数据
非阻塞必须一直轮询 可以与多路复用、就绪通知组合
select 不会阻塞 是否等待取决于事件状态和超时设置
epoll 已经把业务数据读好了 就绪通知之后通常还要调用读取函数
EAGAIN 说明连接坏了 它通常表示当前没有可处理条件
所有 read 返回 0 都是断连 要结合长度参数和文件/套接字类型判断
设置非阻塞不会影响别的 fd 共享打开文件描述时,状态位也共享
异步提交成功就代表读取成功 还要检查后续完成结果

11.2 自测一:有可读事件,却读到 EAGAIN

题目: 多路复用报告可读,随后非阻塞读取返回 -1/EAGAIN,是否一定是系统出错?

答案: 不是。通知和实际读取之间状态可能改变,数据也可能已经被其他执行者取走。保持非阻塞、正确处理暂不可读,回到合适的等待或调度路径即可。不要因为一个事件就跳过返回值检查。

11.3 自测二:一个线程阻塞,其他线程能运行吗

题目: 线程 A 阻塞在普通 recv(),线程 B 是否也必须停止?

答案: 不必。这里被阻塞的是调用线程。如果线程 B 可运行且没有被其他锁或依赖条件限制,仍可以被调度执行。需要结合程序中的共享锁与任务关系判断实际影响。

11.4 自测三:读完数据又得到 EAGAIN,要关连接吗

题目: 一次循环已经读到 100 字节,下一次得到 EAGAIN,能否直接判定对端掉线?

答案: 不能。它只表示目前没有更多可读数据。保留已读内容和连接状态,等待后续事件。流接收方向的正常结束,应通过正长度接收返回 0 等条件识别。

11.5 自测四:dup 以后只修改一个 fd

题目: b = dup(a) 后,对 a 设置 O_NONBLOCK,通过 b 读取时会受影响吗?

答案: 会,因为它们引用同一个打开文件描述,共享相关文件状态标志。这不同于为每个整数描述符建立独立配置。

11.6 自测五:异步任务完成前能复用缓冲区吗

题目: 异步读取提交成功后,立即释放目标缓冲区,等回调来了再申请一块新的,可以吗?

答案: 不能这样假设。已提交操作关联的是原缓冲区,需要遵守接口的生命周期约束。提交成功不是缓冲区已经不再被使用的证明;错误、取消和完成的资源回收时机都要按具体 API 处理。


写在最后

理解五种 I/O 模型,最有用的不是按顺序背出五个名字,而是在代码面前能问出三个具体问题:现在是在等数据,还是在取数据?通知的是就绪,还是某次操作完成?这次返回值到底表示哪一种状态?

阻塞读取把等待放在调用中;非阻塞读取让应用在暂时做不了时先拿回控制权;多路复用集中等待多个描述符;信号驱动把就绪信息送给应用;异步 I/O 则把已提交操作的完成结果另行交付。

把这些区别带回 fcntl() 和读取循环,很多细节就有了理由:为什么先取旧状态再按位添加,为什么 EAGAIN 不该被当作断连,为什么 EOF 必须单独处理,为什么成功字节数不能等同于完整业务消息。下一步学习多路复用接口时,我们就能把注意力放在事件集合与调度机制上,而不是继续被名词困住。

相关推荐
程序猿乐锅1 小时前
万字详解Single-flight(单飞模式)
java·开发语言·分布式·后端·学习·single-flight
无言听风雨丶1 小时前
Linux 容器本质探秘:Cgroup 与 Namespace 深度解析与实战
linux·运维·服务器·kubernetes
万联WANFLOW1 小时前
CVE-2026-98432 等漏洞自查:AI 查出的 Linux、Windows 漏洞,运维怎么确认受不受影响
linux·运维·windows·网络安全
Java后端的Ai之路1 小时前
【AIAgent开发】-Agent工具封装成CLI(纯干货!!!)
java·javascript·人工智能·agent·cli
OPEN-F1 小时前
Ubuntu 自动登录后 GNOME 远程桌面连不上?一次登录钥匙环锁定的排查与永久修复
linux·运维·ubuntu
硅基手札2 小时前
【Linux 内核专栏 19】20 · 嵌入式子系统 — GPIO·pinctrl·clock·regulator
linux·运维·单片机·linux内核·嵌入式开发·调试定位
AntsData2 小时前
用 12K+ 条亚马逊评论信号,定位机械键盘下一版该改什么
java·大数据·计算机外设
默|笙2 小时前
【Linux网络】传输层协议TCP(3)滑动窗口、流量控制
linux·网络·tcp/ip
code2cat2 小时前
【随笔】连续批处理:长短请求怎样在迭代之间补位
java·人工智能·后端·大语言模型·vllm