今天翻到一篇不错的技术分享,看完之后自己也琢磨了一下,把思路梳理记录下来。
请君浏览
二、五种 IO 模型全景——一次钓鱼讲透- 2.1 阻塞 IO(Blocking IO)
- 2.2 非阻塞 IO(Non-blocking IO)
- 2.3 信号驱动 IO(Signal-driven IO)
- 2.4 IO 多路转接(IO Multiplexing)
- 2.5 异步 IO(Asynchronous IO)
- 2.6 五种模型横向对比——一张表看清本质
- 6.1
fcntl(fd, F_SETFL, O_NONBLOCK)直接覆盖了读写权限 - 6.2 非阻塞
read()返回 -1 时没有区分"无数据"和"真错误" - 6.3 轮询循环中没有 sleep,导致 CPU 满载
- 6.4 混淆"同步通信"和"进程同步"
- 6.5 误以为
select/epoll是异步 IO
前言
在前面几篇文章中,我们已经掌握了 UDP 和 TCP 的 Socket 编程基础、网络协议分层原理,以及 IP 层和链路层的核心概念。这些都是在"IO 能正常工作"的前提下进行的——recvfrom() 一调,数据就来了。但你有没有想过一个根本坑:当数据还没到达时,recvfrom() 到底在干什么?它是在原地傻等,还是可以先去干别的事?
这个坑的答案,就是 Linux 的 IO 模型。五种 IO 模型不只是面试八股文,它们决定了你的服务器是能同时处理 10 个连接还是 10000 个连接,是让 CPU 空转浪费还是高效利用每一毫秒。Nginx 之因此比 Apache 快,Node.js 之因此能用单线程处理高并发,Redis 之因此能单线程跑到每秒十万 QPS——背后的秘密都藏在这五种 IO 模型里。
本文将从 IO 的本质出发,用钓鱼的故事串联五种 IO 模型,理清"同步/异步"和"阻塞/非阻塞"这两对最易混淆的概念,最终用 fcntl 实战带你把默认的阻塞 IO 改成非阻塞模式。读完本文,你将可以在面试中清晰地画出五种 IO 模型的流程图、解释它们之间的本质区别,并亲手写出非阻塞 IO 的 C 代码。
一、IO 的本质——等待与拷贝
在聊五种 IO 模型之前,务必先搞清楚一个底层事实:任何 IO 操作,都由两个截然不同的阶段组成。
1.1 IO 的两个阶段
一个典型的 recvfrom() 调用,背后发生了两件事:
阶段描述谁在干活耗时占比阶段一:等待等待网卡收到数据,数据到达内核的 Socket 接收缓冲区网卡 + DMA + 内核协议栈90%+阶段二:拷贝内核将缓冲区中的数据拷贝到用户进程的 buffer(recvfrom 的 buf 参数)CPU 执行 copy_to_user<10%
用户进程 内核
| |
|--- recvfrom(buf, len) ---->| ← 系统调用发起
| |
| 阶段一:等待 |
| [数据还没到...] | ← 内核等网卡把数据写入 Socket 缓冲区
| [等待...等待...] |
| |
| 阶段二:拷贝 |
| 理解了这个框架,五种 IO 模型就不再是五个需要死记硬背的名词,而是一条清晰的"等待策略"光谱——从被动等待到主动轮询,从单个等待到批量等待,从部分托管到完全托管。每一种模型都是为了在特定场景下"让等待的代价最小化"。
---
### 二、五种 IO 模型全景——一次钓鱼讲透
周末你去河边钓鱼,同时架了五根竿,每根用了不同的策略——
**第一根**你抛下去就死盯着漂,寸步不离,鱼不咬钩人不动。**第二根**架好你就回帐篷刷手机,隔几分钟跑出来瞄一眼,没动静继续回去刷。**第三根**挂了铃铛,抛竿后你直接进帐篷睡觉,铃响才冲出来收竿。**第四根**最有意思——你坐在五根竿正中间的小马扎上,眼睛来回扫,哪根动就收哪根。**第五根**你压根没碰,雇了旁边的钓友帮你看,说好"钓三条送我家",然后你就回家看电视了。
五根竿,五种策略,正好对应 Linux 的五种 IO 模型。一根一根来看——

