什么是I/O?
I/O 是 Input/Output(输入/输出) 的缩写。在C++中,它指的是程序与外部环境(不仅仅是用户,还包括文件、网络、设备等)进行数据交换的过程。
输入 :数据从外部源(如键盘、文件、网络)流入程序。例如,从键盘读取一个字符,从文件加载数据。
输出 :数据从程序流出到外部目标。例如,在屏幕上显示文本,将数据写入文件,向网络发送数据。
五种I/O模型介绍
1.阻塞式I/O
阻塞式I/O是最传统、最简单的I/O模型。应用程序发起I/O调用后,进程会被挂起(阻塞),一直等待数据就绪且数据从内核拷贝到用户空间后才会返回,整个过程应用程序处于完全等待状态。(所有的套接字默认都是阻塞方式)这种I/O模型就像一个人去钓鱼然后一直盯着鱼竿看鱼儿是否上钩了。

特点:编程简单、一个进程/线程只能处理一个连接、资源利用率低,CPU大量时间在等待
2.非阻塞式I/O
应用程序发起I/O调用后立即返回,如果数据还未就绪则返回错误码(EWOULDBLOCK),应用程序需要不断轮询数据是否就绪,但这会消耗CPU资源,一般只有特定场景使用。但是在两次I/O调用之间可以去完成其他的事务,因此效率上会比阻塞式I/O高。这种I/O模型就像一个人去钓鱼时不时的会去看一下鱼儿是否上钩,在两次查看间可能会去刷手机也可能会去喝水。

3.信号驱动式I/O
应用程序发起I/O调用后立即返回因此不会阻塞进程(此时SIGIO依旧被特殊处理,信号处理函数被设置)当数据就绪的时候内核会发送SIGIO信号通知应用程序,应用程序在信号处理函数中进行实际的I/O操作。这种I/O模型就像一个人去钓鱼,然后当鱼儿上钩的时候铃铛就会响,这样就提醒钓鱼人去拉鱼竿了。

4.多路转接式I/O

多路转接式I/O(多路复用I/O),也称为事件驱动I/O,是构建高性能网络服务器的关键技术。使用select、poll、epoll等系统调用监控多个文件描述符,当某个或某些文件描述符就绪时,通知应用程序进行读写操作。应用程序是在调用select/poll/epoll时候阻塞,而不是在单个I/O操作上阻塞。这种I/O模型就像一个人使用一百根鱼竿去钓鱼,只要一个或多个在一定时间内的鱼标浮动就赶快拉鱼竿将鱼吊起来。

5.异步式I/O

