IO次数会影响程序的效率,在编程中往往会尽量减少IO次数,用以提高程序的效率,例如缓冲区,就是减少IO次数提高效率的一种方式;而IO影响效率的最大原因其实是因为IO=等+拷贝,在进行IO时往往需要拷贝的数据就绪,或者其他资源就绪,才能进行拷贝,所以其中等是占据了IO的最大时间的;
1.五种IO模型
IO模型大致可以分为以下五种:
1.阻塞式
2.非阻塞,非阻塞轮询
3.信号激发式
4.异步IO(操作系统做等待动作)
5.多路复用/转接
主要讲解2.非阻塞轮询与4.多路复用
1.1 阻塞式 I/O (Blocking I/O)简述
- 描述:在阻塞式 I/O 模型中,当一个应用程序执行 I/O 操作(如读写文件、网络数据等)时,它会一直等待直到 I/O 操作完成。也就是说,程序会阻塞在该 I/O 操作上,直到数据被完全读取或写入。
- 优缺点:
- 优点:编程模型简单,易于理解和实现。
- 缺点:阻塞会导致 CPU 被空闲浪费。每次进行 I/O 操作时,进程必须等待,不能执行其他任务,效率较低。
例子:使用
read()
或write()
进行 I/O 操作时,如果没有数据可读,或者目标设备不可写,程序会一直等待,直到数据准备好。
1.2 非阻塞 I/O 和 非阻塞轮询
- 描述:在非阻塞 I/O 模型中,当执行 I/O 操作时,如果资源当前不可用(例如文件、网络等没有数据),操作会立即返回错误,通常是
EAGAIN
或EWOULDBLOCK
,而不是阻塞进程的执行。这样,应用程序可以继续执行其他任务,并定期检查 I/O 操作是否完成(即轮询)。- 优缺点:
- 优点:应用程序可以继续执行其他任务,避免了 I/O 操作的等待时间,适合需要高响应能力的场景。
- 缺点:需要不断地轮询检查 I/O 操作是否完成,可能会增加 CPU 的使用,造成一定的性能消耗。
例子:通过
open()
加上O_NONBLOCK
标志打开文件描述符,在调用read()
或write()
时,如果无法立即执行,函数会返回EAGAIN
或EWOULDBLOCK
错误,应用程序需要手动检查并再次尝试。
下面我们通过一些代码和例子来实现一下:
要实现非阻塞轮询的IO方式,只需要在打开文件时设置文件的flag标志位,就可以改变文件在不同情况下的等待方式,而想设置非阻塞等待方式,我们可以这样操作:
1.在open打开文件时设置O_NONBLOCK标志位
int openNoBlock(const string& file)
{
int fd=open(file.c_str(),O_RDONLY|O_NONBLOCK,0666);
if(fd<0)
{
cout<<"open fail"<<endl;
exit(-1);
}
return fd;
}
上面的函数在打开file中路径时设置了O_NONBLOCK标志位,让file以非阻塞和只读的方式打开,之后在对这个file文件进行读操作时,就可以使用while循环非阻塞式的等待;
2.使用fcntl函数直接设置已经打开了的文件
void setNonBlock(int fd)//可以传递不同文件,修改不同文件为非阻塞等待
{
int flag = fcntl(fd, F_GETFL);
if (flag < 0)
{
lg(ERROR, "fcntl file erroron:%d,%s", errno, strerror(errno));
exit(-1);
}
else
{
fcntl(0, F_SETFL, flag | O_NONBLOCK);
}
}
完整代码:
network_code/IO_2025_2_27/testFcntl · future/Linux - 码云 - 开源中国
我们可以看到通过上面的fcntl函数也是可以将某个文件描述符代表的文件直接设置为非阻塞状态的;
运行现象:
程序在一直打印的情况下读取到了键盘输入的字符"asd"并在读取到后直接输出;
还需要注意的是:使用非阻塞轮询时read返回值小于0并不一定是read错误,还有可能是文件未就绪,会返回
EWOULDBLOCK宏;
1.3 信号激发式 (Signal-driven I/O)简述
- 描述:在信号驱动 I/O 模型中,I/O 操作不会阻塞进程,且进程不会主动轮询 I/O 操作。相反,操作系统通过信号通知进程何时进行 I/O 操作。例如,当数据准备好时,内核通过发送一个信号(如
SIGIO
)通知应用程序可以进行 I/O 操作。- 优缺点:
- 优点:进程不需要轮询或持续检查 I/O 状态,降低了 CPU 消耗。
- 缺点:信号处理机制可能会增加程序的复杂性,程序必须能正确处理这些信号。
例子:这个情况没有使用过,但是似乎和之前使用signal函数的情况差不多,在资源准备就绪的时候通过发送信号唤醒IO;
1.4 异步 I/O (Asynchronous I/O)简述
- 描述:在异步 I/O 模型中,应用程序发起 I/O 操作后,不需要等待操作完成,操作系统会在 I/O 完成时通知应用程序。异步 I/O 完全依赖操作系统来管理 I/O 操作的等待,应用程序不需要进行轮询或处理信号,而是通过回调函数、事件通知等机制来处理 I/O 操作的结果。将IO交给OS来处理,程序自己处理其他事件;
- 优缺点:
- 优点:极大地减少了 I/O 等待时间,CPU 可以继续执行其他任务,直到 I/O 操作完成。
- 缺点:实现复杂,应用程序需要能够处理回调或事件通知,并且操作系统也需要支持异步 I/O。
1.5 多路复用 (Multiplexing) / 多路转接
- 描述:多路复用是一种允许单一进程同时处理多个 I/O 操作的机制。在这个模型中,进程通过
select()
或poll()
等系统调用来检查多个文件描述符,判断哪些文件描述符准备好进行读写操作。当有一个或多个文件描述符准备好时,进程就会进行操作。- 优缺点:
- 优点:适用于需要同时处理多个 I/O 操作的场景,能够避免阻塞和过多的线程或进程切换。
- 缺点:虽然多路复用减少了阻塞和线程切换的开销,但它的实现仍然需要轮询文件描述符,且当描述符数量非常多时,性能可能会下降。
例子:
select()
或poll()
等调用允许程序监视多个文件描述符,直到某个描述符有数据可以读取,进程就可以进行相应的 I/O 操作。使用情况:一般是在网络服务器,如 Web 服务器,需要同时处理多个客户端请求时;
1.5.1 select
参数说明:
nfds:监视的文件描述符集合中最大的文件描述符加 1。通常用
FD_SETSIZE
(例如:1024是linux中大小)来定义文件描述符的最大数量,nfds
是文件描述符的范围,从 0 到nfds-1
readfds:指向一个文件描述符集合,
select()
会检查这些文件描述符是否可以进行读取操作。文件描述符会被加入集合(通常使用FD_SET
,FD_CLR
,FD_ISSET
等宏来操作集合)
writefds:指向一个文件描述符集合,
select()
会检查这些文件描述符是否可以进行写入操作。exceptfds:指向一个文件描述符集合,
select()
会检查这些文件描述符是否有异常条件(例如,出错等)timeout:指定超时时间,用来限制等待文件描述符变为“准备就绪”的时间。
timeout
是一个结构体,包含两个字段:tv_usec
:表示微秒数。tv_sec
:表示秒数。struct timeval { time_t tv_sec; // 秒数 suseconds_t tv_usec; // 微秒数 };
如果
timeout
为NULL
,则select()
会一直阻塞,直到有文件描述符就绪或者信号中断。如果timeout
的值为{0, 0}
,则select()
会立即返回,不会阻塞。
返回值:
select与下面要讲的poll的返回值意义只在于辨别是否有准备就绪的文件,意义并不大
成功:
select()
返回就绪的文件描述符数量,表示多少个文件描述符已经准备好进行相应的操作(读取、写入或异常)。失败:如果发生错误,返回
-1
,并设置errno
来指示具体错误类型。
errno
错误代码:
EBADF
:其中某个文件描述符无效。
EINVAL
:nfds
参数值无效。
ENOMEM
:内存不足。使用注意事项:
select()
是一种阻塞式的 I/O 多路复用机制,适用于监控少量的文件描述符。当文件描述符数量较多时,select()
的性能可能会降低。- 为了避免重复设置文件描述符集合,通常会在每次调用
select()
前使用FD_ZERO()
初始化集合,并使用FD_SET()
添加文件描述符。
代码实现:
network_code/IO_2025_2_27/select · future/Linux - 码云 - 开源中国
现象:
实现了允许程序监视多个文件描述符,直到某个描述符有数据可以读取,进程就可以进行相应的 I/O 操作。
select函数虽然可以完成对多个文件描述符的监视,但是这样的监视面临着大量的拷贝,需要不断地将fd_set这个内核数据结构从用户与内核之间拷贝,所以会大大降低效率;但其实这不是IO方式的问题,而是这个函数参数的问题,函数的fds表参数都是输入输出型参数所以会导致fds表被覆盖,所以需要不断的进行拷贝修改fds表;为了提高select的效率,我们接下来看看poll对其的提升(select改进的历史);
1.5.2 poll
参数:
fds: 一个指向
struct pollfd
数组的指针。每个struct pollfd
结构表示一个文件描述符及其感兴趣的事件。struct pollfd { int fd; /* file descriptor */ short events; /* requested events */ short revents; /* returned events */ };
其中events是发送给poll,告诉它需要关系的事件,当某些事件就绪时就会返回revents;也就是说我们只需要填数据到events中,调用poll后返回的pollfd数据结构中的revents参数就会告诉我们哪些事件就绪了;
events与revents都是宏标志位,是short类型可以标识16个标志;
下面是参数可以填写的事件:
nfds: 要监视的文件描述符集合的大小(即
fds
数组中元素的个数)timeout: 超时时间,单位为毫秒。
如果
timeout
为-1
,表示无限期等待,直到有事件发生。如果
timeout
为0
,表示不等待,立即返回。如果
timeout
为正数,表示最大等待时间(以毫秒为单位)返回值:
- 成功: 返回就绪文件描述符的数量,即有多少文件描述符的事件已发生。
- 失败: 返回
-1
,并设置errno
以指示错误。- 超时: 如果在指定时间内没有事件发生,返回
0
。
实现代码:
network_code/IO_2025_2_27/poll · future/Linux - 码云 - 开源中国
实现现象:
成功实现了和前面select一样的现象,并且代码简化了很多,而且提高了效率,这归咎于select的参数设置的并不是很好,文件描述符fd与关心的事件是分离的原因,而poll通过pollfd表这个数据结构将fd与fd需要关心的事件联系起来放在一个数据结构中,让用户使用更加方便,大大改善了select的不足;
poll成功的解决了select的这些缺点:
1.等待的fd是有上限
2.输入输出型参数比较多数据拷贝的频率比较高
3.输入输出型参数比较多每次都要对关心的fd进行事件重置
4·用户层,使用第三方数组管理用户的fd,用户层需要很多次遍历,内核中检测fd事件就绪,也要遍历
1.5.3 epoll
目前服务器编写公认的效率最高的多路转接方案;
epoll模型不再需要用户来维护需要关注的文件描述符表,而是将这个文件描述符表交给epoll模型来维护,只开放修改接口给用户,下面是交给用户的接口:
epoll_create:
参数:
size:该参数的含义在现代 Linux 系统中已不再重要,早期用于指定
epoll
实例的大小,实际上现在的 Linux 内核忽略它。因此,size
只需传递一个大于 0 的值,通常设置为1
。但在一些文档或旧版本的内核中,它曾表示用于epoll
内部的事件队列的大小。
返回值:
- 成功时,返回一个非负整数,这个整数是
epoll
实例的文件描述符。你可以使用这个文件描述符在后续的操作(如epoll_ctl
和epoll_wait
)中进行访问和操作。- 失败时,返回
-1
,并设置errno
以指示错误原因。
epoll_wait:
参数:
epfd
:
这是由epoll_create
或epoll_create1
返回的epoll
实例的文件描述符,表示你要等待的事件来源。
events(输出型参数)
:返回已经就绪的文件描述符和事件:下面是
events的类型数据结构
epoll_event中events中关心的事件一般有这些:
EPOLLIN
:对应文件描述符可读。
EPOLLOUT
:对应文件描述符可写。
EPOLLERR
:对应文件描述符发生错误。
EPOLLHUP
:对应文件描述符被挂起。
EPOLLET
:边缘触发模式。
EPOLLONESHOT
:一次性触发事件,事件发生后自动从epoll
中移除。而data作用是:可以存放用户自定义的数据,通常是一个
void*
类型的指针,方便在处理事件时关联文件描述符或其他数据。
maxevents
:
指定events
数组的大小,表示可以返回的最大事件数。如果发生的事件数超过这个值,epoll_wait
会返回最大值。这个值通常应该设置为你期望处理的最大事件数。
timeout
:
指定等待事件的超时值,单位是毫秒。可以设置以下几种:
-1
:无限等待,直到至少一个事件发生。0
:非阻塞模式,立即返回,不等待任何事件。- 正整数:表示等待时间,单位是毫秒。等待的最大时间。
返回值:
成功时: 返回发生的事件数,也就是
epoll_event
数组中填充的事件个数。返回的事件数可以小于maxevents
,表示当前发生的事件数目。失败时: 返回
-1
,并设置errno
来表示错误原因。
epoll_ctl:
参数说明:
epfd
(int):epfd
是由epoll_create()
或epoll_create1()
创建的epoll
实例的文件描述符,用来标识一个epoll
实例。
op
(int):op
表示操作类型,决定了epoll_ctl
对文件描述符fd
的处理方式,可能的值为:
EPOLL_CTL_DEL
:从epoll
实例中删除文件描述符,停止对其的监视。EPOLL_CTL_MOD
:修改已经添加到epoll
实例中的文件描述符的监视事件。EPOLL_CTL_ADD
:将文件描述符fd
添加到epoll
实例中,开始监视该文件描述符。
fd
(int):
fd
是需要进行操作的文件描述符,通常是一个套接字、文件或管道的文件描述符。- 这个文件描述符会在
op
为EPOLL_CTL_ADD
或EPOLL_CTL_MOD
时进行处理。
event
(struct epoll_event *):
event
是一个指向epoll_event
结构体的指针,该结构体定义了感兴趣的事件以及附加的用户数据。epoll_event
结构体的定义如下:struct epoll_event { __uint32_t events; // 事件类型,如 EPOLLIN, EPOLLOUT 等 epoll_data_t data; // 用户数据,可以存储文件描述符、指针等 };
events
字段定义了感兴趣的事件类型,如:
EPOLLIN
:可读事件EPOLLOUT
:可写事件EPOLLERR
:错误事件EPOLLHUP
:挂起事件- 这里的事件是和上面的epoll_wait的event相同的;
data
字段可以用于存储与文件描述符相关的任意数据,通常存储文件描述符或者其他相关的结构。data的数据结构也是和上面的epoll_wait中的data一样的;返回值:
- 成功:返回
0
。- 失败:返回
-1
,并设置errno
来指示错误类型。常见的
errno
错误值:
EBADF
:epfd
或fd
不是有效的文件描述符。参数无效,通常是由于
EINVAL
:op
的值不正确,或者event
为NULL
,或其它不符合要求的情况。内存不足,无法分配内存来完成操作。
ENOMEM
:权限不足,通常发生在没有足够权限执行某些操作时。
EPERM
:
了解了epoll模型的接口后,接下来了解一下epoll模型的底层原理:
epoll模型其实和poll与select类似,都需要一个数据结构来存储用户要关心的文件描述符,但epoll不同与前面两种多路转接方式的地方是,epoll不再需要用户对这个文件描述符表数据结构直接进行维护,用户只需要调用epoll开放的接口来对这个文件描述符表来修改,而epoll模型底层维护的文件描述符表其实是一颗红黑树,红黑树的查找效率非常高(logn),在用户关心的文件描述符的某个事件在这个文件描述符表中可以查找到并且事件就绪时,这个时候就可以将这个文件描述符放入就绪队列,用户拿到就绪队列,就可以进行相对应事件的操作了
原理图片(复习用):
图片/epoll原理.png · future/my_road - 码云 - 开源中国
下面是我实现的epoll的代码:
network_code/IO_2025_2_27/4_epoll · future/Linux - 码云 - 开源中国
1.5.4 epoll工作模式
epoll有两种工作模式:
LT水平触发(epoll默认模式)
ET边缘触发
复习用:
图片/epoll工作模式.png · future/my_road - 码云 - 开源中国
其中LT模式,是epoll的默认模式,当我们epoll中关心的文件描述符中关心的事件就绪时,epoll会不断返回这些就绪的文件描述符的个数,通过epoll中维护的数据结构告诉我们哪些文件描述符的哪些事件就绪了,LT模式的重点就在于这个不断上,只有事件就绪我们不对这个事件做处理,那么epoll就会不断提醒(只要调用就会返回事件就绪);
而对于ET模式,当epoll中关心的文件描述符中关心的事件就绪时,epoll只会提醒一次,就是说我们下次调用epoll时,如果我们的就绪事件没有任何更新,那么epoll就不会再提醒了;
我们可以结合物理上的边缘触发和水平触发来理解,LT模式,电压高是就一直为高电平(有事件就绪就一直提醒),ET模式,只有电压发生变化时电平才发生改变(只有就绪事件发生改变时才会进行提醒)
那么我们在实际代码实现中,对于epoll我们应该如何设置这两种模式呢?首先LT模式的不断提醒的机制,虽然可以方便我们程序员编写代码,但是这样的机制其实相比于ET模式,效率会低一些;那么在编写代码时如果想使用ET模式编写(因为LT是默认模式所以一般不用直接设置),应该如何设置呢?我们在为epoll添加事件时就需要添加一个EPOLLET边缘触发模式的事件,让epoll以边缘触发ET的模式来关心事件;其次由于ET模式只在就绪事件发生变化(增加)时才提醒,如果我们处理就绪事件时只处理了一部分,那么这部分的就绪事件由于是一直存在的如果没有新的就绪事件添加,那么就会导致epoll不会再提醒,所以我们在处理就绪事件时需要一次处理完(用循环或者设置一个很大的缓冲区,一次将就绪事件内容全部获取走);但这样还不够,如果当我们获取事件时获取发现事件全部获取完了,我们继续获取由于获取函数的底层一般是阻塞的(例如read,如果文件暂时没有数据可以获取那么就会阻塞住),而我们的epoll是用来给服务器接受链接的,所以是万万不能阻塞的,那么我们就需要将我们的获取就绪事件的文件描述符设置为非阻塞模式(如何设置?可以通过我们前面讲的1.2中的fcntl函数来设置);所以自此我们的ET模式就设置好了;
设置ET模式:
1.在添加关心事件时多添加一个EPOLLET(例如我们关心读那么就添加EPOLLIN|EPOLLET)
2.将获取事件的文件描述符设置为非阻塞模式(fcntl设置O_NONBLOCK进fd的flag中)
小tip:LT模式效率设置文件描述符为非阻塞后像ET模式一样可以快速全部将就绪事件全部处理完,之后LT模式就不会提醒了,那么此时LT模式与ET模式就基本上没有什么区别了;
我们讲解了这么多接下来我们看看基于ET模式的代码实现吧:
1.5.5 reactor
这是一个基于epoll的ET模式的服务器,通过epoll不断添加或者去除关心的文件描述符中的事件,当这些事件就绪时进行提醒,从而形成了一个可以同步式处理多个用户请求的服务器;这样相比与我们前面使用的线程池来接收多个链接消耗会更小并且也可以接受多个链接;
代码实现:
network_code/IO_2025_2_27/5_reactor · future/Linux - 码云 - 开源中国
代码中有注释我就不详细讲解了,可以让AI来辅助理解;
这份代码值得注意的地方(复习用):
这份代码中对于写事件的处理:
对于智能指针的问题:
对于智能指针我们最好是先创建一个智能指针让后将其存储起来,创建这个智能指针的原因是我们要保存一份引用计数来确保智能指针指向的对象不会被析构;当我们还需要拷贝或者使用这个对象时,我们采用拷贝智能指针的方式来间接使用对象,以此来保证内存安全,当真正不需要这个对象时我们就可以对这个最后的保存进行释放即可,这样防止二次析构的情况出现;