epoll全面讲解:从实现到应用

多路复用的适用场合

• 当客户处理多个描述符时(例如同时处理交互式输入和网络套接口)

• 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口

• 如果一个服务器即要处理TCP,又要处理UDP

• 如果一个服务器要处理多个服务或多个协议

select/poll/epoll差别

  1. poll返回的时候用户态需要轮询判断每个描述符的状态,即使只有一个描述符就绪,也要遍历整个集合。如果集合中活跃的描述符很少,遍历过程的开销就会变得很大,而如果集合中大部分的描述符都是活跃的,遍历过程的开销又可以忽略。epoll的实现中每次只遍历活跃的描述符,在活跃描述符较少的情况下就会很有优势,在代码的分析过程中可以看到epoll的实现过于复杂并且其为实现线程安全需要同步处理(锁),如果大部分描述符都是活跃的,遍历这点区别相对于加锁来说已经微不足道了,此时epoll的效率可能不如select或poll。

  2. 传参方式不同 支持的最大描述符不同,根本原因是内核管理每个文件句柄的数据结构不同,select能够处理的最大fd无法超出FDSETSIZE,因为调用select传入的参数fd_set是一个位数组,数组大小就是FDSETSIZE默认为1024,所以调用方式限制了并发量。Poll是利用一个数组传入的参数,没有最大限制。Epoll不需要每次都传入,因为会调用epoll_ctl添加。使用方式不同,select调用每次都由于内核会对数组进行在线修改,应用程序下次调用select前不得不重置这三个fdset,而poll比他聪明点,将句柄与事件绑定在一起通过一个struct pollfd实现,返回时是通过其revets实现,所以不需要重置该结构,直接传递就行,epoll不需要传递。支持的事件类型数不同:select应为没有将句柄与事件进行绑定,所以fd_set仅仅是个文件描述符集合,因此需要三个fd_set分别传入可读可写及异常事件,这使得他不能处理更多类型的事件,The choice of the events to wait for is limited; for example, to detect whether the remote socket is closed you have to a)monitor it for input and b) actually attempt to read the data from socket to detect the closure (read will return 0). Which is fine if you want to read from this socket, but what if you’re sending a file and do not care about any input right now? 而poll采用的pollfd中event需要使用64个bit,epoll采用的 epoll_event则需要96个bit,支持更多的事件类型。

  3. poll每次需要从用户态将所有的句柄复制到内核态,如果以万计的句柄会导致每次都要copy几十几百KB的内存到内核态,非常低效。使用epoll时你只需要调用epoll_ctl事先添加到对应红黑树,真正用epoll_wait时不用传递socket句柄给内核,节省了拷贝开销。

  4. 内核实现上:select/poll 轮流调用所有fd对应的poll(把current挂到各个fd对应的设备等待队列上),等到有事件发生的时候会通知他,在调用结束后,又把进程从各个等待队列中删除。在 epoll_wait时,把current轮流的加入fd对应的设备等待队列,在设备等待队列醒来时调用一个回调函数(当然,这就需要“唤醒回调”机制),把产生事件的fd归入一个链表,然后返回这个链表上的fd。(2019,3,8 :需要去核对)

  5. Select 不是线程安全的,epoll是线程安全的,内部提供了锁的保护,就算一个线程在epoll_wait的时候另一个线程epoll_ctl也没问题。

  6. 内核使用了slab机制,为epoll提供了快速的数据结构。

  7. Select和poll相当于epoll的LT模式,不支持ET模式,epoll支持更为该高效的ET模式 (ET和LT差别见下文)

epoll工作原理

epoll原理到实战视频讲解:epoll实战揭秘

epoll_create

操作系统在启动时会注册一个evnetpollfs的文件系统,对应的file operations只是实现了poll跟release操作,然后初始化一些数据结构,例如一个slab缓存,以便后面简化epitem和eppoll_entry对象的分配, 初始化递归检查队列等。

创建一个eventpoll对象, 里边有用户信息,是不是root,最大监听fd数目,等待队列,就绪链表,红黑树的头结点等,并且创建一个fd 即epollfd,,

