目的是为了解决select和poll还未解决的问题——遍历检测fd是否就绪的问题,那用户态到内核,内核到用户的拷贝消耗呢,无法避免。
一 epoll接口
1 epoll_create
在当前的系统中,size被忽略,但是要传一个大于零的数,可能是系统发展中参数设计多了,但又无法删除,免得别人代码出错。
会在底层创建一个epoll模型,返回一个文件描述符。epoll模型也是资源,由于linux一切皆文件的设计理念,进程的资源几乎都是用struct file结构体管理,好处就是统一了进程访问资源的方式,都是用文件描述符在表中找到struct file结构体,从而找到资源,这对于我们使用者来说是非常方便的,因为只要是访问资源,就要fd,方便我们理解接口的使用。
接下来的接口就是要解决下面的两个问题。
2 epoll_ctl
参数1 epfd就是传上一个接口的返回值。为什么第一个参数都是epfd,因为这两个参数都是对epoll模型进行操作,当然要能先找到这个模型。
参数2 op会被设为如下三个值: 添加,修改,删除。
这个接口的意义就是告诉内核我们要用event变量来添加/修改该fd原先的事件,删除的话显然就不需要event变量了,也就是告诉内核现在关心fd的哪些事件,内核epoll模型保存了添加的所有fd以及关心的事件,这个接口就是对这个模型里的数据进行增删改。
3 epoll_wait
这个接口就是告诉我们哪些fd就绪了,返回值和timeout参数和select含义一模一样,在timeout时间内在epoll模型的等待队列中等待。
参数1已经提及过了,中间两个参数,参数一返回的是一个缓冲区,或者是一个结构体数组的起始地址,maxevents表示空间大小,两个参数结合就能表示一个缓冲区。这个缓冲区是输出型参数,用来返回fd以及对应的就绪事件。
接下来看看这个下面结构体类型,来看看返回了什么信息。
events很眼熟了,就是保存该fd所有的就绪事件,如下的宏,这些宏对应比特位上的一个个标记位,一个个按位与就可以用一个int变量保存。
接下来解释一下EPOLLPRI,先前学习tcp报文我们提到过六大标记位,是来标识报文的类型。
我们一直在发数据,但是对端处理得的太慢了,我们就发一个psh来催促,来催促接收方尽快从tcp缓冲区数据取走,这个PSH就是让sockfd处于就绪状态。这个就绪状态是struct file中的就绪状态吗,为什么能催促对端处理呢?
如果上层应用很忙,没在select等待,那psh没作用,如果是在select,poll等待,此时我们发送PSH,os会将fd立刻设为就绪状态,然后唤醒select,poll进程,重新遍历fd,搜集全部就绪的fd就可以返回了。
为什么之前有数据时,os不将fd设为就绪状态呢? 因为延迟通知机制。凭什么tcp缓冲区一有数据来,我们就要立刻交给上层呢,假如每次都来很少的数据,每次os要将数据交给进程,这里面都会有内核到用户态的转变,这个开销也是不小的,所以有了延迟通知机制,等缓冲区多一点数据再交付,而psh就是让os别等了,立刻去交付。
问1 然后就是data这个联合体,在epoll_ctl中,是我们初始化了data成员,我们如果往里面放了fd,epoll_wait取出来epoll_event中的data成员保存的就是fd。那data究竟应该保存什么。
简单场景下,1 我们直接保存fd,将来返回的时候方便获取到fd进行读取,2 有时候我们经常要将fd和更多信息做关联,我们就定义一个上下文结构体,我们可以直接将该类型成员指针放入其中,这样返回的时候可以通过指针获得更多的信息,当然此时上下文信息要包括fd,不然到时候怎么获取fd。
3 有时候可以保存其它数据,例如会话id。服务端会保存客户端的一些状态信息,会话id是服务端用于区分客户端链接的标识符。在分布式系统中,我们获取这个id可以判断应该将这个请求路由到哪个服务器节点。
二 epoll底层
1 从硬件到内存
os如何知道网卡上有数据,os不会去检测硬件,效率太低,而是由硬件去发信号,这个称为硬件中断,也就是硬件在检测到数据来的时候,就会发一个硬件中断给cpu,然后cpu根据中断号执行中断向量表里的函数,操作肯定是将数据拷贝到内存。拷贝到内存,然后os调用一层层协议解析函数对内存的报文进行解析,最后给到文件缓冲区,这个文件缓冲区都是在传输层的。
可是硬件如何检测自己的状况呢,怎么知道自己设备上有数据了,这个类比cpu来理解即可,cpu可以检测自己寄存器的存储情况,就是根据里面的高低电频来看看是否有数据,所以一些硬件也可以来检测自己的寄存器来看看是否有数据,有就会执行对cpu充电的指令,这就是发送一个硬件中断。
2 设置文件就绪
而select和poll都是检测全部fd对应的文件结构体中的标记位,来看文件上是否收到数据,不是直接去检测硬件的,当标记为表示不可读,就会将进程链入struct file结构体的等待队列,等待os通知唤醒,唤醒后还是全部遍历,因为用户要求关心多个fd,一个就绪就返回会导致反复调用系统调用,增加开销。
问2 文件结构体的标记位是什么时候被修改的。os执行硬件中断方法将数据从外设到内存,然后将一部分功能交给软中断函数执行,说是交给,其实不是直接调用,而是在软中断函数向量表中将对应标记位置1,硬件中断函数执行结束,后续cpu执行os的代码,会定期去执行这个表里被标记的函数。为什么剥离了一部分给其它函数呢,为了减少硬件中断函数的执行时间,使cpu能快速响应下一个硬件中断。
而软中断函数执行的就是将内存中的网络报文,调用一层层协议解析函数,最后放给tcp缓冲区中,此时也可能会将文件结构体内的标记位进行修改,也可能不会,因为延迟通知机制。等数据多到一定程度通知。
3 接口回顾
下面这个接口会在底层创建一颗红黑树
还会有一个就绪队列,先进先出,用处后面慢慢提及。红黑树节点含义: 表示用户告诉内核,要关心fd的哪些事件。显然key值是fd。epoll_ctl是fd通过来对红黑树进行增删改查。这是epoll模型第一点。
节点还有一些成员是其他链接属性相关,简单理解就是一些指针,方便我们链接左右子树。
红黑树的作用? 方便查询,因为我们修改删除节点伴随大量查找,二叉搜索树的查找效率显然是不错的。问3 如果用哈希表查找呢? 有一段时间fd突然增加,然后哈希表扩容,当fd减少后,哈希表要缩容就很难了,但是对于红黑树来说,缩容是很容易的,这是第二点。
总的来说, 我们通过epoll_ctl接口对红黑树进行增删改查,这样红黑树就会维护好我们要os关心的fd以及对应的事件,os通过某种手段知道红黑树上有节点对应的文件就绪了,此时就在就绪队列新增一个节点,之后就可以用epoll_wait来获取了。这是比较容易理解的,具体实现要结合epoll模型第三点来理解。
问4 os如何得知红黑树上fd就绪了? 后面提及。
epoll第三点,红黑树的节点不仅仅属于红黑树,还属于就绪队列。理解变得复杂了,但是这种实现可以在内部减少空间消耗,其实很简单,就是节点多一个指针来维护新的数据结构就行了。
红黑树和就绪队列具体实现: 红黑树类记录红黑树的头节点,就绪链表类记录链表的相关信息。当我们用epoll_ctl对红黑树节点添加,修改event的时候都不影响就绪队列的链接指针,当我们要删除节点的时候,也就是把红黑树链接指针置空,此时也不影响就绪队列的链接。当fd就绪后,节点被连入就绪队列,revent记录了就绪信息,我们就可以通过epoll_wait通过就绪队列类,遍历拷贝获取就绪的事件。由此来看,用户态到内核,内核到用户的拷贝是无法避免的。
问5 节点不一定会保存fd?之前提及过节点的data成员会用来保存其它数据,此时key值就要由红黑树自己生成key值。
4 实现的意义
select和poll要遍历fd检测是否就绪,此时我们判断是否有节点就绪,直接看就绪队列是否为空即可,此时从o(N)优化成了o(1)。这就是epoll相较于select,poll作出的优化。
注意1 就绪队列其实就是个生产消费模型,os往里面放节点,我们通过epoll_wait往里面拿节点。
2 就绪队列上层不一定会一次性读完。
3 凡是没在红黑树中的节点,也就没有被关心,也就不会出现在就绪队列。
4 就绪队列和红黑树共用节点,所以前面说一个节点不是只属于一个数据结构。
回顾问4 os如何知道哪些fd就绪了,知道有fd就绪后就将节点连入就绪队列是什么时候做的。
我们在设置文件就绪提到软中断,会修改file结构体中的标记位,而在epoll模型中还会执行file结构体内的回调方法。首先在调用epoll_ctl时,os会对该fd的file结构体对象中进行函数注册,其实就是往file结构体内某个特定的成员赋值,放一个函数地址。当软中断修改完file结构体的标记位,就会调用这个注册函数,这个注册函数会将节点连入就绪队列,然后唤醒调用epoll_wait的用户(用户进程可能在epoll模型的等待队列中休眠),在进程醒来前,cpu可能已经处理多了软件中断函数,也就有多个fd就绪,用户直接获取就绪队列的节点就返回了,所以os就自然地知道fd就绪了,我们用户也自然能醒来去就绪队列中获取。
当然对于没有被epoll关心的fd,os就不会进行回调函数注册,也就不会有如上操作了,而select,poll没有对函数进行注册,也不会有这一步。
三 epoll服务器
1 服务器初始化
服务器基本结构如下,保存端口号和套接字,套接字,bind,listen函数都是封装好的,可以直接换成原生fd,以及原生的bind函数接口。
我们在start要accept接收链接,但是不能直接accept,因为此时不一定有数据,可能会直接阻塞,我们应该先将监听套接字添加到epoll中,就绪后再读写。
epoll基本接口封装:
再来封装epoll的接口,方便后面创建和使用epoll模型。
所以在EpollServer函数中要新增一个Epoller成员。
epoller提供一些关闭fd和获取fd的接口。
在服务端的init函数初始化epoll模型,这里就用到了Create函数。
释放资源,这里就用到了close函数,来关闭内部的fd。
在start函数要将监听套接字加入,所以epoll又封装一个AddEvent函数来帮忙添加fd的关心事件和用户数据。
event变量的初始化,一个是外部传的事件,还有一个是fd。添加如下。
开始捞取就绪事件,下面是封装的wait接口,我们知道epoll_wait是将结果返回到一片缓冲区中去,这个缓冲区的起始地址和大小我们要传入,显然再次证明wait获取结果,一定是要线性拷贝的,无法避免。
gnum是我们定义的常量,表示缓冲区的大小。
此时就绪的fd数据都放在revs中了,revs[gnum]这种用常量在数组初始化表达大小在某些老版本中是无法编译通过的。
对wait的不同返回值做处理:
完善Handler函数:num表示准备好的fd个数,所以直接for循环缓冲区。
fd和events变量就获取了套接字和我们想要的就绪事件。
2 事件处理
为什么我们一直不读取就会一直通知,这个和工作模式有关系。
链接事件到来: 直接accept获取,此时要将新的fd添加到epoll中,目前只关心读事件。
如果sock返回异常,此时我们不做处理,继续处理下一个fd,我们获取链接失败不应该直接退出,所以在Accept函数内只是打了一下日志,外部则是直接continue。
读取事件就绪:
直接recv读取,并且还可以send发送回去。
读取返回异常,此时我们要在epoll模型中删除对应fd。注意: epoll在操作的时候,内部会判断fd合法,所以要先在epoll中移除fd,然后才关闭fd。
不是在那个revs数组中删除,这个里面保存的是就绪fd,后面会被覆盖,应该在epoll模型中删除。
3 bug
send回显的时候只有 epoll server echo,buffer数据未回显。当服务端改为buffer[s] = 0又可以回显,buffer[s - 1] = 0则不行。
因为buffer内的数据是"aa\r\n",这说明telnet发送的换行符应该\r\n,导致buffer[s - 1]只清除了\n,此时echo内的字符为,"aaa\r [epoll ....]",打印的时候\r使得光标移到最开始了,然后后面的[epoll...]就覆盖了。
4 拓展
我们这个buffer就是一个局部变量,如果我们一次没有读取到一个完整报文,那buffer就被释放了,用全局变量的话,那从多个fd中读取的内容都在一个变量里了。
就只能让一个fd关联一个应用层缓冲区,还有我们如何保证读到一个完整报文,肯定是网络协议。此时io和网络协议栈的关系就显现了。
5 reactor拓展
1 什么是reactor
是基于多路转接的,包括事件派发器和链接管理器的服务器,派发器就是基于epoll_wait的事件处理函数,链接管理就是对fd的管理。因为若是对方一直不发数据,那我们底层就要一直维护着资源,tcp要隔几个小时才会将资源销毁,若是要快速回收资源,只能我们开发人员对fd链接进行管理,记录上次收的数据,太长时间没数据来就关闭fd。
链接管理: 增加一个成员,管理所有缓冲区,并且将fd和缓冲区相关联。
下面是缓冲区对象。
给监听套接字构建缓冲区对象,并加入管理。
还要管理其它fd的connection对象。
在读取的时候就全部加入到inbuffer中,之后如何解析,需要制定应用层协议。
connection多增加三个回调函数。
添加fd时判断fd类型,分别注册不同的函数。
监听套接字的读:就是获取链接,写不需要注册,异常处理也不用。普通套接字:读就是read,写就是注册我们写的recv函数。
在fd就绪就调用回调方法,此时就不用判断fd的类型了。
之后管理链接就遍历上面的connections中的fd即可。
6 lt(水平)和et(边缘)模式
lt和et是什么,区别是什么? lt: tcp缓冲区有数据就通知。et:tcp有数据到来才通知,也就是缓冲区内数据发生变化才通知。
这个通知策略和内核工作关系? 我们知道数据到来会触发硬件中断,调用中断函数,最后会执行回调方法,将就绪节点加入到就绪队列,此时还会唤醒正在等待的进程,显然这个进程是先前调用了epoll_wait,没获取到资源,所以在epfd的等待队列中休眠。在lt模式下,进程调用epoll_wait获取了一些节点,然后只读取fd中的一部分数据,甚至不读取,下次调用epoll_wait的时候,系统调用或者说内核会主动检查就绪队列上的节点的状态(状态检查),看看是否是可读取或者可写的,满足就将其加入到就绪队列,不满足就移除,这就是为什么不读取,就会一直在就绪队列的原因,因为状态检查将其纳入了就绪队列。
et模式下: 这个模式下就绪队列的节点新增只和事件驱动有关,也就是除非硬件上来了数据,使得调用file结构体内的回调方法,才会将节点加入到就绪队列,每次读取,所有就绪队列的节点都会移除。
et模式倒逼程序员读完全部数据,因为没读完下次epoll不会通知了,可是我怎么知道读完了?只能一直read?直到返回的字节数少于某个数,或者为零。可是若是为零会卡住,因为read,recv默认阻塞,没数据读取就阻塞了,所以此时应该非阻塞读取。
et效率 >= lt,高效的问题: 1 et模式下调用epoll_wait的次数少,返回的次数少,也就是说调用系统调用的次数少,当然提高了效率。lt模式下程序员也可以读完全部数据,但是并非所有程序员都会实现这个复杂的逻辑,所以才有了et模式,倒逼程序员读完数据。
2 而且由于et模式取走了大量的数据,使得tcp底层能更新出大的接收窗口,可以让对方也多发点,整体来看,发送效率也提高了。
应用场景:lt,高响应,而且编写难度低。et:应用于高io,缓冲区数据太多,需要et模式加快速度,尽快读完。
7 epoll模型缺陷
1 可移植性差,只能在window下使用
2 epoll_ctl不支持同时传多个fd的事件控制,调用频繁。