前言 :
在之前我们就介绍过IO的效率比较慢,但是为什么比较慢呢?为什么访问外设比较慢?这些我们没有说过
I/O 即 Input/Output(输入/输出) ,是计算机与外部设备(如磁盘、网卡等)进行数据交换的过程。
当客户端和服务端三次握手建立TCP连接后,会拿到网络套接字fd,此时你想要IO,可以通过系统调用read读数据,那么你想读就一定能读到吗?
不一定,因为接收缓冲区中可能数据还没有就绪,我们需要等数据从客户端的发送缓冲区拷贝到服务端的接收缓冲区,但是中间还需要经过网络,这就大大加深了我们等的时间。
所以,为什么在介绍系统、文件等时,不去介绍IO呢?因为从系统的角度不太好介绍清楚,学习了网络之后,我们更能明白两台距离很远的主机通信是需要经过重重路由器的转发的,这当然需要时间等待。
那你可能会有疑问:我平时数据IO的时候,感觉很快呀!
那是因为是对于你人来说,会感觉快,但是对于计算机的cpu来说就很慢了
所以,IO为什么慢?IO操作的本质是什么?
IO操作慢的根本原因在于其包含两个阶段:
- 等待(Wait) :操作系统需要等待外部设备(例如等待网络数据到达、磁盘读取就绪)准备好数据或接收数据。这个过程是不可控的,取决于硬件响应速度。
- 拷贝(Copy) :一旦数据准备就绪,需要将数据从内核缓冲区拷贝到用户空间(读),或从用户空间拷贝到内核缓冲区(写)。
因此,一个完整的IO操作可以表示为:I/O = 等 + 拷贝
其中,"等待"时间通常远大于"拷贝"时间,尤其是在网络或磁盘IO中。例如,从硬盘读取一个文件,可能需要几十毫秒的寻道和旋转延迟,而实际数据传输可能只花几毫秒。
什么是高效 I/O?
定义:单位时间内传递的数据量越大,I/O 效率越高。
但是关键是要减少等待的时间,所以高效 I/O的真正本质是单位时间内,减少IO中,等待的比重!
换句话说,我们希望"等待"所占比例尽可能小,从而在相同时间内完成更多数据传输。
所以我们为什么要来介绍IO呢?因为我们要在网络中高效通信,那就需要高效IO
想要高效IO就需要减少等待的时间,那要怎么减少呢?这个时候就有人想出来若干种IO模式,即五种IO模型
文章目录
- [1. 五种IO模型](#1. 五种IO模型)
- [2. 高级IO重要概念](#2. 高级IO重要概念)
- [3. 非阻塞IO](#3. 非阻塞IO)
1. 五种IO模型
钓过鱼的都知道,钓鱼 = 等 + 钓,我们要等🐟咬钩(等),再把🐟钓起来放进桶里(拷贝),如果等的时间短的话,那钓鱼的效率就高
所以下面我们来通过钓鱼的例子将五种IO模型都串联起来介绍,帮助我们更深刻理解五种IO模型

核心比喻框架
-
钓鱼者 :代表应用程序/进程。
-
钓鱼 (等待鱼上钩 ):代表I/O操作(特别是"等待数据就绪"阶段)。
-
鱼 :代表需要读取或写入的数据。
-
河 :代表操作系统内核。
-
桶 :代表用户缓冲区(用于存放已拷贝的数据)。
五种I/O模型详解
- 张三 - 阻塞I/O
-
行为:专注盯着鱼漂,一动不动,直到鱼上钩。
-
对应模型:发起I/O调用后,进程被"阻塞",挂起等待,直到数据就绪并完成从内核到缓冲区的拷贝。
- 李四 - 非阻塞I/O
-
行为:边看书刷视频,边时不时看一眼鱼漂。如果没鱼,就继续做自己的事(不会卡住)。
-
对应模型:发起I/O调用后,如果数据未就绪,内核立即返回一个错误,进程不会被挂起,可以继续执行,但需要不断轮询检查。
- 王五 - 信号驱动I/O
-
行为:在鱼竿上系个铃铛,鱼咬钩时铃铛响,再过来收竿。
-
对应模型:先开启信号驱动,然后进程可以继续执行。当数据就绪时,内核会主动发送一个信号通知进程,进程再发起I/O操作进行数据拷贝。
- 赵六 - 多路复用(I/O多路转接)
-
行为:同时管理一整卡车的鱼竿(如100根)。
-
对应模型:使用 select、poll、epoll等系统调用,单个进程可以同时监视多个文件描述符。当其中任何一个就绪时,进程再进行处理。它高效的关键在于能用一次等待管理多个I/O。
- 田七 - 异步I/O
-
行为:想吃鱼,但让助理小王去全程负责钓鱼,钓好后再送过来。自己完全不用参与钓鱼过程。
-
对应模型:进程发起一个I/O请求后立即返回,内核会负责完成整个I/O操作(等待+拷贝),完成后通知进程"一切都已就绪,数据已在你的缓冲区里"。
关键问题与结论
问题1的解答 :
阻塞IO与非阻塞IO的核心区别在于等待方式。
- 阻塞IO:当IO条件不满足时(如数据未到达),调用会一直等待,直到条件满足才返回。例如张三盯着鱼漂,不干别的,直到鱼上钩。
- 非阻塞IO:当IO条件不满足时,立即返回错误或无数据状态,不等待。例如李四看书刷视频,不因鱼没上钩而卡住,可以做其他事。
因此,两者的主要区别是"是否主动等待",而非阻塞IO效率更高,因为它允许CPU执行其他任务。
问题2的解答 :
从"鱼"的角度分析,赵六拉了一卡车鱼竿(100只),相当于同时监控多个IO资源。单位时间内,鱼咬钩的概率更高,因此单位时间内钓到的鱼更多,即赵六的钓鱼效率最高。这对应于多路复用(如select/poll/epoll)技术,能高效处理大量并发连接,单位时间内处理的能力强,效率高
问题3的解答 :
王五虽然没有"主动检测"鱼漂,但他仍然在"等待"鱼咬钩这一事件的发生。他通过铃铛(信号)被通知,本质上仍属于"等待"机制,只是由系统(操作系统)代替他轮询检测。因此,王五有等待,只是方式不同------被动等待事件触发,而非主动轮询。
结论4的解答 :
阻塞、非阻塞、信号驱动、多路复用都属于同步IO。因为它们都要求用户进程参与IO操作的某个阶段(如等待、检测、接收信号等)。只要用户进程参与了IO过程中的"等"或"拷贝"阶段,就是同步IO。而异步IO则完全不参与,IO完成后再通知。
问题5的解答 :
同步IO vs 异步IO 的核心区分标准是:
- 同步IO:用户进程参与IO的"等待"或"数据拷贝"阶段。例如阻塞、非阻塞、信号驱动、多路复用都属于同步IO。
- 异步IO:用户进程只发起IO请求,后续的等待和数据拷贝由内核完成,完成后通知用户进程。例如田七让助理小王帮忙钓鱼,自己去忙其他事,等鱼上钩后小王通知他。
此外,IO同步 ≠ 线程同步。IO同步指的是IO操作的执行模式,线程同步是指线程间的协调机制(如锁、信号量)。两者概念不同,没有直接关系,正如"老婆"和"老婆饼"一样,名字相似但本质不同。
- 阻塞IO:在内核将数据准备好之前,系统调用一直等待,所有的套接字,默认都是阻塞方式.
阻塞IO是最常见的IO模型.

- 非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码.
非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费,一般只有特定场景下才使用

- 信号驱动IO:内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作.

- IO多路转接:虽然从流程图上看起来和阻塞IO类似,实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态.

- 异步IO:由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).

小结
- 任何IO过程中,都包含两个步骤:第一是等待,第二是拷贝,而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间,让IO更高效,最核心的办法就是让等待的时间尽量少.
2. 高级IO重要概念
在这里,我们要强调几个概念
同步通信 vs 异步通信(synchronous communication / asynchronous communication)
同步和异步关注的是消息通信机制.
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果;
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果;而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用.
另外,我们回忆在讲多进程多线程的时候,也提到同步和互斥,这里的同步通信和进程之间的同步是完全不相干的概念.
- 进程/线程同步也是进程/线程之间直接的制约关系
- 是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系,尤其是在访问临界资源的时候.
大家以后在看到 "同步" 这个词,一定要先搞清楚大背景是什么,这个同步,是同步通信异步通信 的同步,还是同步与互斥的同步.
阻塞 vs 非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态.
- 阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回.
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程,线程可以立即返回并执行后续代码,通常需要通过轮询等方式来获取最终结果。
其他高级IO
非阻塞IO,纪录锁,系统V流机制,I/O多路转接(也叫I/O多路复用)、readv和writev函数以及存储映射IO(mmap),这些统称为高级IO.
我们此处重点讨论的是I/O多路转接:
传统I/O模型中,每个I/O操作(如read、write、recv、send等)都包含两个阶段:
- 等待(Wait) :进程阻塞,等待数据就绪(如网络数据到达、磁盘读取完成等)。
- 拷贝(Copy) :数据从内核空间拷贝到用户空间(或反之)。
select 是一种I/O多路复用机制,它允许一个进程同时监视多个文件描述符,等待其中任意一个变为可读、可写或发生异常。其核心思想是:
- 一次等待多个fd:通过传入一个fd集合(如fd_set),select系统调用可以同时监听多个fd的状态。
- 避免轮询:不需要为每个fd单独调用read/write等系统调用,而是先通过select判断哪些fd已经就绪。
- 后续操作:在select返回后,程序再对就绪的fd调用相应的read/write/recv/send等系统调用进行数据拷贝。
这里我们先简单介绍一下select,后面会详细介绍多路转接
3. 非阻塞IO
阻塞IO在之前我们在写代码时就经常接触,也比较简单,下面我们就来介绍一下如何设置非阻塞IO
在之前,我们使用传统系统调用时,是如何设置非阻塞I/O的呢?
调用 open时指定
如果文件尚未打开,可以在使用 open系统调用时直接指定 O_NONBLOCK 标志。
cpp
int fd = open("/path/to/file", O_RDWR | O_NONBLOCK, 0644);
需要注意的是,网络套接字由 socket调用创建,而非 open,所以此方法主要用于普通文件、设备文件等。
不过,在socket编程中,也可以使用socket函数创建socket时,通过设置类型为 SOCK_NONBLOCK 来创建非阻塞socket(注意:这需要Linux内核2.6.27以上,并且仅在支持SOCK_NONBLOCK标志的协议族中可用):
cpp
int sockfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
使用 MSG_DONTWAIT标志
对于网络编程中的 send, recv, sendto, recvmsg等特定I/O调用,可以使用 MSG_DONTWAIT 标志来临时地、仅针对本次调用启用非阻塞行为。例如:
cpp
ssize_t n = recv(sockfd, buf, len, MSG_DONTWAIT);
这在套接字本身处于阻塞模式,但某次操作不希望被阻塞时非常有用。
非阻塞I/O的行为与处理
成功设置为非阻塞模式后,如果I/O操作(如 read或 write)不能立即完成,系统调用会立即返回-1,并将错误码 errno设置为 EAGAIN 或 EWOULDBLOCK。这并非真正的错误,而是预期行为,意味着"资源暂时不可用,请稍后再试"。
不同I/O类型的非阻塞行为
- 普通文件:
-
几乎总是立即返回,因为常规文件总是"就绪"的
-
但某些特殊文件(如设备文件)可能真的会阻塞
- 管道和FIFO:
-
读空管道或写满管道会返回EAGAIN
-
需要轮询或使用I/O多路复用
- Socket:
-
读:没有数据时返回EAGAIN/EWOULDBLOCK
-
写:缓冲区满时返回EAGAIN/EWOULDBLOCK
-
连接:非阻塞connect()会返回EINPROGRESS
下面我们来介绍一种更通用的设置非阻塞模式的方法
使用 fcntl函数
这是最常用且通用的方法,适用于已经打开的文件描述符(如标准输入、套接字等)。fcntl通过修改文件描述符的状态标志来工作,遵循"读-改-写"模式:
cpp
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
传入的cmd的值不同,后面追加的参数也不相同.
fcntl函数有5种功能:
- 复制一个现有的描述符(cmd=F_DUPFD).
- 获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).
- 获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).
- 获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
- 获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW).
我们此处只是用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞.
文件状态标志(核心应用)
这是 fcntl最常用的功能,允许你动态修改一个已打开文件的属性,而无需重新open。
-
F_GETFL: 获取当前文件状态标志(如 O_RDONLY, O_NONBLOCK, O_APPEND等)。返回的标志需要与掩码 O_ACCMODE进行与操作(&)来单独检查访问模式(只读、只写、读写)。 -
F_SETFL: 设置文件状态标志。注意 :并非所有标志都可修改,通常只允许设置 O_APPEND, O_NONBLOCK, O_ASYNC等。标准做法是"读-改-写":先获取当前标志,再修改所需位,最后设置回去,以避免覆盖其他标志。
示例:
cpp
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <fcntl.h>
// 0 标准输入,默认就是阻塞的
void SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK); // O_NONBLOCK:将fd设置为非阻塞
}
int main()
{
SetNonBlock(0);
char buffer[1024];
while (true)
{
// Linux中ctrl + d:标识输入结束,read返回值是0,类似读到文件结尾
ssize_t n = read(0, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n - 1] = 0;
std::cout << buffer << std::endl;
}
else if (n < 0) // 非阻塞read,如果底层数据没有准备好,数据读取算不算出错?不算
{
// 1. 读取出错 2. 底层没有数据准备好
if(errno == EAGAIN || errno == EWOULDBLOCK) // 错误码
{
std::cout << "数据没有准备好..." << std::endl;
sleep(1);
// 做你的事情
continue;
}
else if(errno == EINTR)
{
continue;
}
else
{
// 真正的read出错了
}
}
else
{
break;
}
}
}
读取返回值处理:这是非阻塞 I/O 的核心。
-
n > 0:成功读取到数据。代码中 buffer[n - 1] = 0;是为了去掉输入末尾的换行符(\n)
-
n == 0:表示到达了文件结尾(End-of-File)。对于标准输入,这通常意味着用户按下了 Ctrl+D(在类Unix系统中),输入流被关闭。此时跳出循环是合理的。
-
n < 0:需要检查 errno来确定具体原因。
错误码判断:
-
EAGAIN或 EWOULDBLOCK:在绝大多数 Linux 系统上,这两个值是等价的。它表示当前没有数据可读,但这不是故障。代码的处理方式是打印提示信息,睡眠1秒后继续循环,这是一种简单的轮询策略。
-
EINTR:表示系统调用被信号中断。处理方式是立即重试(continue),这是正确做法。
-
其他错误:属于真正的错误,代码中留出了处理逻辑,在实际应用中应填充具体的错误处理代码,例如记录日志并退出循环。
运行结果:
bash
ltx@Xshell:~/gitLinux/Linux_network/SetNonBlock$ ./a.out
数据没有准备好...
数据没有准备好...
数据没有准备好...
数据没有准备好...
ab数据没有准备好...
c
abc
数据没有准备好...
数据没有准备好...
数据没有准备好...
a数据没有准备好...
c数据没有准备好...
ccc
acccc
数据没有准备好...
数据没有准备好...
数据没有准备好...