#### 2.1 阻塞 IO(Blocking IO)
第一根竿——死盯。你抛竿后就钉在河边,鱼不咬钩人不走。进程也是这个脾气:`recvfrom()` 一调,如果内核的 Socket 接收缓冲区里没有数据,进程立刻被**挂起(sleep)**,CPU 让给其他进程。数据到了、`copy_to_user()` 拷贝完成,`recvfrom()` 才慢悠悠地返回,进程继续往下走。整个过程里进程除了"等"什么都没做。
进程 内核
| |
|--- recvfrom(sockfd, buf) -->|
| | 数据未就绪——进程挂起
| (进程休眠......) | 等待数据到达网卡
| | 内核协议栈处理
| | 数据写入 Socket 接收缓冲区
| |
| | 数据就绪——copy_to_user()
| 阻塞 IO 是 Linux 中所有套接字的默认行为。 你之前写的每一个 recvfrom()、accept()、read(),只要没特殊设置,就都是阻塞的。这不是 bug——对于绝大多数场景,阻塞 IO 的代码最简单:“发起调用 → 等待 → 拿到结果”,程控流跟业务流程完全一致,心智负担最低。
阻塞 IO 的特点:
- 优点:代码逻辑最简单,一调用一等一拿,不需要额外机制
- 缺点:一个进程同一时刻只能伺候一个 fd。想同时处理多个连接?只能开多进程或多线程——每个执行流各自阻塞在各自的 fd 上,靠"人海战术"堆并发
- 适用场景:连接数不多、处理时间可预测的场景(命令行工具、低并发内网服务)
2.2 非阻塞 IO(Non-blocking IO)
第二根竿——来回跑。你把竿架好就回帐篷刷手机,每隔半分钟跑出来瞄一眼鱼漂,没动静?回去接着刷,半分钟后再来。反复循环,直到某一次冲出来注意到鱼已经上钩了,收竿走人。
把这个套路翻译成系统调用:先用 fcntl 给 fd 打上 O_NONBLOCK 标记,然后在一个循环里反复 recvfrom()。数据没到时,内核不挂起进程,而是立刻塞回来一个 EWOULDBLOCK(或 EAGAIN)错误码。进程一看,“哦,没数据”,扭头去干别的事——处理其他 fd、执行计算任务,过一会再回来 recvfrom() 试一次。这个过程就是轮询(Polling)。
进程 内核
| |
|--- recvfrom(sockfd, buf) --------->|
||
||
| | 数据就绪——copy_to_user()
| 非阻塞 IO 的关键变化不是"怎么等",而是"等的时候能干什么"。 阻塞 IO 一等就挂起,什么都做不了;非阻塞 IO 每次尝试都立刻返回一个答案——哪怕是"没好呢"——失败了你就可以名正言顺地去处理别的事。
**非阻塞 IO 的特点**:
- **优点**:一个进程可以在等待 IO 的间隙执行其他任务,不需要多线程
- **缺点**:轮询本质上是"忙等"——每次 `recvfrom()` 都是一次用户态 ↔ 内核态的切换。如果数据来得稀疏,99% 的轮询尝试都是白费的:“付出了系统调用的成本,却什么都没拿到”
- **适用场景**:数据频繁到达的特定场景,或对延迟极度敏感的实时系统
#### 2.3 信号驱动 IO(Signal-driven IO)
第三根竿——挂铃铛。抛竿后你在竿梢夹了个铃铛,然后钻进帐篷安心睡觉。铃铛一响,你才冲出来收竿。抛竿之后到铃响之前,你全程不在现场。
技术上,进程先通过 `sigaction()` 注册一个 `SIGIO` 信号处理函数,然后就可以继续执行其他代码了——不阻塞、不轮询。内核在后台等数据,数据就绪后给进程发 `SIGIO` 信号。进程在信号处理函数里调用 `recvfrom()`,把数据从内核拷到用户空间。
进程 内核
| |
|--- sigaction(SIGIO, handler) ---->| 注册信号处理函数
| |
| 干别的事(不会被阻塞!) | 内核异步等待数据
| | ......
| | 数据就绪——内核发送 SIGIO 信号!
||
| | copy_to_user()
| 信号驱动 IO ≠ 异步 IO,这是面试高频考点。 信号驱动只帮你做了第一阶段(等待),铃响了你还得自己跑过去收竿——第二阶段的拷贝仍然是进程自己调 recvfrom() 完成的。异步 IO 则是连竿都不碰,鱼直接送到你手上。
信号驱动 IO 在实际中用得较少,主要因为 SIGIO 信号处理时机不可控、信号处理函数只能用可重入函数、而且 epoll 在绝大多数场景下是更优选择。
2.4 IO 多路转接(IO Multiplexing)
第四根竿——一次看五根。你坐在五根竿正中间的小马扎上,眼睛来回扫:第一根没动、第二根没动、第三根漂沉了!冲过去收竿。回座位继续扫,第四根又动了!再冲过去。你不是"只盯一根竿",而是在批量监视——五根竿的动静全在你的视野里,哪根有鱼收哪根。
select() / poll() / epoll() 做的就是这件事:一次系统调用,同时把几十、几百甚至上万个 fd 交给内核监视。哪个 fd 上有数据可读了,select() 就返回,告诉你"fd_3 和 fd_7 就绪了"。然后你逐个对就绪的 fd 调用 recvfrom(),拷完数据收工。
进程 内核
| |
|--- select(nfds, &readfds, ...) ------->|
| (进程挂起,等待任意 fd 就绪) | 同时监视多个 fd
| | 等待任意一个 fd 上有数据到达
| |
||
||
| IO 多路转接和阻塞 IO 画出来很像——都在等。但本质差异在于:阻塞 IO 是"一对一",一个线程只等一个 fd;IO 多路转接是"一对多",一个线程同时等成百上千个 fd。 Nginx 能扛十万并发、Redis 能单线程跑到每秒十万 QPS,靠的就是这个模型。
**IO 多路转接的特点**:
- **优点**:单进程/单线程即可管理海量连接,是 Linux 高性能服务器的事实标准
- **缺点**:编程模型比阻塞 IO 复杂,需要维护"就绪 fd 集合"并逐个处理,学习曲线更陡
- **适用场景**:几乎所有生产级高并发网络服务器。后续文章会专门深入 `select`/`poll`/`epoll`