而eventpoll对象保存在struct file结构的private指针中,为方便从fd得到eventpoll对象,并返回。

epoll_ctl

将epoll_event结构拷贝到内核空间中;

并且判断加入的fd是否支持poll结构;

并且从epfd->file->privatedata获取event_poll对象,根据op区分是添加,删除还是修改;

首先在eventpoll结构中的红黑树查找是否已经存在了相对应的fd,没找到就支持插入操作,否则报重复的错误;

相对应的修改,删除比较简单就不啰嗦了

插入时会进行上锁。

插入操作时,会创建一个与fd对应的epitem结构,并且初始化相关成员,比如保存监听的fd跟file结构之类的,

最后调用加入的fd的file operation->poll函数(最后会调用poll_wait操作)用于来将当前进程注册到设备的等待队列:在其内传递poll_table变量调用poll_wait,poll_table会提供一个函数指针,事实上调用的就是这个函数指针指向的对象,该函数就是将当前进行挂在设备的等待队列中,并指定设备事件就绪时的回调函数callback,该callback的实现就是将该epitem放在rdlist链表中。

最后将epitem结构添加到红黑树中

C/C++Linux服务器开发精彩内容包括:C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,MongoDB,ZK,流媒体,P2P,Linux内核,Docker,TCP/IP,协程,DPDK多个高级知识点分享。

视频获取+qun:正在跳转

epoll全面讲解:从实现到应用_第1张图片

epoll_wait

计算睡眠时间(如果有),判断eventpoll对象的链表是否为空,不为空那就干活,不睡眠,并且初始化一个等待队列,把自己挂上去,设置自己的进程状态为可睡眠状态。判断是否有信号到来(有的话直接被中断醒来),如果啥事都没有那就调用schedule_timeout进行睡眠,如果超时或者被唤醒,首先从自己初始化的等待队列删除 ,然后开始拷贝资源给用户空间了。

拷贝资源则是先把就绪事件链表转移到中间链表,然后挨个遍历拷贝到用户空间。

并且挨个判断其是否为水平触发,是的话再次插入到就绪链表。

具体实现有很多细节: 如果拷贝rdlist过程中又有事件就绪了怎么办,如果epollfd被另一个epoll监听会不会循环唤醒,lt什么时候会从rdlist中删除等,见下文 !

EPOll的ET与LT

内核实现:

只是在从rdlist中返回的时候有区别,内核首先会将rdlist拷贝到一个临时链表txlist, 然后如果是LT事件并且事件就绪的话fd被重新放回了rdllist。那么下次epoll_wait当然会又把rdllist里的fd拿来拷给用户了。举个例子。假设一个socket,只是connect,还没有收发数据,那么它的poll事件掩码总是有POLLOUT的,每次调用epoll_wait总是返回POLLOUT事件,因为它的fd就总是被放回rdllist;假如此时有人往这个socket里写了一大堆数据,造成socket塞住,fd不会放回rdllist,epoll_wait将不会再返回用户POLLOUT事件。如果我们给这个socket加上EPOLLET,然后connect,没有收发数据,epoll_wait只会返回一次POLLOUT通知给用户(因为此fd不会再回到rdllist了),接下来的epoll_wait都不会有任何事件通知了。

注意上面LT fd拷贝回rdlist并不是向用户处理完之后发生的,而是向用户拷贝完之后直接复制到rdlist中,那么如果用户消费这个事件使事件不就绪了怎么办,比如说本来是可读的,返回给用户,用户读到不可读为止,继续调用epoll_wait 返回rdlist,则发现不可读,事实上每次返回之前会以NULL继续调用poll,判断事件是否变化,平时调用poll会传递给poll_table变量,就进行添加到等待队列中,而此时不需要添加,只是判断一下状态,如果rdlist中状态变化了,就不会给用户返回了。

触发方式:

根据对两种加入rdlist途径的分析,可以得出ET模式下被唤醒(返回就绪)的条件为:

对于读取操作:

(1) 当buffer由不可读状态变为可读的时候,即由空变为不空的时候。

(2) 当有新数据到达时,即buffer中的待读内容变多的时候。

(3) 当buffer中有数据可读(即buffer不空)且用户对相应fd进行epoll_mod IN事件时

对于写操作:

(1) 当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候。

(2) 当有旧数据被发送走时,即buffer中待写的内容变少得时候。

(3) 当buffer中有可写空间(即buffer不满)且用户对相应fd进行epoll_mod OUT事件时

对于LT模式则简单多了,除了上述操作为读了一条事件就绪就一直通知。

ET比LT高效的原因:

经过上面的分析,可得到LT每次都需要处理rdlist,无疑向用户拷贝的数据变多,且epoll_wait循环也变多,性能自然下降了。

另外一方面从用户角度考虑,使用ET模式,它可以便捷的处理EPOLLOUT事件,省去打开与关闭EPOLLOUT的epoll_ctl(EPOLL_CTL_MOD)调用。从而有可能让你的性能得到一定的提升。例如你需要写出1M的数据,写出到socket 256k时,返回了EAGAIN,ET模式下,当再次epoll返回EPOLLOUT事件时,继续写出待写出的数据,当没有数据需要写出时,不处理直接略过即可。而LT模式则需要先打开EPOLLOUT,当没有数据需要写出时,再关闭EPOLLOUT(否则会一直返回EPOLLOUT事件),而调用epoll_ctl是系统调用,要陷入内核并且需要操作加锁红黑树,总体来说,ET处理EPOLLOUT方便高效些,LT不容易遗漏事件、不易产生bug,如果server的响应通常较小,不会触发EPOLLOUT,那么适合使用LT,例如redis等,这种情况下甚至不需要关注EPOLLOUT,流量足够小的时候直接发送,如果发送不完在进行关注EPOLLOUT,发送完取消关注就行了,可以进行稍微的优化。而nginx作为高性能的通用服务器,网络流量可以跑满达到1G,这种情况下很容易触发EPOLLOUT,则使用ET。

实际应用:

当epoll工作在ET模式下时,对于读操作,如果read一次没有读尽buffer中的数据,那么下次将得不到读就绪的通知,造成buffer中已有的数据无机会读出,除非有新的数据再次到达。对于写操作,主要是因为ET模式下fd通常为非阻塞造成的一个问题——如何保证将用户要求写的数据写完。

要解决上述两个ET模式下的读写问题,我们必须实现:

a. 对于读,只要buffer中还有数据就一直读;

b. 对于写,只要buffer还有空间且用户请求写的数据还未写完,就一直写。

使用这种方式一定要使每个连接的套接字工作于非阻塞模式,因为读写需要一直读或写直到出错(对于读,当读到的实际字节数小于请求字节数时就可以停止),而如果你的文件描述符如果不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。这样就不能在阻塞在epoll_wait上了,造成其他文件描述符的任务饿死。

所以也就常说“ET需要工作在非阻塞模式”,当然这并不能说明ET不能工作在阻塞模式,而是工作在阻塞模式可能在运行中会出现一些问题。

ET模式下的accept

考虑这种情况:多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪

连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理。

解决办法是用 while 循环抱住 accept 调用,处理完 TCP 就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢? accept 返回 -1 并且 errno 设置为 EAGAIN 就表示所有连接都处理完。

的正确使用方式为:

while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote, (size_t *)&addrlen)) > 0) {  

    handle_client(conn_sock);  

}  

if (conn_sock == -1) {  

     if (errno != EAGAIN && errno != ECONNABORTED   

            && errno != EPROTO && errno != EINTR)   

        perror("accept");  

}
扩展:服务端使用多路转接技术(select,poll,epoll等)时,accept应工作在非阻塞模式。