异步 I/O 就是一种"等消息"的工作模式。当你发起一个 I/O 操作(比如读取文件、访问网络)时,你不会停下来干等着它完成,而是把这个任务交代给系统,然后转身就去忙别的事情。当系统完成这个任务后,它会通过某种方式(比如回调函数)通知你。如果使用钓鱼的例子来理解就是你是一个大老板,然后你想要去钓鱼但是你还要其他的事情需要处理(没有事情也可以),你就交给了司机小王去钓鱼,然后小王调上鱼后就打电话告诉你钓上鱼了。
总结
I/O操作时间开销 = 等待数据的时间 + 拷贝数据的时间。等待数据时间是从你发起 I/O 请求,到数据真正准备好可供读取之间所花费的时间。对于网络请求,这可能包括数据包在网络中传输的延迟、远程服务器处理请求的时间等。对于磁盘读取,这可能包括磁头寻道、磁盘旋转到正确位置的时间。拷贝数据的时间是数据从内核缓冲区(操作系统的空间)被复制到你的应用程序缓冲区(用户空间)所花费的时间。我们需要去尽量减少等待数据的时间,这样I/O的效率才能提升,异步I/O就是可以同时等待多个数据就绪,那么在总体上就减少了数据等待时间。
高级I/O
同步I/O与异步I/O
同步I/O就是发出一个调用后,如果数据没有就位,就一直处于调用状态而不会返回,一旦数据就位,立即得到返回值;换句话说,调用者需要主动等待调用结果,这期间不能做任何事情。
异步则是相反,调用 在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用 发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
阻塞I/O与非阻塞I/O
阻塞和非阻塞指的是程序在等待调用结果时候的方式。阻塞调用是指结果返回之前,当前线程会被挂起,只有调用的线程得到结果之后才会返回。非阻塞调用是指在不能立即得到结果时候,该线程并不会被挂起而是返回特定的返回值。
非阻塞I/O如何设置?
使用fcntl函数对文件描述符进行设置,一个文件描述符默认都是以阻塞的方式进行I/O。
#include
#include
int fcntl(int fd, int cmd, ... /* arg */ );
复制一个现有的描述符(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).
当我们想要设置一个文件描述符为非阻塞的时候,首先需要获取描述符的状态(这是一个位图),然后再进行设置。
void SetNoBlock(int fd)
{ int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
I/O多路转接
SELECT
select 是一种I/O多路复用技术,允许程序同时监视多个文件描述符(通常是套接字),等待一个或多个描述符就绪(可读、可写或异常)。
函数接口
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds: 最大文件描述符值加1(因为描述符从0开始)
readfds: 监视可读性的描述符集合
writefds: 监视可写性的描述符集合
exceptfds: 监视异常条件的描述符集合
timeout: 超时时间,NULL表示无限等待,0表示立即返回
返回值:执行成功返回被监视的文件描述符中就绪文件描述符的个数,如果返回0代表在timeout时间内没有文件描述符就绪;当有错误的时候返回-1并且设置errno,此时readfds、writefds、exceptfds和timeout的值变得不可预测。
关于类型fd_set

其本质是一个"位图",使用位图中对应的位来表示需要被监视的文件描述符。
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
关于timeval类型

tv_sec用于控制秒,tv_usec用于控制微秒。
注意:三个集合在select成功返回的时候会被设置(输入输出型参数),因此每次你select之前都需要将集合中需要被监视的文件描述符置为1.
socket就绪的条件
读就绪
socket内核中,接收缓冲区的字节数大于或等于低水位标记SO_RCVLOWAT,此时可以无阻塞的读该文件描述符并且返回值大于0。
socket TCP通信中,对端连接关闭(发送FIN包),此时socket也会变为可读,但recv()会返回0表示对端关闭。
监听socket上有新的连接请求。
socket上有未处理的错误。
写就绪
socket内核中,发送缓冲区的字节数大于或等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写该文件描述符并且返回值大于0。
socket的写操作被关闭(close或者shutdown).(对端关闭连接但服务器方却没有进入CLOSED状态) 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号。
socket使用非阻塞connect连接成功或者失败后。
socket上有未处理的错误。
异常就绪
socket上收到带外数据
select的特点
1.可监控文件描述符的个数取决于fd_set的大小
2.将fd加入select的监控集合的同时需要用额外的空间(一般是数组)来存放select监控集合的fd,因为select中的集合参数是输入输出型的,返回的时候参数的内容会被改变。通过数组作为源数据和fd_set进行判断哪些fd有新事件到达哪些fd没有事件到达,并且还需要根据该数组重新取得fd加入到对应的集合中。
备注:fd_set的大小是可以调整的
select的缺点1.每次调用select都需要手动设置fd集合
2.每次调用select都需要吧fd集合从用户态拷贝到内核态,这开销在fd很多的时候是不能被忽视的
3.同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
4.select支持的文件描述符数量十分有限
以下是我用select实现的一个echo服务器:
https://gitee.com/ADcoconut/new-linux-code/tree/main/selectserver
POLL
poll 是 UNIX/Linux 系统中用于 I/O 多路复用的系统调用,与 select 功能类似,但解决了 select 的一些限制。
函数接口
#include
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
}
参数:fds是一个poll函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合.nfds表示fds数组的长度.timeout表示poll函数的超时时间, 单位是毫秒(ms)
返回值:在timeout期间有描述符事件就绪返回描述符的个数,如果没有则返回零;错误返回-1并设置errno

