为什么需要IO多路复用
首先我要向大家输出一个IO的概念:IO在我看来就是 等 + 拷贝(简化IO模型),等就是等待系统资源(设备。数据等)就绪(比如等待文件描述符就绪,等待数据就绪),拷贝就是拷贝数据资源(比如将你写的把内容将它从写缓冲区拷贝到读缓冲区,让用户可以正常读取)。如果想要IO速度快,减少等待时间是提高IO性能的关键。等待时间通常包括等待硬件资源(如磁盘、网络)就绪的时间以及等待操作系统调度的时间。在IO密集型应用中,多个IO操作可能同时进行。减少每个操作的等待时间可以显著提高整体吞吐量,因为更多的操作可以在相同的时间内完成。相比之下,拷贝时间通常是固定的,且受限于系统硬件和架构的约束,其改进空间相对较小。使用更高效的IO多路复用机制是减少等待时间的有效方式:操作系统为我们提供了select、poll、epoll三个函数,这三个函数都是用于IO多路复用的机制,它们允许单个进程或线程监视多个文件描述符,并在一个或多个文件描述符准备就绪时通知程序。从左到右(select、poll、epoll),它们在性能和功能上确实逐渐增强。(除了使用更高效的IO多路复用机制外,还有其他策略可以减少等待时间,如使用异步IO、非阻塞IO、多线程/多进程、以及优化数据结构和算法以减少CPU负载等。对于异步IO、非阻塞IO不了解的可以看我这篇博客:http://t.csdnimg.cn/WfwzE,它介绍了五种IO模型)。
select
select的作用
select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;
select函数模型:
#include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数解释:
- 参数nfds是需要监视的最大的文件描述符值+1;
- rdset,wrset,exset分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描
- 述符的集合;
- 参数timeout为结构timeval,用来设置select()的等待时间
参数timeout取值:
- NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;
- 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
- 特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回。
函数返回值:
- 执行成功则返回文件描述词状态已改变的个数
- 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
- 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测。
错误值可能为:
- EBADF 文件描述词为无效的或该文件已关闭
- EINTR 此调用被信号所中断
- EINVAL 参数n 为负值。
- ENOMEM 核心内存不足
操作fd_set的接口
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
- 如果指定的文件描述符
fd
在集合set
中被设置(即,对应的位为真),则函数返回非零值(通常是1)。- 如果指定的文件描述符
fd
在集合set
中未被设置(即,对应的位为假),则函数返回零。void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
fd_set结构:
其实这个结构体就是一个整数数组,更准确的来说是一个“位图”,长整型的每个位的位置代表的是文件描述符的值,这个位的值(1或者0)代表是否关心(值为1代表关心反之则是不关心)这个文件描述符的状态。
timeval结构:
struct timeval { time_t tv_sec; /* seconds */ suseconds_t tv_usec; /* microseconds */ };
直接初始化
struct timeval tv = {5, 0};//5秒0毫秒
单独赋值#include <sys/time.h> struct timeval tv; tv.tv_sec = 10; // 10秒 tv.tv_usec = 500000; // 500,000微秒,即0.5秒
select的缺点
- 每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便.
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- select支持的文件描述符数量太小
select使用示例
检测标准输入
#include <iostream>
#include <unistd.h>
#include <sys/select.h>
using namespace std;
int main()
{
fd_set read_fd;
FD_ZERO(&read_fd); // read_fd的全部位置零
FD_SET(0, &read_fd); // 将文件描述符0(表中输入)加入read_fd"位图"表示要关心文件描述符0的状态
while (1)
{
cout << ">";
fflush(stdout);//刷新标准输出缓冲区
int ret = select(1, &read_fd, nullptr, nullptr, nullptr);
if (ret < 0)
{
cerr << "select fail" << endl;
return 1;
}
if(FD_ISSET(0, &read_fd))//判断0号文件描述符是否就绪
{
char buff[1024];
int n = read(0, buff, sizeof(buff) - 1);
if(n > 0)
{
buff[n] = 0;
cout << "input:" << buff << endl;
}
else
{
cerr << "read fail" << endl;
}
}
else
{
cout << "is not ready" << endl;
continue;
}
}
return 0;
}
结果:
poll
poll函数接口
#include <poll.h> 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).
events和revents的取值
返回结果
- 返回值小于0, 表示出错;
- 返回值等于0, 表示poll函数等待超时;
- 返回值大于0, 表示poll由于监听的文件描述符就绪而返回
poll的优缺点
优点:
- pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式. 接口使用比select更方便.
- poll并没有最大数量限制 (但是数量过大后性能也是会下降).
缺点:
poll中监听的文件描述符数目增多时
- 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符.
- 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中.
- 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降.
poll使用示例
#include <poll.h>
#include <unistd.h>
#include <iostream>
using namespace std;
int main()
{
struct pollfd poll_fd;
poll_fd.fd = 0;
poll_fd.events = POLLIN;
for (;;)
{
int ret = poll(&poll_fd, 1, 1000);
if (ret < 0)
{
perror("poll");
continue;
}
if (ret == 0)
{
cout << "poll timeout" << endl;
continue;
}
if (poll_fd.revents == POLLIN)
{
char buf[1024] = {0};
read(0, buf, sizeof(buf) - 1);
cout << "stdin: " << buf << endl;
}
}
return 0;
}
结果:
epoll
epoll_create
函数原型
#include <sys/epoll.h> int epoll_create(int size);
功能
epoll_create
通过调用内核接口,在内核中创建一个新的epoll实例,并返回一个非负的文件描述符(句柄),该描述符用于后续对epoll实例的操作。参数
size
参数在Linux 2.6.8及以后的版本中已被忽略,但调用时仍需传入一个大于0的值。这个参数原本用来告诉内核这个监听的数目一共有多大,即事件表中可以关注的最大文件描述符数。返回值:
成功时,
epoll_create
返回一个非负的文件描述符,即epoll
句柄;失败时,返回-1,并设置errno
以指示错误。注意:
epoll
句柄是一个重要的系统资源,使用完毕后应使用close
系统调用进行关闭,以避免资源泄漏。
epoll_ctl
函数原型
#include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数功能
epoll_ctl
是Linux系统下的一个系统调用,用于控制epoll实例中文件描述符的事件。它允许程序向epoll实例中添加、修改或删除文件描述符及其相关的事件,从而实现对多个文件描述符上I/O事件的高效管理。参数说明
- epfd:由
epoll_create
系统调用返回的文件描述符(epoll句柄),代表要操作的epoll实例。- op:表示要执行的操作类型,可以是以下三个宏之一:
EPOLL_CTL_ADD
:向epoll实例中添加一个新的文件描述符。EPOLL_CTL_MOD
:修改epoll实例中已存在的文件描述符的事件。EPOLL_CTL_DEL
:从epoll实例中删除一个文件描述符。- fd:要操作的文件描述符,即要添加到epoll实例中、要修改其事件或要从epoll实例中删除的文件描述符。
- event:指向
epoll_event
结构体的指针,用于指定要添加或修改的文件描述符的事件类型和其他相关信息。events可以是以下几个宏的集合:
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
- EPOLLOUT : 表示对应的文件描述符可以写;
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
- EPOLLERR : 表示对应的文件描述符发生错误;
- EPOLLHUP : 表示对应的文件描述符被挂断;
- EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
- EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里.
epoll_event
结构体typedef union epoll_data { void *ptr; int fd; __uint32_t u32; __uint64_t u64; } epoll_data_t; struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
返回值
- 成功:返回0。
- 失败:返回-1,并设置
errno
以指示错误原因。可能的错误码包括EBADF
(无效的文件描述符)、EEXIST
(尝试添加已存在的文件描述符)、EINVAL
(无效的参数)等。
epoll_wait
函数原型
#include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
函数功能
监听I/O事件
参数说明
- epfd:这是由
epoll_create
或epoll_create1
创建并返回的epoll实例的文件描述符。通过这个描述符,可以向epoll实例中添加、删除或修改要监视的文件描述符。- events:这是一个指向
epoll_event
结构数组的指针,用于接收准备就绪的事件。epoll_event
结构包含与事件相关的文件描述符和数据。在调用epoll_wait
后,events
数组会被填充为准备就绪的文件描述符和它们关联的事件(例如读就绪、写就绪等),即它是一个输出型参数。- maxevents:这个参数指定了
events
数组可以容纳的最大事件数。epoll_wait
最多会返回这个数目的准备就绪事件。如果少于这个数目的事件准备就绪,那么实际返回的事件数会少于maxevents
。如果这个参数设置为0,那么epoll_wait
将立即返回,不等待任何事件发生。- timeout:这个参数指定了
epoll_wait
在没有事件准备就绪时应等待的最长时间(以毫秒为单位)。如果设置为-1,epoll_wait
将无限期地等待,直到至少有一个事件准备就绪。如果设置为0,epoll_wait
将立即返回,不等待任何事件发生。如果设置为一个正整数N,epoll_wait
将等待最多N毫秒。返回值
- 成功:返回值为
ssize_t
类型,表示实际ready的文件描述符的数量。如果返回值为0,表示在指定的超时时间内没有事件发生。- 失败:返回值为-1,并设置全局变量
errno
以指示错误类型。可能的错误码包括EBADF
(epfd不是有效的文件描述符)、EFAULT
(具有写许可权不能访问事件指向的存储区)、EINTR
(信号处理程序中断了该调用)、EINVAL
(epfd不是epoll文件描述符,或者maxevents小于或等于零)等。
使用顺序
- 调用epoll_create创建一个epoll句柄;
- 调用epoll_ctl, 将要监控的文件描述符进行注册;
- 调用epoll_wait, 等待文件描述符就绪
epoll的两种工作方式
水平触发(LT:Level Triggered)
epoll默认状态下就是LT工作模式.
特点
- 持续触发:一旦有事件发生,内核会持续通知应用程序,直到应用程序处理了所有的数据或文件描述符状态发生变化。
- 编程简洁:由于内核会持续通知,编程时不需要担心错过事件,因此编程模型相对简洁。
- 支持阻塞和非阻塞socket:LT模式既可以在阻塞socket上使用,也可以在非阻塞socket上使用。
边缘触发(ET:Edge Triggered)
如果我们在epoll_ctl将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式
特点
- 一次触发:一次事件只触发一次通知,如果事件(如可读事件)发生后,应用程序没有及时处理(即没有清空缓冲区),那么内核不会再次发送通知,直到有新的数据到达或文件描述符状态再次改变。
- 高效性:由于减少了不必要的通知,ET模式在处理大量并发连接时更为高效。
- 要求非阻塞socket:使用ET模式时,通常需要将socket设置为非阻塞模式,以避免在处理完数据前阻塞进程
例子说明
假如有这样一个例子:
我们已经把一个tcp socket添加到epoll描述符
这个时候socket的另一端被写入了2KB的数据
调用epoll_wait,并且它会返回. 说明它已经准备好读取操作
然后调用read, 只读取了1KB的数据
继续调用epoll_wait......LT会这样做:
由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait
仍然会立刻返回并通知socket读事件就绪,直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回ET会这样做:
虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候,
epoll_wait 不会再返回了.也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会
LT VS ET
LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.
相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到
每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.
另一方面, ET 的代码复杂程度更高了
epoll使用例子
监视标准输入流读读事件
代码
#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>
#include <iostream>
#include <cstring>
using namespace std;
int main()
{
int epoll_fd = epoll_create1(0); // 创建一个 epoll 实例
if (epoll_fd == -1) {
perror("epoll_create1");
return 1;
}
struct epoll_event event;
struct epoll_event events[10]; // 存放就绪的文件描述符
// 监听标准输入(stdin)
event.data.fd = 0; // 文件描述符为 0,即 stdin
event.events = EPOLLIN; // 监听可读事件
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, 0, &event) == -1) {
perror("epoll_ctl: add");
return 1;
}
for (;;) {
int nfds = epoll_wait(epoll_fd, events, 10, 1000); // 等待事件发生
if (nfds == -1) {
perror("epoll_wait");
continue;
}
if (nfds == 0) {
cout << "epoll timeout" << endl;
continue;
}
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == 0 && (events[i].events & EPOLLIN)) {
char buf[1024] = {0};
ssize_t count = read(0, buf, sizeof(buf) - 1);
if (count > 0) {
buf[count] = '\0'; // 确保字符串正确结束
cout << "stdin: " << buf << endl;
}
}
}
}
close(epoll_fd); // 关闭 epoll 实例
return 0;
}
结果