原因:如果accept工作在阻塞模式,考虑这种情况: TCP 连接被客户端夭折,即在服务器调用 accept 之前(此时select等已经返回连接到达读就绪),客户端主动发送 RST 终止连接,导致刚刚建立的连接从就绪队列中移出,如果套接口被设置成阻塞模式,服务器就会一直阻塞在 accept 调用上,直到其他某个客户建立一个新的连接为止。但是在此期间,服务器单纯地阻塞在accept 调用上(实际应该阻塞在select上),就绪队列中的其他描述符都得不到处理。

解决办法是把监听套接口设置为非阻塞, 当客户在服务器调用 accept 之前中止

某个连接时,accept 调用可以立即返回 -1, 这时源自 Berkeley 的实现会在内核中处理该事件,并不会将该事件通知给 epoll,而其他实现把 errno 设置为 ECONNABORTED 或者 EPROTO 错误,我们应该忽略这两个错误。(具体可参看UNP v1 p363)

EPOLlONSHOT

在一些监听事件和读取分开的场景中,比如说在主线程中监听,在子线程中接收数据并处理,这时候会出现两个线程同时操作一个socket的局面,比如说主线程监听到事件交由线程1处理,还未处理完又有事件到达,主线程交由线程2处理,这就导致数据不一致,一般情况下需要在该文件描述符上注册EPOLLONESHOT事件,操作系统最多触发其上注册的一个可读可写或异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该EPOLLONESHOT事件。反过来思考也一样,注册了该事件的线程处理完数据后必须重新注册,否则下次不会再次触发。参见《linux高性能服务器编程》9.3.4节

但是有一个缺陷,这样的话会每次都调用epoll_ctrl陷入内核,并且epoll为保证线程安全会使用了加锁红黑树,这样会严重影响性能,此时就需要换一种思路,在应用层维护一个原子整数或称为flag来记录当前句柄是否有线程在处理,每次有事件到来得时候会检查这个原子整数,如果在处理就不会分配线程处理,否则会分配线程,这样就避免了陷入内核,使用epoll_data来存储这个原子整数就行。

对于使用EPOLLSHOT方式来防止数据不一致既可以使用ET也可以使用LT,因为他防止了再次触发,但是使用原子整数的方式只能使用ET模式,他不是防止再次触发,而是防止被多个线程处理,在有些情况下可能计算的速度跟不上io涌来的速度,就是无法及时接收缓冲区的内容,此时接收线程和主线程是分开的,如果使用LT的话主线程会一直触发事件,导致busy-loop。 而使用ET触发只有在事件到来得时候会触发,缓冲区有内容并不会触发,触发的次数就变少了,虽然主线程还是可能空转(fd有事件到来,但已被线程处理,此时不需要处理,继续epoll_wait就好),但这样空转比屡次调用epoll_ctl的概率小多了。

epoll的误区

1. epoll ET模式只支持非阻塞句柄?

其实也支持阻塞句柄,只不过根据应用的使用场景,一般只适合非阻塞使用,参见上文“EPOLL ET与LT的实际应用”

2. epoll的共享内存?

epoll相对于select高效是因为从内核拷贝就绪文件描述符的时候用了共享内存? 这是不对的,实现的时候只是用了使用了copy_from_user跟__put_user进行内核跟用户虚拟空间数据交互,并没有共享内存的api。

问题集锦

epoll需要再次op->poll的原因

因为等待队列中的有事件后会唤醒所有的进程,可能有的进程位于对头把事件消费后就直接删除了这个事件,后面的进程唤醒后可能再没有事件消费了,所以需要再次判断poll,如果事件还在则加入rdlist中。当然消费完事件后不一定会删除,等待队列中可以通过flag选项设置消费的方式。

epoll每次都将txlist中的LT事件不等用户消费就直接返回给rdlist,那么在用户消费了该事件后,导致事件不就绪,再次调用epoll_wait,epoll_wait还会返回rdlist吗?

不会再次返回,因为在返回就绪列表之前会还调用一次revents = epi->ffd.file->f_op->poll(epi->ffd.file, NULL) 来判断事件,如果事件发生了变化,就不在返回。

内核的等待队列:

内核为了支持对设备的阻塞访问,就需要设计一个等待队列,等待队列中是一个个进程,当设备事件就绪后会唤醒等待队列中的进程来消费事件。但是在使用select监听非阻塞的句柄时候,这个队列不是用来实现非阻塞,而是实现状态的等待,即等待某个可读可写事件发生后通知监听的进程

内核的 poll技术就是为了poll/select设计的?

每个设备的驱动为了支持操作系统虚拟文件系统对其的使用需要提供一系列函数,比如说read,write等,其中poll就是其中一个函数,为了select,poll实现,用来查询设备是否可读或可写,或是否处于某种特殊状态。

eventPoll的两个队列

evnetpoll中有两个等待队列,

wait_queue_head_t wq;

wait_queue_head_t poll_wait;

前者用于调用epoll_wait()时, 我们就是"睡"在了这个等待队列上...

后者用于这个用于epollfd本事被poll的时候... 也就是说epollfd被其他epoll监视,调用其file->poll() 时。

对于本epoll监视的句柄有消息的时候会向wq消息队列进行wakeup,同时对于poll_wait也会进行wakeup

eventPollfs实现的file opetion

只实现了poll和realse,由于epoll自身也是文件系统,其描述符也可以被poll/select/epoll监视,因此需要实现poll方法,具体就是ep_eventpoll_poll方法,他内部实现是将监听当前epollfd的线程插入到自己的poll_wait队列中,判断自己接听的句柄是否有事件发生,如果有的话需要将信息返回给监听epollfd的epoll_wait, 具体方法是然后扫描就绪的文件列表, 调用每个文件上的poll 检测是否真的就绪, 然后复制到用户空间,但是文件列表中有可能有epoll文件, 调用poll的时候有可能会产生递归, 所以用ep_call_nested 包装一下, 防止死循环和过深的调用。具体参见问题递归深度检测(ep_call_nested)

epoll的线程安全问题

当一个线程阻塞在epoll_wait()上的时候,其他线程向其中添加新的文件描述符是没问题的,如果这个文件描述符就绪的话,阻塞线程的epoll_wait()会被唤醒。但是如果正在监听的某文件描述符被其他线程关闭的话像表现是未定义的。在有些 UNIX系统下,select会解除阻塞返回,而文件描述符会被认为就绪,然而对这个文件描述符进行IO操作会失败(除非这个文件描述符又被分配了),在Linux下,另一个线程关闭文件描述符没有任何影响。但不管怎样,应当尽量避免一个线程关闭另一个线程在监听的文件描述符。

递归深度检测(ep_call_nested)

epoll本身也是文件,也可以被poll/select/epoll监视,如果epoll之间互相监视就有可能导致死循环。epoll的实现中,所有可能产生递归调用的函数都由函数ep_call_nested进行包裹,递归调用过程中出现死循环或递归过深就会打破死循环和递归调用直接返回。该函数的实现依赖于一个外部的全局链表nested_call_node(不同的函数调用使用不同的节点),每次调用可能发生递归的函数(nproc)就向链表中添加一个包含当前函数调用上下文ctx(进程,CPU,或epoll文件)和处理的对象标识cookie的节点,通过检测是否有相同的节点就可以知道是否发生了死循环,检查链表中同一上下文包含的节点个数就可以知道递归的深度。参见参考2。

为什么需要创建一个文件系统:

一是可以在内核维护一些信息,这些信息在多次epoll_wait之间是保持的(保存的是eventpoll结构)第二点是epoll本身也可以被poll/epoll

两个回调函数

Epoll向等待队列有两个函数交互,分别是调用对应设备的poll函数,在poll函数中调用ep_ptable_queue_proc函数,将当前进程插入到等待队列,指定ep_poll_callback为唤醒时的回调函数。Ep_poll_callback实现将当前的句柄复制到rdlist并wakeup,eventpoll的wq等待队列。

你可能感兴趣的:(Linux服务器开发,后端开发,Linux后台开发,epoll,epoll源码,epoll实现,Linux服务器开发,后端开发)