poll的优点
1.pollfd中包含了要监视的event和发生的event,因此不需要再像select那样每次调用前都需要初始化。(但注意revents处理后需要进行清除)
2.没有文件描述符的限制,但是数量过多会有性能会下降
poll的缺点1.和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符.
2.每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中.
3.同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效 率也会线性下降.
以下是我用poll实现的一个echo服务器:
https://gitee.com/ADcoconut/new-linux-code/tree/main/pollserver
EPOLL
epoll 是 Linux 内核为处理大量文件描述符 I/O 事件而设计的高性能 I/O 多路复用机制。它是 select 和 poll 的替代品,特别适合高并发网络编程场景。
接口介绍
int epoll_create(int size)
创建epoll实例,返回文件描述符
自从linux2.6.8之后,size参数是被忽略的,且用完之后, 必须调用close()关闭。
现在多用接口int epoll_create1(int flags),其中flag是用来设置exec,防止程序替换后可以使用该文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
管理epoll监控的文件描述符集合
它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型.
参数:第一个参数是epoll_create()的返回值(epoll的句柄).
第二个参数表示动作,用三个宏来表示.
|---------------|---|---------------|
| EPOLL_CTL_ADD | 1 | 添加新的 fd 到监控列表 |
| EPOLL_CTL_MOD | 2 | 修改已监控 fd 的事件 |
| EPOLL_CTL_DEL | 3 | 从监控列表删除 fd |
第三个参数是需要监听的fd.
第四个参数是告诉内核需要监听什么事.
返回值:成功返回0;失败返回-1并设置errno。
关于epoll_event类型
typedef union epoll_data {
void *ptr; // 用户自定义数据(指针)
int fd; // 关联的文件描述符
uint32_t u32; // 32位整数
uint64_t u64; // 64位整数
} epoll_data_t;
struct epoll_event {
uint32_t events; // epoll 事件掩码
epoll_data_t data; // 用户数据(epoll_wait 返回时携带)
};
常用的evnets事件掩码
|--------------|------|----------------|
| 标志 | 说明 | 触发条件 |
| EPOLLIN | 可读事件 | 有数据可读 |
| EPOLLOUT | 可写事件 | 可写入数据 |
| EPOLLERR | 错误事件 | 发生错误(自动监控) |
| EPOLLHUP | 挂起事件 | 对端关闭连接 |
| EPOLLRDHUP | 对端关闭 | 对端关闭写端(TCP半关闭) |
| EPOLLET | 边缘触发 | 设置边缘触发模式 |
| EPOLLONESHOT | 单次触发 | 事件触发后自动禁用监控 |
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
等待 epoll 实例上的事件发生,返回就绪的事件列表
参数:参数epfd,是epoll_create创建实例的返回值------epoll实例对应的文件描述符。
参数events是分配好的epoll_event结构体数组. epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存)。
maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size。
参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).
返回值:如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败并设置errno。
Epoll的工作原理 
当数据包到达网卡时,网卡通过中断通知内核。数据经协议栈处理后放入对应socket的接收缓冲区,这会触发socket的就绪回调函数。如果是epoll监控的socket,回调函数ep_poll_callback会直接将对应的监控项(epitem)添加到epoll的就绪链表(rdllist)中,并唤醒等待的进程。当用户调用epoll_wait时,内核直接返回就绪链表中的事件,无需遍历所有监控的文件描述符。
epoll的优点
1.接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文 件描述符, 也做到了输入输出参数分离开。
2.数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频 繁(而select/poll都是每次循环都要进行拷贝)
3.事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述 符数目很多, 效率也不会受到影响.
4.没有数量限制: 文件描述符数目无上限.
epoll的工作方式
水平触发(Level_Triggered LT)当epoll检测到某个文件描述符上有事件就绪时,epoll_wait会返回通知。如果应用程序没有处理完所有就绪数据,后续的epoll_wait调用会持续返回该事件,直到数据被完全处理。(epoll默认的工作模式)
边缘触发(Edge_Triggered ET)只在文件描述符的状态发生变化时通知一次事件,例如从无数据到有数据,或从可读变为可写。一旦事件被通知,应用程序必须尽可能处理所有就绪数据,否则epoll_wait可能不会再返回,导致数据处理不及时或丢失。(在ET模式下,通常要求文件描述符设置为非阻塞模式,以避免因数据未完全读取而阻塞。 )
如果还是不太明白我们可以看下面的一个例子。
情形:我们已经把一个tcp socket添加到epoll描述符,这个时候socket的另一端被写入了2KB的数据,调用epoll_wait,并且它会返回. 说明它已经准备好读取操作,然后调用read, 只读取了1KB的数据,继续调用epoll_wait......
水平触发Level Triggered 工作模式(epoll默认状态下就是LT工作模式)
当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用仍然会立刻返回并通知socket读事件就绪.直到缓冲区上所有的数据都被处理完, 支持阻塞读写和非阻塞读写。
边缘触发Edge Triggered工作模式
epoll_wait 才不会立刻返回. epoll_wait 时, 如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式.当epoll检测到socket上事件就绪时, 必须立刻处理. 如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用epoll_wait的时候,epoll_wait 不会再返回了!也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.。
ET的性能比LT性能更高(epoll_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll.只支持非阻塞的读写!
LT和ET对比
LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.
相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到
每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.另一方面, ET 的代码复杂程度更高了.
理解ET模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 "工程实践" 上的要求。
假设这样的场景: 服务器接受到一个10k的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 不会发送第 二个10k请求。

如果服务端写的代码是阻塞式的read, 并且一次只 read 1k 数据的话(read不能保证一次就把所有的数据都读出来, 参考 man 手册的说明, 可能被信号打断), 剩下的9k数据就会待在缓冲区中。

此时由于 epoll 是ET模式, 并不会认为文件描述符读就绪. 冲区中. 直到下一次客户端再给服务器写数据. epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓 epoll_wait 才能返回。
但是!我们做出的假设是没有回复就不会发送下一次请求!因此服务器必须读完全部请求并做出回复,客户端才会发送下一次请求,这样就逼着服务器需要一次事件就绪要读取完全部数据。
所以, 为了解决上述问题(阻塞read不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮询的方式来读缓冲区, 保证一定能把完整的请求都读出来。
epoll的适用场景
epoll适合高并发网络服务器、需要处理大量I/O事件的系统、需要大量处理短链接的服务。但如果只是系统内部,服务器和服务器之间的通信,只有少数连接的情况就不太适用了。
以下是我用epoll(LT模式)实现的一个echo服务器:
https://gitee.com/ADcoconut/new-linux-code/tree/main/epollserver