#### 2.5 异步 IO(Asynchronous IO)
第五根竿——雇人全包。你把鱼竿、鱼桶、抄网全部交给旁边的钓友,说清楚"钓三条,杀好洗净送我家",然后你回家看电视。整个过程你一次都没碰过鱼竿。过了一段时间门铃响了——三条鱼已经处理完毕放在门口。
进程调用 `aio_read()`,一口气把 `sockfd`、用户 `buf`、读取长度、通知方式(信号或回调)全塞给内核,然后**立刻返回**去干别的事。内核在后台包揽一切:等数据、拷数据,全部做完之后一个信号或回调通知进程——“数据已经在你 buffer 里了,直接用”。
进程 内核
| |
|--- aio_read(sockfd, buf, len, cb) --->| 进程立即返回!
| |
| 继续干别的事(没被阻塞、不需要轮询) | 内核在后台:
| | ① 等待数据到达
| | ② 数据到达后 copy_to_user()
| | ③ 拷贝完成后通知进程
| |
| 异步 IO = 两个阶段全部由内核完成。 信号驱动是"可以来读了"(第一阶段好了,第二阶段自己来),异步 IO 是"已经帮你读好了"(两个阶段全包)。判断标准就一条:数据拷贝是内核做的还是你自己调的 recvfrom()?内核做的 → 异步 IO。
异步 IO 的特点:
- 优点:CPU 利用率最高——进程完全不参与等待和拷贝,全由内核和 DMA 完成
- 缺点:Linux 原生 AIO 长期不完善,对网络 Socket 支持有限。真正成熟的方案是近几年才出现的 io_uring
- 适用场景:极高吞吐的磁盘 IO(数据库),或基于 io_uring 的高性能网络应用
2.6 五种模型横向对比——一张表看清本质
IO 模型第一阶段(等待)第二阶段(拷贝)进程在等待时CPU 利用率并发能力阻塞 IO进程自己等,挂起进程自己拷挂起(不占 CPU)低(等的时候浪费了时间)低(一个进程等一个 fd)非阻塞 IO进程轮询(反复系统调用)进程自己拷反复询问(占 CPU)低(轮询浪费 CPU)中(可以边等边干别的)信号驱动 IO内核通知进程自己拷不挂起高中IO 多路转接一次 select 等多个 fd进程自己拷(哪个好了拷哪个)挂起在 select 上高(批量等待)极高异步 IO内核全包内核全包不挂起最高极高
一句话总结:IO 多路转接是当前工业界的"版本答案"。 阻塞 IO 最简单,非阻塞 IO 太费 CPU,信号驱动和异步 IO 各有局限。select/poll/epoll 为代表的 IO 多路转接在"实现麻烦度"和"并发能力"之间取得了最佳平衡,是 Linux 高性能服务器的不二之选。
三、同步异步 vs 阻塞非阻塞——两对最易混淆的概念
如果你去面试,面试官问你"同步 IO 和阻塞 IO 有什么区别",你能答清楚吗?这两对概念经常被混用,但它们的考察维度完全不同。
3.1 同步通信 vs 异步通信
同步和异步关注的是"消息通信机制",即:你发起一个调用后,结果是主动去拿,还是被动被通知。
同步(Synchronous):
- 发出一个调用后,在没有拿到结果之前,该调用就不返回
- 一旦调用返回,返回值就已经在手里了
- 调用者主动等待调用结果
- 发出调用后,调用直接返回了,此时没有拿到结果
- 被调用者处理完后,通过状态、通知或回调函数告知调用者结果已经就绪
- 调用者被动接收通知
一个生活中的例子:去食堂打饭 vs 点外卖。 去食堂打饭是同步——你站在窗口等,师傅炒好菜端给你,你拿到菜才离开。点外卖是异步——你下单后就去干别的事了,骑手送到后打电话(通知)或按门铃(回调),你才去取餐。
3.2 阻塞调用 vs 非阻塞调用
阻塞和非阻塞关注的是"程序在等待调用结果时的状态",即:在等结果的过程中,线程还能不能干别的事。
阻塞(Blocking):
- 调用结果返回之前,当前线程被挂起(sleep)
- 调用线程只有在拿到结果之后才会返回,继续执行
- 线程在等待期间不能执行其他代码
- 不能立刻拿到结果时,调用不阻塞当前线程
- 立即返回一个状态码(如
EWOULDBLOCK) - 线程不会被挂起,可以继续执行其他代码
一个生活中的例子:排队取钱 vs ATM 取钱。 排队取钱是阻塞——你在队伍里不能离开,务必等前面的人全部办完轮到你。ATM 取钱是非阻塞——你插卡后注意到余额不足取不了,立刻拿到错误提示走人,不需要在原地等。
3.3 "同步"一词的双重身份——最容易掉进去的坑
在计算机科学中,"同步"这个词至少有两种完全不同的含义,初学者经常混淆:
上下文"同步"的含义举例同步通信(Synchronous Communication)调用者等待调用结果同步 IO(recvfrom 等数据回来才返回)进程/线程同步(Process Synchronization)多个线程/进程协调执行次序,互斥/制约互斥锁、信号量、条件变量
这两者是完全不相关的概念! “同步通信"中的同步,说的是"调用者和被调用者之间的消息交互方式”;“进程同步"中的同步,说的是"多个执行流之间的制约与协调关系”。它们只是共用了一个中文翻译,底层含义完全不同。
在日常交流中,当别人提到"同步"时,先搞清楚大背景——他在聊 IO 模型还是多线程编程?如果你把"同步 IO"理解成"多个线程之间的同步",那就彻底跑偏了。
3.4 四者如何组合——用蒸唐僧的故事讲清楚
同步/异步 和 阻塞/非阻塞 是两套独立坐标系,它们可以自由组合:
同步异步阻塞同步 + 阻塞 = 阻塞 IO异步 + 阻塞 ≈ 不存在(很少有这种组合)非阻塞同步 + 非阻塞 = 非阻塞 IO / IO 多路转接异步 + 非阻塞 = 异步 IO
用一个"蒸妖怪抓唐僧"的例子来理解四者的关系:
- 同步 + 阻塞:唐僧亲自站锅边等水烧开,一步不离开。水不开,人就定在原地不动。(
recvfrom()一直等到数据来) - 同步 + 非阻塞:唐僧每隔两分钟跑来锅边看一眼水开了没,没开就回去念经,过一会再来看。反复跑腿。(轮询
recvfrom()+EWOULDBLOCK) - 异步 + 阻塞:妖怪说"水开了我叫你",但唐僧不放心还是站锅边等……这组合有点拧巴,实践中基本没有
- 异步 + 非阻塞:妖怪全权负责蒸锅,水开了直接把蒸好的唐僧端到桌上。唐僧全程不在厨房,念经去了。(
aio_read()+ 回调)
一个检查自己是否理解的快速测试:select() / epoll() 属于同步 IO 还是异步 IO?答案是 同步 IO。因为 select() 只负责告诉你"哪些 fd 就绪了"(第一阶段),数据的拷贝(第二阶段)还是由进程自己调用 recvfrom() 来完成的。异步 IO 务必保证两个阶段都由内核完成。
四、阻塞 IO 深入——默认行为与代码验证
在了解了理论框架之后,我们来写代码验证"所有套接字默认都是阻塞的"。
4.1 默认就是阻塞的
Linux 中,通过 socket() 创建的套接字,或者通过 open() 打开的文件,默认都是阻塞 IO。这意味着:
- 对 TCP socket 调用
read()/recv():如果接收缓冲区为空,进程挂起,直到有数据到达 - 对 TCP socket 调用
write()/send():如果发送缓冲区满了,进程挂起,直到有空闲空间 - 调用
accept():如果全连接队列为空,进程挂起,直到有客户端连接进来 - 对 UDP socket 调用
recvfrom():如果接收缓冲区为空,进程挂起,直到收到数据报
你之前写 UDP 服务器时,recvfrom() 一调用程序就"卡住了"等客户端发消息——那不是 bug,这正是阻塞 IO 的标准行为。阻塞 IO 是内核提供的最简单、最自然的 IO 模型。
4.2 阻塞 IO 的代码表现
写一段简单的代码,感受阻塞 IO 的直观表现:
#include
#include
#include
int main()
{
char buf[1024] = {0};
printf("请输入内容(阻塞等待 stdin...):\n");
// read() 默认是阻塞的——如果 stdin 没有数据,
// 进程会在这里一直挂起,直到用户输入回车
ssize_t n = read(0, buf, sizeof(buf) - 1);
if (n > 0)
{
buf[n] = '\0';
printf("你输入了: %s\n", buf);
}
return 0;
}
编译运行:
$ gcc -o blocking_test blocking_test.c
$ ./blocking_test
请输入内容(阻塞等待 stdin...):
# ← 光标停在这里,进程挂起,等待用户输入
# 你输入 "hello" 并回车后:
你输入了: hello
这个过程完美展示了阻塞 IO:
1. 进程调用 read(0, buf, ...) 发起读请求
2. 内核注意到 stdin(fd=0)的缓冲区是空的——没有用户输入
3. 进程被挂起(状态变为 S,sleeping),CPU 被释放给其他进程
4. 用户在终端输入了 “hello\n”,数据进入 stdin 缓冲区
5. 内核完成数据拷贝(从内核缓冲区到用户 buf)
6. read() 返回,进程继续执行
可以用另一个终端 ps aux | grep blocking 观察——当程序停在等待输入时,进程状态是 S(interruptible sleep),这正是"被挂起等待 IO"的标志。
五、非阻塞 IO 实战——fcntl 与轮询编程
现在来实现非阻塞 IO。核心工具是 fcntl() 系统调用。
5.1 fcntl 函数详解
fcntl(file control)是 Linux 中操作文件描述符属性的通用接口。它的能力远远不止"设置非阻塞"这一件事:
#include
#include
int fcntl(int fd, int cmd, ... /* arg */);
// fd: 要操作的文件描述符
// cmd: 操作命令(决定 fcntl 做什么)
// ...: 根据 cmd 不同,附加参数也不同
// 返回值: 根据 cmd 不同,成功时的返回值含义不同;失败统一返回 -1
fcntl 有五种功能,通过 cmd 参数区分:
cmd 值功能说明F_DUPFD复制文件描述符类似 dup(),返回新的 fdF_GETFD / F_SETFD获取/设置文件描述符标记目前只有 FD_CLOEXEC(exec 时自动关闭)F_GETFL / F_SETFL获取/设置文件状态标记这就是我们要用的——加 O_NONBLOCKF_GETOWN / F_SETOWN获取/设置异步 IO 所有权用于信号驱动 IO(SIGIO)F_GETLK / F_SETLK / F_SETLKW获取/设置记录锁文件锁,用于进程间协调
我们只关注第三种功能——F_GETFL 和 F_SETFL。文件状态标记是一个位图(bitmap),每位代表一种属性,O_NONBLOCK 只是里面之一。另外还有 O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写)、O_APPEND(追加)等。这个设计非常精巧——通过一个整数就能组合出无数种文件打开行为。
5.2 SetNoBlock 实现
基于 fcntl,实现一个 SetNoBlock 函数,将任意文件描述符设置为非阻塞模式:
#include
#include
#include
void SetNoBlock(int fd)
{
// 第一步:先用 F_GETFL 拿到当前的文件状态标记
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl F_GETFL");
return;
}
// 第二步:在原有标记的基础上,按位或上 O_NONBLOCK,再写回去
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
这个函数的实现有三个关键点:
1. 为什么要先 F_GETFL 再 F_SETFL,而不是直接 SET?
因为 F_SETFL 是覆盖式写入——如果你直接 fcntl(fd, F_SETFL, O_NONBLOCK),会把 fd 原有的所有标记(如 O_RDWR、O_APPEND)全部清零,只剩下一个 O_NONBLOCK。这会导致 fd 变成不可读不可写的状态,程序直接废了。先 F_GETFL 拿到完整的位图,再 | O_NONBLOCK 加上目标位后写回——这才安全。
这是一个经典的人为失误,笔者就曾栽过。不先用 F_GETFL 而直接 F_SETFL,代码编译没问题、运行没报错,但之后对 fd 的读写全都失败。查了半天才发现 fd 的读写权限位被覆盖掉了。
2. 为什么用位或(|)操作?
O_NONBLOCK 的值通常是 04000(八进制),即二进制中某一位为 1。按位或操作保证:
- 原来
fl中的 1 保持为 1(权限、追加等属性不变) - 原来
fl中对应O_NONBLOCK那位置为 1(加上非阻塞属性) - 不会把不该设置的位设成 1(
|只会"加 1"不会"变 0")
3. 错误处理为什么只有一个 perror?
fcntl 失败的概率极低(除非传入了非法的 fd 或 cmd),所以在教学代码中用 perror 打印错误信息就够了。生产代码中建议用日志框架记录并返回错误码。
5.3 轮询方式读取标准输入——完整示例
现在用 SetNoBlock 改写前面阻塞 IO 的例子,实现非阻塞轮询读取 stdin:
#include
#include
#include
#include
#include
void SetNoBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl F_GETFL");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
// 将 stdin(fd=0)设置为非阻塞模式
SetNoBlock(0);
while (1)
{
char buf[1024] = {0};
ssize_t read_size = read(0, buf, sizeof(buf) - 1);
if (read_size < 0)
{
// 非阻塞模式下,无数据可用时返回 EAGAIN/EWOULDBLOCK
// (在 Linux 上这两个宏的值相同,都表示"再试一次")
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
printf("没有输入,做点别的事...\n");
sleep(1); // 模拟进程在做其他工作
continue; // 回头继续轮询
}
else
{
// 真正的错误(如 fd 无效),打印后退出
perror("read");
break;
}
}
else if (read_size == 0)
{
// read 返回 0 表示读到 EOF(如 Ctrl+D)
printf("检测到 EOF(Ctrl+D),退出程序\n");
break;
}
else
{
// 成功读到数据
buf[read_size] = '\0';
printf("输入: %s\n", buf);
}
}
return 0;
}
编译并运行:
$ gcc -o nonblock_test nonblock_test.c
$ ./nonblock_test
没有输入,做点别的事... # 循环打印,同时等待输入
没有输入,做点别的事...
hello # ← 你在这里输入了 "hello" + 回车
输入: hello # 立刻被 read() 读取
没有输入,做点别的事... # 继续循环
没有输入,做点别的事...
^D # ← Ctrl+D 发送 EOF
检测到 EOF(Ctrl+D),退出程序
关键观察:程序不会阻塞在 read() 上等用户输入。在用户不输入的时候,循环在不断打印"做点别的事"——这就是非阻塞 IO 的核心行为:数据没准备好就立刻返回错误码,给进程机会去干别的。
5.4 非阻塞 IO 的优缺点总结
优点缺点不挂起进程,可以在"无数据"期间执行其他任务轮询消耗 CPU——即使没有数据,read() 系统调用仍在高频执行编程模型简单:一个循环搞定资源利用率低:大部分轮询尝试返回的都是 EWOULDBLOCK适合数据来得很快的场景(轮询成功率 → 100%)不适合数据稀疏到达的场景(99% 的轮询是浪费的)
非阻塞 IO 是一个"特定场景专用"的模型,不是通用方案。 它的致命缺陷在于:每次轮询都是一次系统调用(用户态 ↔ 内核态切换),如果没有数据,这次切换完全是浪费的。如果同时监视 1000 个非阻塞 fd 并在一个循环里逐个 read()——这就是 1000 次系统调用/每轮,里面 999 次可能都白费了。这也是为什么 IO 多路转接(epoll)最终成为了主流方案——它把"检查哪些 fd 就绪"的工作交给了内核,一次返回所有就绪的 fd。
六、常见问题与避坑指南
在实际编程中,和 IO 模型相关的坑往往不是"代码写错了"那么明显,而是"行为不符合预期"的隐式问题。
6.1 fcntl(fd, F_SETFL, O_NONBLOCK) 直接覆盖了读写权限
- 现象:调用
SetNoBlock()后,原来的 fd 突然读不了也写不了,read()/write()返回-1且errno为EBADF(Bad file descriptor)。 - 原因:没有先用
F_GETFL获取现有标记,直接用O_NONBLOCK覆盖了。文件状态标记是一个位图——O_RDONLY、O_WRONLY、O_RDWR等权限位也在里面。直接F_SETFL(O_NONBLOCK)会把权限位清零,fd 变成"不可读不可写"状态。 - 解决:必须按"先 GET 后 SET"的模式操作:
// 正确写法
int fl = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
// 错误写法(千万别这么写!)
fcntl(fd, F_SETFL, O_NONBLOCK); // 权限位被清零!
6.2 非阻塞 read() 返回 -1 时没有区分"无数据"和"真错误"
- 现象:非阻塞模式下
read()返回-1,程序直接perror退出,但实际上只是暂时没有数据可读。 - 原因:非阻塞 IO 返回
-1时有两种可能——(1)errno == EAGAIN/EWOULDBLOCK:没有数据,不是错误,该重试;(2)errno为其他值(EBADF、EFAULT、EINTR等):真错误,需要处理。不加区分的if (ret < 0) { perror; exit; }会把情况 (1) 当成错误退出。 - 解决:必须检查
errno:
ssize_t n = read(fd, buf, sizeof(buf));
if (n < 0)
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
// 无数据,不是错误——去干别的,过会再试
continue;
}
else
{
// 真正的错误
perror("read");
break;
}
}
6.3 轮询循环中没有 sleep,导致 CPU 满载
- 现象:运行非阻塞轮询程序后,某个 CPU 核心使用率飙到 100%。
top一看,你的进程在疯狂空转。 - 原因:轮询循环中每次
read()返回EWOULDBLOCK后立刻continue回到循环起点再调read(),形成了高频忙等(busy waiting)。几千、几万次系统调用/秒,CPU 全浪费在"尝试读→失败→再尝试"的死循环里。 - 解决:在无数据的路径上加入
sleep()或usleep(),给 CPU 喘息的机会:
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
usleep(100000); // 睡 100ms,让出 CPU 给其他进程
continue;
}
当然,更好的解决方案是不用非阻塞 IO——用 IO 多路转接(epoll),只对有数据的 fd 调用 read(),从根源上避免空轮询。
6.4 混淆"同步通信"和"进程同步"
- 现象:面试或技术讨论中,把 IO 模型中的"同步 IO"和多线程编程中的"线程同步(互斥锁/信号量)"混为一谈。
- 原因:中文"同步"一词翻译了两个不同的英文概念——IO 领域的
synchronous和并发编程领域的synchronization。前者说的是"调用者要不要等结果",后者说的是"多个执行流怎么协调"。 - 解决:养成习惯,每次听到或说出"同步"时,先在脑子里确认一下上下文——表1:同步通信 vs 异步通信(讨论消息交互机制,要不要等结果);表2:进程同步与互斥(讨论多线程协调,谁先谁后、能不能同时进临界区)。两套话语系统,各不相干。
6.5 误以为 select / epoll 是异步 IO
- 现象:见到
epoll能监视成百上千个连接,“事件驱动、回调处理”,就认为它是异步 IO。 - 原因:
epoll的事件通知机制在表面行为上很像异步——“哪个 fd 就绪了,你去处理一下”。但异步 IO 的核心特征是第二阶段(数据拷贝)也由内核完成、数据直接送到用户 buffer。epoll返回后你仍然需要自己调recvfrom()把数据从内核拷出来——所以它是同步 IO,只是用了多路转接来优化第一阶段的等待效率。 - 解决:判断 IO 是否异步的标准只有一个——数据拷贝(第二阶段)是谁做的? 内核做的 → 异步 IO。进程自己调的
read()/recvfrom()→ 同步 IO。select/poll/epoll都是同步 IO,因为它们只负责"通知你哪些 fd 就绪了",真正的数据拷贝还是得进程自己来。
七、总结
7.1 知识图谱:从 IO 的两个阶段到五种 IO 模型
本文的核心知识点可以用一张 HOW → WHAT → WHY 的逻辑链串联起来:
层次问题答案HOW(怎么做的)IO 操作经历哪些步骤?两个阶段:等待数据就绪 + 数据从内核拷贝到用户空间WHAT(有什么选择)有哪些不同的等待/拷贝策略?五种 IO 模型:阻塞、非阻塞、信号驱动、IO 多路转接、异步WHY(为什么这么设计)为什么会有这么多种模型?因为等待耗时远大于拷贝耗时,不同应用场景对"等待中的 CPU 利用"有不同需求API(怎么实现)如何修改默认的阻塞行为?fcntl(fd, F_SETFL, fl | O_NONBLOCK),非阻塞 IO 通过轮询实现
7.2 动手试试
1. 实现一个非阻塞 TCP 客户端:用 fcntl 将 TCP socket 设为非阻塞,调用 connect() 后轮询检查连接是否建立成功(提示:非阻塞 connect() 返回 -1 且 errno == EINPROGRESS 表示连接正在进行中,用 select() 等待 socket 可写来判断连接完成)
2. 改造阻塞服务器为非阻塞模式:把你之前写的 UDP Echo 服务器的 sockfd 用 SetNoBlock 设为非阻塞,观察在没有客户端发数据时服务器 CPU 使用率的变化。然后加入 sleep(1) 对比效果
3. 文件状态标记体验:写一个程序用 fcntl(fd, F_GETFL) 获取 stdout(fd=1)的文件状态标记,打印出 fl 的值(一个整数),然后用位运算 fl & O_WRONLY 检查 stdout 是否可写
尾声
本章讲解就到此结束了,若有纰漏或不足之处
更多内容可见主页
这篇笔记就先到这里,后面用到新的思路或者发现有问题再补充。
评论 (0)
暂无评论