C++网络编程I/O 多路复用之epoll(一)

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第20天,点击查看活动详情

前言

似乎我总是这样,宁愿持续学习,也不愿意总结。就好像高中,总是跟着老师听课,但不乐于自己把老师讲的知识串起来,进行总结。这种想法其实是在自己骗自己,听了等于学会那是不可能的,不愿意总结就是因为那块知识学的还不熟练,回忆起来觉得麻烦。同时的我也知道最舒服的时候是老师讲课后题的时候,我都会,别的同学在费劲的听讲,而我可以做别的时候,也正因为这样的自大,在有些时候丢失了一些细节的关注,这是我对我中学时期学习的反思。这也是为什么说,人最爽的时刻是知识输出,把自己在脑子里建构的房子,拿出来,展示给大家,那简直就像刚刚练好了凌波微步,就开始输出魅力。
抗拒总结,但有时候越抗拒的事情,越有做它的道理,梳理完epoll之后,对于我自己而言,脑子里会有一个更加清晰地脉络。

回顾上文

上文我们知道了poll对select的改进和发展,但是仍遗留下来一些问题:

  1. 包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间
  2. 内核态要通过遍历线性扫描是否有就绪的文件描述符。
  3. 用户态需要通过遍历获取就绪事件的文件描述符。

针对这三条,epoll进行了解决:

  1. 内核通过一个事件表直接管理用户感兴趣的所有事件,这样内核中保存一份文件描述符集合,epoll需要使用一个额外的文件描述符,来唯一标识内核中的事件表,这个文件描述符使用epoll_create函数来创建。每次调用无需用户重新传入,只需告诉内核修改(epoll_ctl)的部分即可。
  2. 效率提升,内核不再通过轮询遍历的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒,其实就是采用回调函数方式,如果某个文件描述符已就绪,它会主动调用回调函数,该回调函数将文件描述符放入到就绪链表中。
  3. 内核仅会将IO事件就绪的文件描述符返回(epoll_wait)给用户,这样,用户无需遍历整个文件描述符集合。

如果仔细分析这些改进,其实都在函数中有体现,透过函数的内容现象看到底层内核的运行本质,这样的映射学习,有利于我们理解和明晰。也是在学习epoll的过程中,我体会到了异步的妙用,为什么要有回调函数、函数指针这样的东西存在。

epoll

内核检测epoll传递的fd集合, 是以红黑树的形式遍历的,红黑树用于管理所有的文件描述符fd,就绪链表用于保存有事件发生的文件描述符。

epoll_create函数

创建一棵监听红黑树

#include <sys/epoll.h>
int epoll_create(int size);
参数: 
        size: 创建的红黑树的监听节点数量(仅供内核参考,没意义)。
返回值;
        >0: 文件描述符, 操作epoll树的根节点,来唯一标识内核中的事件表。

epoll_ctl函数

对监听红黑树进行管理,操作epoll的内核事件表: 添加节点, 删除节点, 修改已有的节点属性。

#include<sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数:

  1. epfd:epoll_create的返回值, 通过这个值就可以找到红黑树。
  2. op:指定操作类型,操作类型有三种
    • EPOLL_CTL_ADD 添加fd到监听红黑树epfd,往事件表中注册事件
    • EPOLL_CTL_MOD 修改epfd上的注册事件属性
    • EPOLL_CTL_DEL 从红黑树epfd删除注册事件,取消监听
  3. fd:待监听的 fd
  4. struct epoll_event *event:指定监听文件描述符的什么事件,本质是个结构体,还是个结构体套结联合体

返回值:成功 0; 失败: -1 ,设置errno。

epoll_event的定义

struct epoll_event
{
	uint32_t events; //Epoll监听的事件
	epoll_data_t data;  //用于存储用户数据
};

