🔥 星光编译者 · 个人主页
📚 学习专栏: 《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 组织方式。
不过,真正容易把人绕进去的,并不是五个名字,而是"等待""就绪""完成"被混在了一起。先把一次接收拆开,再比较模型,概念就会清楚得多。
文章目录
- 一次接收,先分清两个阶段
- 阻塞与非阻塞:没数据时怎么办
- [I/O 多路复用:把多个连接放到一起等待](#I/O 多路复用:把多个连接放到一起等待)
- [信号驱动与异步 I/O:通知的是就绪还是完成](#信号驱动与异步 I/O:通知的是就绪还是完成)
- 同步、异步、阻塞、非阻塞怎么区分
- [fcntl:正确打开 O_NONBLOCK](#fcntl:正确打开 O_NONBLOCK)
- 读返回值:五类结果不能混在一起
- 实验一:非阻塞读取标准输入
- [实验二:用 socketpair 观察完整状态变化](#实验二:用 socketpair 观察完整状态变化)
- 从轮询走向事件循环
- 常见误区与自测
一、一次接收,先分清两个阶段
1.1 数据到达内核,不等于程序已经拿到数据
假设客户端发送了一段字节流,服务端要把它读到自己的 buffer 中。为了理解模型,可以把普通套接字接收简化成两个阶段:
- 等待数据可用:数据经过网络与协议栈处理,进入套接字的内核接收缓冲区,满足本次接收的条件。
- 取回数据:执行接收操作,把可交付的数据从内核缓冲区复制到调用者提供的用户缓冲区,返回本次读取结果。

例如,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 的操作顺序是:
- 把接收端设为非阻塞,先读一次,确认得到暂不可读。
- 另一端发送
hello,接收端每次最多读取 2 字节。 - 读完后再次收到
EAGAIN/EWOULDBLOCK,此时对端仍未关闭。 - 关闭对端写方向,再次读取,确认返回 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 必须单独处理,为什么成功字节数不能等同于完整业务消息。下一步学习多路复用接口时,我们就能把注意力放在事件集合与调度机制上,而不是继续被名词困住。