多路复用(二)
- 1. 多路复用 - poll
- 功能
- 函数原型
- 关于 pollfd 结构体类型
- poll 代码框架
- 一些示例代码
- poll相较于select优点
- poll的缺点
- 2. 多路复用 - epoll
- 关于epoll的三个系统调用
- 创建 - epoll_create
- 控制 - epoll_ctl
- 函数原型
- 关于 epoll_event 结构体
- 等待 - epoll_wait
- 函数原型
- wait 过程
- 3. epoll 原理
- 两个核心成员
- 过程
- 回调机制
- 注册回调函数
- 事件触发与回调函数调用
- 回调机制优点
- 4. epoll 代码框架
- 5. epoll 的优点
- 6. epoll 的工作方式
- Level Triggered - 水平触发(LT 工作模式)
- Edge Triggered - 边缘触发(ET 工作模式)
- 对比 LT 和 ET
1. 多路复用 - poll
功能
- 主要作用和select一样,只负责等待事件就绪,不负责拷贝
- 但主要解决select两个缺点:
- select 支持文件数量太少
- 接口调用麻烦
函数原型
#include <poll.h>int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- fds:输入输出型参数,这是一个指向struct pollfd类型数组的指针,每个数组元素代表一个要监视的文件描述符及其相关的事件和状态信息。
- nfds:表示fds数组中元素的个数,即要监视的文件描述符的数量。
- timeout:整型变量,指定poll函数的超时时间,单位是毫秒。
- 如果设置为 -1,则表示poll函数会一直阻塞,直到有事件发生或被信号中断;
- 如果设置为 0,则poll函数会立即返回,不阻塞;
- 如果设置为大于0的值,则表示poll函数会阻塞指定的毫秒数,直到有事件发生或超时
关于 pollfd 结构体类型
struct pollfd {int fd; /* 文件描述符 */short events; /* 监视的事件 */short revents; /* 实际发生的事件 */
};
- fd:要监视的文件描述符,可以是任何有效的文件描述符,如标准输入输出、网络套接字等。
- events:指定要监视的事件类型,可以是以下几种事件的按位或组合
- POLLIN:表示对应的文件描述符有数据可读。(读关心)
- POLLOUT:表示对应的文件描述符可以写入数据。(写关心)
- POLLERR:表示对应的文件描述符发生了错误。(错误关心)
- POLLHUP:表示对应的文件描述符被挂断或关闭。
- POLLNVAL:表示对应的文件描述符无效。
- revents:这是一个由poll函数在返回时填充的字段,用于表示实际发生在该文件描述符上的事件。程序可以根据revents的值来判断具体发生了哪些事件,并进行相应的处理。
- 返回值:poll函数的返回值表示满足事件条件的文件描述符数量。如果返回值大于0,则表示有相应的文件描述符发生了指定的事件,可以通过检查revents字段来确定具体是哪些文件描述符和事件;如果返回值为0,则表示在指定的超时时间内没有任何文件描述符发生事件;如果返回值为-1,则表示poll函数调用失败,此时可以通过errno变量获取错误码,以确定具体的错误原因。
poll 代码框架
- 首先需要一个 pollfd 类型数组 fd_array[] 来装下要关心的文件描述符数组并初始化(不关心的fd默认为 -1,关心的fd添加进数组中并设置event)
- 调用poll函数
- 等待 poll 的成功返回,函数的返回值代表等待成功的文件描述符数量,
- 就绪的文件描述符需要通过遍历 fd_array,查看每一个元素的 revent 是否有对应的关系事件,如果有,则需要对该描述符进行对应操作(这些操作可能会产生新的文件描述符,需要在fd_array中添加关心。比如TCP监听套接字负责处理连接,这会产生新的文件描述符)
- 如果还需要重新对这些文件描述符进行等待,则回到第2步循环即可
一些示例代码
const size_t fd_Num = 1024; //数组最大值,等待文件描述符最大值,可以是其他值
pollfd fd_array[fd_Num]; //定义pollfd数组
// 初始化数组函数
void InitFdArray()
{for (int i = 0; i < fd_Num; i++){fd_array[i].fd = -1;fd_array[i].events = 0;fd_array[i].revents = 0;}
} // 添加一个描述符进关心数组
void AddFdInArray(int sockfd, int pollevent)
{for(int i = 0;i<fd_Num;i++){if(fd_array[i].fd == -1){fd_array[i].fd = sockfd; //设置对应元素的 fd 为关心的文件描述符fd_array[i].events |= pollevent; //设置添加关心break;}}//走到这表示要扩容,表示数组里面没有闲置的位置}// 从数组中移除一个文件描述符关心
void RemoveFdFromArray(int sockfd)
{for(int i = 0;i<fd_Num;i++){if(fd_array[i].fd == sockfd){fd_array[i].fd = -1;fd_array[i].events = 0;fd_array[i].revents = 0;break;}}
}// 循环等待事件
void Loop()
{while (true){int n = poll(fd_array,fd_Num,-1);if (n > 0){// 已经有事件就绪了// 跳到其他函数处理文件描述符事件}else if (n == 0){cout<<"Time is over!"<<endl;}else{cerr<<"Poll Error"<<endl;exit(0);}}
}// 主函数调用 poll
int main()
{InitFdArray();AddFdInArray(1,POLLIN);AddFdInArray(2,POLLIN); Loop();return 0;
}
poll相较于select优点
- 支持文件描述符数量大大增加(取决于数组大小)
- select的读事件、写事件和异常事件关心是作为参数输入,是分开的,而poll封装了pollfd结构体,需要事件关心直接设置 event 即可,同时返回就绪的事件也可以通过 revent 读取
poll的缺点
- 和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符
- 每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核中
- 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降
2. 多路复用 - epoll
epoll是改进的poll,几乎消除了poll的所有缺点
关于epoll的三个系统调用
创建 - epoll_create
- 功能:创建一个epoll实例
int epoll_create(int size);
- 自从 linux2.6.8 之后,size 参数是被忽略的
- 成功时,epoll_create函数返回一个非负整数,这个整数代表所创建的epoll实例的文件描述符。通过这个文件描述符,可以对epoll实例进行各种操作。失败则返回-1,错误码在errno当中
- 该文件描述符必须被 close() 关闭
控制 - epoll_ctl
函数原型
- 功能:epoll 的事件注册函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
它不同于 select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型
- epfd: epoll_create()的返回值
- op:表示动作,用三个宏来表示.
- EPOLL_CTL_ADD:将文件描述符fd添加到epoll实例epfd中,并监听event所指定的事件。
- EPOLL_CTL_MOD:修改已经添加到epoll实例epfd中的文件描述符fd的监听事件为event所指定的事件。
- EPOLL_CTL_DEL:从epoll实例epfd中删除文件描述符fd,不再监听其相关事件。
- fd:表示需要监听的文件描述符
- event:一个指向struct epoll_event结构体的指针,是告诉内核需要监听什么事
- 返回值:成功时,epoll_ctl函数返回0。失败时,函数返回-1,并且会设置相应的错误码errno,常见错误码如下
- EINVAL:参数无效,如epfd不是一个有效的epoll实例文件描述符,或者op参数取值无效,或者event中的事件类型无效等。
- EBADF:文件描述符无效,如epfd或fd不是一个有效的文件描述符。
- EEXIST:当使用EPOLL_CTL_ADD操作时,如果要添加的文件描述符fd已经存在于epoll实例epfd中,则会返回此错误。
- ENOENT:当使用EPOLL_CTL_MOD或EPOLL_CTL_DEL操作时,如果指定的文件描述符fd不存在于epoll实例epfd中,则会返回此错误。
- ENOMEM:内核内存不足,无法完成相应的操作。
- ENOSPC:系统对epoll实例中所能添加的文件描述符数量有限制,当超过这个限制时,会返回此错误。
关于 epoll_event 结构体
struct epoll_event {uint32_t events; /* Epoll events */epoll_data_t data; /* User data variable */
};
上面是 epoll_event结构体原型,其中 成员events 表示用于指定要监听的事件类型,可以是以下事件的按位或组合
- EPOLLIN:表示对应的文件描述符可以读数据。
- EPOLLOUT:表示对应的文件描述符可以写数据。
- EPOLLERR:表示对应的文件描述符发生错误。
- EPOLLHUP:表示对应的文件描述符被挂断。
- EPOLLET:设置边缘触发模式,与水平触发模式相对,边缘触发模式下只有在事件状态发生变化时才会触发通知,而水平触发模式下只要文件描述符满足事件条件就会一直触发通知。
data成员变量是一个联合体,具体原型如下
typedef union epoll_data {void *ptr;int fd; //常用uint32_t u32;uint64_t u64;
} epoll_data_t;
它可以用来存储与该文件描述符相关的用户数据,通常可以将文件描述符本身或者指向包含该文件描述符相关信息的结构体指针存储在其中,以便在事件发生时能够方便地获取和使用相关信息。
等待 - epoll_wait
函数原型
- 功能:收集在 epoll 监控的事件中已经发送的事件
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- 参数:
- epfd: epoll_create()的返回值
- events: 输出型参数,自定义的 epoll_event 结构体数组
- maxevents: 告诉内核这个 events 数组有多少元素(即该函数返回后可以最多同时输出多少已经就绪的文件描述符)
- timeout:超时时间,单位毫秒(0 会立即返回,-1 是永久阻塞)
wait 过程
- epoll 将会把发生的事件赋值到 events 数组中 (events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存)
- 如果函数调用成功,返回对应 I/O 上已准备好的文件描述符数目,如返回 0 表示已超时, 返回小于 0 表示函数失败
- 如果复制的事件数量等于maxevents,则表示就绪队列中的事件数量超过了用户指定的最大容量,此时epoll_wait返回maxevents,用户需要再次调用epoll_wait来获取剩余的就绪事件。
3. epoll 原理
两个核心成员
- 当某一进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关,一个是红黑树,一个是双链表(就绪队列)
struct eventpoll
{..../*红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件*/struct rb_root rbr;/*双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件*/struct list_head rdlist;....
};
过程
- 每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件.
- 这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是 logN,其中 n 为树的高度).
- 而所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法.
- 这个回调方法在内核中叫 ep_poll_callback,它会将发生的事件添加到 rdlist 双链表中.
- 在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体(原型如下)
struct epitem
{struct rb_node rbn;//红黑树节点struct list_head rdllink;//双向链表节点struct epoll_filefd ffd; //事件句柄信息struct eventpoll *ep; //指向其所属的 eventpoll 对象struct epoll_event event; //期待发生的事件类型
}
- 当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的rdlist 双链表中是否有 epitem 元素即可.
- 如果 rdlist 不为空,则把发生的事件复制到用户态(即复制给函数 epoll_wait 的第二个参数),同时将事件数量返回给用户. 这个操作的时间复杂度是 O(1).
回调机制
- epoll的回调机制是其高效处理 I/O 事件的核心所在
注册回调函数
当使用epoll_ctl向epoll实例中添加一个要监听的文件描述符时,内核会为该文件描述符对应的设备驱动程序注册一个回调函数。这个回调函数的主要作用是当该文件描述符上的事件发生时,由内核自动调用该函数来通知epoll实例相应事件的发生。不同的设备驱动程序会根据自身的特点和功能实现相应的回调函数,例如对于网络套接字的驱动程序,其回调函数会在套接字的状态发生变化时被调用,如数据可读、可写、连接建立、连接断开等情况。
事件触发与回调函数调用
当被监听的文件描述符上的事件发生时,例如网络套接字接收到了新的数据,对应的设备驱动程序会检测到这个事件,并自动调用之前注册的回调函数。在回调函数中,会将该文件描述符及其对应的事件信息传递给epoll核心模块,epoll核心模块收到这些信息后,会将该文件描述符及其事件信息添加到epoll实例的就绪队列中,从而表示该文件描述符上有相应的事件已经就绪,可以被用户程序获取和处理。
回调机制优点
- 高效性:通过回调机制,内核能够在事件发生的第一时间通知epoll实例,而不需要像传统的select或poll那样,用户程序需要不断地轮询所有被监听的文件描述符来检查事件是否发生。这样大大减少了不必要的系统调用和上下文切换,提高了系统的整体性能和效率,尤其在处理大量并发连接但只有少数活跃连接的场景下,能够显著降低 CPU 的使用率。
- 实时性:回调函数在内核层面被触发,能够及时地将事件信息传递给epoll实例,从而保证了用户程序能够尽快地获取到就绪事件并进行处理,提高了系统对 I/O 事件的响应速度和实时性。
- 灵活性:设备驱动程序可以根据不同的设备类型和事件类型实现各自的回调函数,这样可以针对不同的硬件设备和 I/O 场景进行定制化的事件处理,提高了系统的灵活性和可扩展性。
4. epoll 代码框架
- epoll 代码与上面三个函数息息相关,大致也就三步(创建,控制,等待)
const size_t maxsize = 1024;
struct epoll_event ret_events[maxsize]; //创建一个epoll_event数组来接收已就绪的文件描述符//添加进epoll关心文件描述符的函数
void AddFdToEpoll(int epfd, int fd,)
{struct epoll_event event;event.data.fd = fd;event.events = EPOLLIN; //默认添加读关心int n = epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&event);
}//从epoll删除关心文件描述符的函数
void DeleteFdToEpoll(int epfd, int fd)
{int n = epoll_ctl(epfd,EPOLL_CTL_DEL,fd,nullptr);
}//循环等待epoll关心事件void Loop(int epfd)
{while(true){int n = epoll_wait(epfd,ret_events,maxsize,-1);if(n > 0){//表示一些文件描述符已经就绪//这里是处理这些文件描述符代码}else if(n == 0){cout<<"time out"<<endl;break;}else{cout<<"epoll Fail"<<endl;break;}}
}
int main()
{int epfd = epoll_create(1); //创建epoll实例//假设这部分申请了文件描述符3,4做tcp套接字AddFdToEpoll(epfd, 3);AddFdToEpoll(epfd, 4);Loop(epfd);DeleteFdToEpoll(epfd,3);DeleteFdToEpoll(epfd,4);close(3);close(4);close(epfd);return 0;
}
5. epoll 的优点
- 接口使用方便:虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
- 数据拷贝轻量:只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而 select/poll 都是每次循环都要进行拷贝
- 事件回调机制:避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度 O(1). 即使文件描述符数目很多, 效率也不会受到影响
- 没有数量限制:件描述符数目无上限.
6. epoll 的工作方式
Level Triggered - 水平触发(LT 工作模式)
例子:快递员通知你拿快递,你不拿或者没拿完,快递员一直通知你
- 当 epoll 检测到 socket 上事件就绪的时候
- 如果不进行处理. 或者只处理一部分.在第二次调用epoll_wait 时, epoll_wait 仍然会立刻返回并通知 socket 事件就绪
- 直到缓冲区上所有的数据都被处理完,epoll_wait 才不会立刻返回.
- 支持阻塞读写和非阻塞读写
Edge Triggered - 边缘触发(ET 工作模式)
例子:快递员通知你拿快递,你不拿或者没拿完,快递员直接跑路;直到下一次你又有新的快递,才会通知你
- 当 epoll 检测到 socket 上事件就绪时, 必须立刻处理.
- 无论你是忽略,或者只处理一部分,在第二次调用epoll_wait 的时候, epoll_wait 不会再返回了
- 也就是说, ET 模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
- ET 的性能比 LT 性能更高( epoll_wait 返回的次数少了很多). Nginx 默认采用ET 模式使用 epoll.
- 只支持非阻塞的读写(需要设置套接字为非阻塞)
对比 LT 和 ET
- LT 是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中
就把所有的数据都处理完. 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些.- 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的. 另一方面, ET 的代码复杂程度更高了.
- select 和 poll 其实也是工作在 LT 模式下. epoll 既可以支持 LT, 也可以支持 ET