@[toc]
本篇摘要
- 本篇将介绍五种IO模型,以及对其中多路转接IO的
select,poll进行介绍,使用,总结等。
一· 五种IO模型介绍:
1. 阻塞IO(Blocking IO, BIO)
-
核心特点:应用程序发起IO操作(如读/写)后,线程会被“卡住”,直到IO完成(数据到达/发送完毕)。
-
工作流程:
-
总结:
简单但低效,适合单连接、低并发场景(如早期客户端程序),所有套接字默认都是阻塞IO。
2. 非阻塞IO(Non-blocking IO, NIO)
-
核心特点:应用程序发起IO操作后,函数立即返回(不会阻塞线程),但需主动轮询检查数据是否就绪,非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返叵
EWOULDBLOCK错误码。 -
工作流程:
-
总结:
避免线程阻塞,但需频繁轮询(浪费CPU),适合短连接或对延迟敏感的场景。
3. IO多路复用(Multiplexed IO)
-
核心特点:通过一个系统调用监控多个文件描述符(FD),当任意一个FD就绪(数据可读/写)时,内核通知应用程序处理。
-
代表实现:
select(老)、poll(改进)、epoll(高性能,Linux专属)。 -
工作流程:
-
总结:
用“单线程管多个连接”,大幅提升并发能力(如Nginx、Redis用epoll)。
4. 信号驱动IO(Signal Driven IO, SIGIO)
-
核心特点:应用程序通过信号(Signal) 通知内核“监控某个FD”,当数据就绪时,内核发送信号触发应用程序处理。
-
工作流程:
-
总结:
异步通知,但信号可能丢失或延迟,实际应用较少(仅适合特定场景)。
5. 异步IO(Asynchronous IO, AIO)
-
核心特点:应用程序发起IO操作后,内核全程完成数据拷贝,完成后主动通知应用程序(无需线程干预)。
-
工作流程:
-
总结:
完全异步,“发起即忘”,效率最高(适合高并发、大文件传输等场景,如Windows的IOCP)。
因此对应信号驱动IO以及异步IO区别:信号驱动是数据准备好了,告诉用户自己调用接口从内核态拷贝到用户态,而异步是直接让内核拿到数据后拷贝到用户态完成才进行通知用户。
相关概念认识
同步通信 vs 异步通信
-
核心区别:
- 同步:调用者自己发起IO操作,全程等待结果返回(自己干完才停)。
- 异步:调用者委托他人执行IO操作,完成后收到通知(交给别人干,自己等通知)。
-
本质差异:
- 同步:IO操作由调用方“亲自执行”,需阻塞等待。
- 异步:IO操作由其他组件执行,调用方仅接收结果通知。
阻塞 vs 非阻塞
-
关注点:
- 程序在“等待调用结果时的状态”。
-
区别:
- 阻塞:等待期间,当前线程被“挂起”(卡住不动),直到结果返回才能继续。
- 非阻塞:等待期间,线程不被“卡住”,可继续执行其他任务(不干等)。
对比
- 同步/异步:描述“IO操作由谁执行”(自己干 vs 别人干)。
- 阻塞/非阻塞:描述“等待时线程是否被挂起”(卡住 vs 不卡住)。
- 二者独立:同步操作可能阻塞或非阻塞(如同步非阻塞需轮询),异步操作通常搭配非阻塞(避免卡死)。
一句话总结
从“干等”(阻塞)到“轮询”(非阻塞),再到“监控多个”(多路复用),最后到“内核代劳”(异步),IO模型逐步解决“线程效率”和“并发能力”的矛盾。
其他高级IO拓展(了解)
-
非阻塞 IO:
- 调用时不阻塞线程,立即返回结果(可能未完成),需主动轮询或配合其他机制(如 I/O 多路复用)获取最终结果。
-
记录锁(文件锁):
- 对文件/数据加锁,确保多进程/线程并发访问时的数据一致性(如防止同时写入)。
-
系统 V 流机制(STREAMS):
- UNIX 系统中的一种设备驱动模型,支持数据的异步处理和模块化组件(现代应用较少,可简单理解为“流式数据处理框架”)。
-
I/O 多路转接(I/O Multiplexing):
- 通过
select/poll/epoll等机制,单个线程监控多个文件描述符的 IO 状态,有事件时才触发处理,避免轮询浪费资源(常用于高并发服务端)。
- 通过
-
readv/writev 函数(分散/聚集 I/O):
- 允许一次系统调用读写多个内存缓冲区(分散写入、聚集读取),减少多次拷贝开销,提高大块数据传输效率。
-
存储映射 IO(mmap):
- 将文件直接映射到进程内存空间,通过内存操作访问文件(避免频繁读写系统调用),适合大文件随机访问和进程间共享内存。
小结:
这些高级 IO 主要解决 效率、并发控制、资源利用 问题,常结合使用(如 epoll + 非阻塞 IO + mmap),适用于高并发、高性能场景。
二.非阻塞IO之fcntl
fcntl 函数基本信息
- 头文件:包含
<unistd.h>和<fcntl.h> - 函数原型:
int fcntl(int fd, int cmd, ... /* arg */); - 核心功能:通过不同的
cmd参数实现对文件描述符的多种控制操作。
fcntl 函数的 5 种主要功能
- 复制文件描述符(
cmd=F_DUPFD):复制已有描述符。 - 获取/设置文件描述符标记(
cmd=F_GETFD或F_SETFD):操作描述符的标志(如关闭标志FD_CLOEXEC)。 - 获取/设置文件状态标记(
cmd=F_GETFL或F_SETFL):控制文件读写模式(如阻塞/非阻塞)。 - 获取/设置异步 I/O 所有权(
cmd=F_GETOWN或F_SETOWN):指定接收异步 I/O 信号的进程。 - 获取/设置记录锁(
cmd=F_GETLK、F_SETLK或F_SETLKW):对文件区域加锁/解锁。
通过 fcntl 设置非阻塞模式
- 核心步骤(以
F_SETFL为例):- 使用
F_GETFL获取当前文件状态标志(返回一个位图)。 - 通过按位或运算(
|)添加O_NONBLOCK标志。 - 使用
F_SETFL将修改后的标志写回文件描述符。
- 使用
- 示例(
set_nonblock函数):void set_nonblock(int fd) { int flags = fcntl(fd, F_GETFL, 0); // 获取当前标志 flags |= O_NONBLOCK; // 添加非阻塞标志 fcntl(fd, F_SETFL, flags); // 设置回文件描述符 }
基于fcntl函数简单测试
测试思路
- 将标准输入(键盘)设置为非阻塞模式,通过无限循环持续尝试读取输入:有数据时输出内容;无数据时因EAGAIN/EWOULDBLOCK提示“没有数据”并休眠1秒;若被信号中断(EINTR)则提示后继续循环;其他错误则提示错误,最终实现非阻塞读取标准输入的功能。
效果展示
- 标准输入流没输入就打印没有数据,否则打印输入的数据。
源码展示
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
using std::cout;
using std::endl;
void set_nonblock(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
flags |= O_NONBLOCK;
fcntl(fd, F_SETFL, flags);
}
int main()
{
set_nonblock(0);
while (1)
{
char buf[1024];
int n = read(0, buf, sizeof(buf));
if (n > 0)
{
buf[n - 1] = 0;
cout << buf << endl;
}
else if (n < 0)
{ //无论是读取失败还对方的接收缓冲区没有数据最后都是n==-1因此三种情况区分!
cout<<n<<endl;
if (errno == EINTR)
{
cout << "被打断了" << endl;
continue;
}
else if (errno == EAGAIN || errno == EWOULDBLOCK)
{ sleep(1);
cout << "没有数据" << endl;
//虽然IO效率没有提高,可以在等待的时间做自己想做的事情!
continue;
}
else
{
cout << "出错了" << endl;
continue;
}
}
}
return 0;
}
三. 多路转接之select
select相关函数介绍
- 功能:系统提供select函数实现多路复用输入/输出模型,用于监视多个文件描述符的状态变化,程序会在select处等待直至监视的描述符有状态改变。
- 函数原型:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);- nfds:需监视的最大文件描述符值+1。
- readfds/writefds/exceptfds:分别对应可读、可写、异常文件描述符的集合。
- timeout:设置等待时间的结构体(struct timeval)。
- 文件描述符集合操作:
- 集合以位图形式存储,从右到左第几位代表对应fd值,用户通过以下函数操作:
void FD_CLR(int fd, fd_set *set); // 清除fd在集合中 int FD_ISSET(int fd, fd_set *set); // 检查fd是否在集合中 void FD_SET(int fd, fd_set *set); // 将fd添加到集合中 void FD_ZERO(fd_set *set); // 清空集合 - 若不关心某类事件,对应参数可传
nullptr。
- 集合以位图形式存储,从右到左第几位代表对应fd值,用户通过以下函数操作:
- timeout参数:
- 结构体含
tv_sec(秒)和tv_usec(微秒)字段。 - 若为
NULL:阻塞等待;若字段全为0:非阻塞(立即返回)。
- 返回值:
- 成功:返回状态改变的文件描述符数量。
- 返回0:超时时间内无描述符状态改变。
- 返回-1:发生错误,错误原因存于errno,涉及错误码如:
- EBADF:无效文件描述符或文件已关闭。
- EINTR:调用被信号中断。
- EINVAL:参数n为负值。
- ENOMEM:内核内存不足。
- 参数 readfds、writefds、exceptfds和 timeout的值可能变为不可预测状态,后续代码不应依赖这些值。
==使用流程:==
select代码编写测试(基于tcp实现echo回显)
设计思路
拿着select进行文件描述符的就绪监测(包括istenfd以及给用户开的普通的fd);当select等待到事件就绪就进行为新的连接开辟fd或者对普通fd的读写操作等;这里只检测读事件默认写都是就络的;但是select会更改输入参数,导致每次都要动态维护需要监测的fd,然后对d集合进行调整;因此这里采取一个数组来动态维护再进行fd集合的填充(新连接来或者有客户退出进行d数组的更新)。
测试效果
- 成功连接,为新的连接开辟新的fd。
- 普通的fd进行的读事件就绪,进行正常读取。
- 客户端退出,对应fd的读事件就绪(里面被写入了类似文件结尾的标志,读取后直接关闭这个fd,移除监测集合中对应的位置)。
源码汇总
select缺点
- 文件描述符监控机制:通过
fd_set位图实现,监控数量由sizeof(fd_set)决定(某服务器为512字节,支持最大4096个文件描述符),位图容量存在上限。 - select的主要缺点:
- 每次调用需手动设置
fd集合,使用不便; - 需将
fd集合从用户态拷贝到内核态,fd数量多时开销大; - 内核需遍历所有传入的
fd,数量多时效率低; - 支持的文件描述符数量上限较低(依赖系统实现)。
- 每次调用需手动设置
- 局限性中的优势:尽管存在上述缺点,但其跨平台兼容性好,因此仍被广泛使用。
四.多路转接之poll
poll解决了select的部分问题,主要体现在两方面:
- 突破fd数量上限:select依赖固定大小数组存储文件描述符(fd),存在上限(如1024);poll改用
pollfd结构体数组,理论上仅受系统资源限制,无固定上限。 - 实现输入输出分离:poll通过
pollfd结构体中的events(监听事件)和revents(就绪事件)字段,明确区分读写等事件类型,无需每次全量遍历检查,提升了事件处理的效率和灵活性。
poll相关函数介绍
poll 是 Linux 系统中用于 I/O 多路复用的系统调用,用于监控多个文件描述符(FD)的状态变化,避免单线程阻塞等待 I/O。以下是其核心用法总结:
函数原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
核心参数
struct pollfd *fds
如下:
#include <poll.h>
struct pollfd {
int fd;
short events;
short revents;
};
- 监控的文件描述符列表,每个元素包含三部分信息:
fd:要监控的文件描述符(如套接字、文件等)。events:感兴趣的事件(通过宏定义指定,如POLLIN、POLLOUT等)。revents:实际发生的事件(由内核填充,调用后检查该字段判断状态)。
-
nfds_t nfdsfds数组的长度(即监控的文件描述符数量)。
-
int timeout- 超时时间(单位:毫秒):
-1:永久阻塞,直到有事件发生或出错。0:立即返回(非阻塞轮询)。>0:最多等待指定毫秒数,超时返回。
- 超时时间(单位:毫秒):
返回值
< 0:发生错误(需检查errno)。= 0:超时(无任何事件发生)。> 0:就绪的文件描述符数量(fds中revents非零的元素个数)。
核心事件宏(events/revents)
使用步骤
-
初始化
pollfd数组:
为每个需要监控的文件描述符创建struct pollfd结构体,设置fd和events。struct pollfd fds[2]; fds[0].fd = stdin_fd; // 标准输入 fds[0].events = POLLIN; // 监控可读事件 fds[1].fd = socket_fd; // 套接字 fds[1].events = POLLIN | POLLOUT; // 监控可读和可写事件 -
调用
poll函数:int ret = poll(fds, 2, 5000); // 监控 2 个 FD,最多等 5 秒 -
处理返回结果:
- 若
ret > 0,遍历fds数组,检查revents判断哪些 FD 就绪:for (int i = 0; i < nfds; i++) { if (fds[i].revents & POLLIN) { // 处理可读事件 } if (fds[i].revents & POLLHUP) { // 处理连接断开 } } - 若
ret == 0:超时处理。 - 若
ret < 0:错误处理(如EINTR表示被信号中断)。
- 若
操作流程:
poll代码编写测试(基于tcp实现echo回显)
设计思路
这里只需要把对应的之前用数组维护的fd集合改成维护这个struct pollfd结构体数组,对结构体里面的变量进行初始化,更新,判断等操作即可。
测试效果
同上面没啥区别。
源码汇总
特点与适用场景
poll的优点
- 接口设计更灵活:不同于select使用三个位图表示fd_set,poll采用pollfd结构体指针,结构体内包含要监视的事件和发生的事件,避免了select“参数-值”传递方式的局限性,使用更方便。
- 文件描述符数量限制宽松:poll没有最大数量限制(实际受系统资源等影响),仅理论上限为数组大小,相比select有一定优势(select通常有固定上限如1024)。
poll的缺点
- 性能随监控数量线性下降:当监听的文件描述符数量增多时,每次调用poll都需要把大量pollfd结构从用户态拷贝到内核态;并且poll返回后需轮询整个pollfd数组来获取就绪描述符,若大量连接中只有少数就绪,效率会显著降低(如第10000个fd就绪仍需遍历全部)。
- 需手动管理就绪状态:poll不自动从就绪列表中移除文件描述符,需要开发者手动处理,增加了代码复杂度与管理成本。
- 存在不必要的拷贝开销:每次调用poll都要将大量pollfd结构在用户态和内核态之间拷贝,和select类似,当监控数量大时,这种拷贝带来的性能损耗不可忽视。
典型场景(相比epoll落后)
- 网络服务器(监控多个客户端连接)。
- 同时处理文件和网络 I/O 的程序。
与 select 的对比
poll使用数组存储 FD,无数量上限(取决于内存),而select使用fd_set(通常限制为 1024)。poll的事件模型更清晰(通过位掩码区分事件),select需重置fd_set。
通过合理使用 poll,可以实现高效的 I/O 多路复用,避免单线程阻塞导致的性能瓶颈。
五.本篇小结
- 本篇带大家简略认识五种常见的IO模型以及多路转接复用的select及poll的接口的使用,也看到了有点与缺点,基于poll略胜一筹,但是还是比不上epoll,因此,下篇介绍epoll独特之处。