uint32_t events的常用取值:

  • EPOLLIN:描述符处于可读状态
  • EPOLLOUT:描述符处于可写状态
  • EPOLLET:将epoll event通知模式设置成edge triggered
  • EPOLLONESHOT:第一次进行通知,之后不再监测
  • EPOLLHUP:本端描述符产生一个挂断事件,默认监测事件
  • EPOLLRDHUP:对端描述符产生一个挂断事件
  • EPOLLPRI:由带外数据触发
  • EPOLLERR:描述符产生错误时触发,默认检测事件

epoll_data_t的定义

typedef union epoll_data
{
    int fd;  //简单,一般用这个记录监听的文件描述符
    void*ptr;  //复杂,指向用户自定义数据,写到reactor(反应堆)模型的时候会用到这个
    uint32_t u32;
    uint64_t u64;

}epoll_data_t;

示例

这块的赋值有些复杂,加个示例一看就明白了。

struct epoll_event temp;
temp.events=EPOLLIN; /*默认是LT水平触发 */
temp.data.fd=lfd;
ret=epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&temp);

epoll_wait函数

阻塞监听

#include<sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数:

  1. epfd:epoll_create的返回值, 通过这个值就可以找到红黑树。
  2. events:传出参数, 以数组形式返回就绪事件的文件描述符集合结构体struct epoll_event
  3. maxevents:定义的events数组的大小
  4. 阻塞时间,就是用户拿一次数据可以等待的时间:
    • -1: 一直阻塞,直到有事件就绪
    • 0: 不阻塞,立即返回,轮询
    • >0: 超时时间(毫秒),如果期间有就绪就返回,否则直到超时

返回值:返回就绪的文件描述符个数,失败时返回-1并设置errno。

用户遍历就绪事件的时候,就是靠函数返回值控制边界,从传出参数events中拿信息。

LT模式与ET模式

这两个词汇来自电学术语,可以将 fd 上有数据认为是高电平,没有数据认为是低电平,将 fd 可写认为是高电平,fd 不可写认为是低电平。那么水平模式的触发条件是状态处于高电平,而边缘模式的触发条件是新来一次电信号将当前状态变为高电平。 select 和 poll 都只能工作在相对低效的LT(水平触发)模式,而epoll 虽然默认也是工作在LT模式下,但是它还可以工作在更高效的ET(边缘触发)模式下。并且 epoll 还支持 EPOLLONESHOT事件。该事件能进一步减少可读、可写和异常事件被触发的次数。

LT(水平触发)模式(默认)

LT(level triggered)是默认的工作方式,并且同时支持block(阻塞)和no-block (非阻塞) socket。当epoll_wait检测到有事件就绪并将此事件返回给用户后,可以不立即处理该事件(因为后面还会被触发)。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次触发,返回此事件,直到该事件被处理。
比如,使用此种模式,当数据可读的时候,EPOLLIN事件到来,不read的话,这个EPOLLIN事件会不停的通知,epoll_wait()将会一直返回就绪事件,然后可以对这个就绪的fd进行IO操作。如果没有完全处理数据,再次调用epoll_wait()还会把此事件当就绪事件返回。进行read(读操作后),才会停止通知。一个事件只要有,就会一直触发

ET(边缘触发)模式

关键词就一句话,只有一个事件从无到有才会触发。 采用ET模式时,当epoll_waitepoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。 比如,如果EPOLL事件到来,不论是否read,只通知一次。
可见ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此ET模式效率比LT模式高

注意:每个使用ET模式的文件描述符都应该是非阻塞的,如果描述符是阻塞的,那么读或写操作将会因没有后续事件而一直处于阻塞状态(饥渴状态)。归结成这个问题:为何epoll的ET模式文件要设置为非阻塞?这段话我也理解了很久,其实弄明白阻塞 IO非阻塞 IO就自然懂得了。

总结

写不动了,其实上面这些东西,网上一搜一大堆,但我希望以一种有条理的方式顺下来吧,在我的脑子里过一遍,有什么东西都会明晰一些。接下来会介绍阻塞 IO非阻塞 IO,然后是epoll服务器的编写思路,然后尽可能的讲解一些为什么和踩坑点,最后提出自己的一些思考吧。

猜你喜欢

转载自juejin.im/post/7112704243580010533