ET(Edge Triggered 边沿触发) vs LT(Level Triggered 水平触发)
5.3.1 ET vs LT - 概念
说到Epoll就不能不说说Epoll事件的两种模式了,下面是两个模式的基本概念
Edge Triggered (ET) 边沿触发
.socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件
.socket的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发读事件
仅在缓冲区状态变化时触发事件,比如数据缓冲去从无到有的时候(不可读-可读)
Level Triggered (LT) 水平触发
.socket接收缓冲区不为空,有数据可读,则读事件一直触发
.socket发送缓冲区不满可以继续写入数据,则写事件一直触发
符合思维习惯,epoll_wait返回的事件就是socket的状态
通常情况下,大家都认为ET模式更为高效,实际上是不是呢?下面我们来说说两种模式的本质:
我们来回顾一下,5.2节(3)epoll唤醒逻辑 的第五个步骤
[5]遍历epoll的ready_list,挨个调用每个sk的poll逻辑收集发生的事件
大家是不是有个疑问呢:挂在ready_list上的sk什么时候会被移除掉呢?其实,sk从ready_list移除的时机正是区分两种事件模式的本质。因为,通过上面的介绍,我们知道ready_list是否为空是epoll_wait是否返回的条件。于是,在两种事件模式下,步骤5如下:
对于Edge Triggered (ET) 边沿触发:
[5]遍历epoll的ready_list,将sk从ready_list中移除,然后调用该sk的poll逻辑收集发生的事件
对于Level Triggered (LT) 水平触发:
[5.1]遍历epoll的ready_list,将sk从ready_list中移除,然后调用该sk的poll逻辑收集发生的事件[5.2]如果该sk的poll函数返回了关心的事件(对于可读事件来说,就是POLL_IN事件),那么该sk被重新加入到epoll的ready_list中。
对于可读事件而言,在ET模式下,如果某个socket有新的数据到达,那么该sk就会被排入epoll的ready_list,从而epoll_wait就一定能收到可读事件的通知(调用sk的poll逻辑一定能收集到可读事件)。于是,我们通常理解的缓冲区状态变化(从无到有)的理解是不准确的,准确的理解应该是是否有新的数据达到缓冲区。
而在LT模式下,某个sk被探测到有数据可读,那么该sk会被重新加入到read_list,那么在该sk的数据被全部取走前,下次调用epoll_wait就一定能够收到该sk的可读事件(调用sk的poll逻辑一定能收集到可读事件),从而epoll_wait就能返回。
5.3.2 ET vs LT - 性能
通过上面的概念介绍,我们知道对于可读事件而言,LT比ET多了两个操作:(1)对ready_list的遍历的时候,对于收集到可读事件的sk会重新放入ready_list;(2)下次epoll_wait的时候会再次遍历上次重新放入的sk,如果sk本身没有数据可读了,那么这次遍历就变得多余了。
在服务端有海量活跃socket的时候,LT模式下,epoll_wait返回的时候,会有海量的socket sk重新放入ready_list。如果,用户在第一次epoll_wait返回的时候,将有数据的socket都处理掉了,那么下次epoll_wait的时候,上次epoll_wait重新入ready_list的sk被再次遍历就有点多余,这个时候LT确实会带来一些性能损失。然而,实际上会存在很多多余的遍历么?
先不说第一次epoll_wait返回的时候,用户进程能否都将有数据返回的socket处理掉。在用户处理的过程中,如果该socket有新的数据上来,那么协议栈发现sk已经在ready_list中了,那么就不需要再次放入ready_list,也就是在LT模式下,对该sk的再次遍历不是多余的,是有效的。同时,我们回归epoll高效的场景在于,服务器有海量socket,但是活跃socket较少的情况下才会体现出epoll的高效、高性能。因此,在实际的应用场合,绝大多数情况下,ET模式在性能上并不会比LT模式具有压倒性的优势,至少,目前还没有实际应用场合的测试表面ET比LT性能更好。
5.3.3 ET vs LT - 复杂度
我们知道,对于可读事件而言,在阻塞模式下,是无法识别队列空的事件的,并且,事件通知机制,仅仅是通知有数据,并不会通知有多少数据。于是,在阻塞模式下,在epoll_wait返回的时候,我们对某个socket_fd调用recv或read读取并返回了一些数据的时候,我们不能再次直接调用recv或read,因为,如果socket_fd已经无数据可读的时候,进程就会阻塞在该socket_fd的recv或read调用上,这样就影响了IO多路复用的逻辑(我们希望是阻塞在所有被监控socket的epoll_wait调用上,而不是单独某个socket_fd上),造成其他socket饿死,即使有数据来了,也无法处理。
接下来,我们只能再次调用epoll_wait来探测一些socket_fd,看是否还有数据可读。在LT模式下,如果socket_fd还有数据可读,那么epoll_wait就一定能够返回,接着,我们就可以对该socket_fd调用recv或read读取数据。然而,在ET模式下,尽管socket_fd还是数据可读,但是如果没有新的数据上来,那么epoll_wait是不会通知可读事件的。这个时候,epoll_wait阻塞住了,这下子坑爹了,明明有数据你不处理,非要等新的数据来了在处理,那么我们就死扛咯,看谁先忍不住。
等等,在阻塞模式下,不是不能用ET的么?是的,正是因为有这样的缺点,ET强制需要在非阻塞模式下使用。在ET模式下,epoll_wait返回socket_fd有数据可读,我们必须要读完所有数据才能离开。因为,如果不读完,epoll不会在通知你了,虽然有新的数据到来的时候,会再次通知,但是我们并不知道新数据会不会来,以及什么时候会来。由于在阻塞模式下,我们是无法通过recv/read来探测空数据事件,于是,我们必须采用非阻塞模式,一直read直到EAGAIN。因此,ET要求socket_fd非阻塞也就不难理解了。
另外,epoll_wait原本的语意是:监控并探测socket是否有数据可读(对于读事件而言)。LT模式保留了其原本的语意,只要socket还有数据可读,它就能不断反馈,于是,我们想什么时候读取处理都可以,我们永远有再次poll的机会去探测是否有数据可以处理,这样带来了编程上的很大方便,不容易死锁造成某些socket饿死。相反,ET模式修改了epoll_wait原本的语意,变成了:监控并探测socket是否有新的数据可读。
于是,在epoll_wait返回socket_fd可读的时候,我们需要小心处理,要不然会造成死锁和socket饿死现象。典型如listen_fd返回可读的时候,我们需要不断的accept直到EAGAIN。假设同时有三个请求到达,epoll_wait返回listen_fd可读,这个时候,如果仅仅accept一次拿走一个请求去处理,那么就会留下两个请求,如果这个时候一直没有新的请求到达,那么再次调用epoll_wait是不会通知listen_fd可读的,于是epoll_wait只能睡眠到超时才返回,遗留下来的两个请求一直得不到处理,处于饿死状态。
5.3.4 ET vs LT - 总结
最后总结一下,ET和LT模式下epoll_wait返回的条件
ET - 对于读操作
[1] 当接收缓冲buffer内待读数据增加的时候时候(由空变为不空的时候、或者有新的数据进入缓冲buffer)
[2] 调用epoll_ctl(EPOLL_CTL_MOD)来改变socket_fd的监控事件,也就是重新mod socket_fd的EPOLLIN事件,并且接收缓冲buffer内还有数据没读取。(这里不能是EPOLL_CTL_ADD的原因是,epoll不允许重复ADD的,除非先DEL了,再ADD)
因为epoll_ctl(ADD或MOD)会调用sk的poll逻辑来检查是否有关心的事件,如果有,就会将该sk加入到epoll的ready_list中,下次调用epoll_wait的时候,就会遍历到该sk,然后会重新收集到关心的事件返回。
ET - 对于写操作
[1] 发送缓冲buffer内待发送的数据减少的时候(由满状态变为不满状态的时候、或者有部分数据被发出去的时候)
[2] 调用epoll_ctl(EPOLL_CTL_MOD)来改变socket_fd的监控事件,也就是重新mod socket_fd的EPOLLOUT事件,并且发送缓冲buffer还没满的时候。
LT - 对于读操作
LT就简单多了,唯一的条件就是,接收缓冲buffer内有可读数据的时候
LT - 对于写操作
LT就简单多了,唯一的条件就是,发送缓冲buffer还没满的时候
在绝大多少情况下,ET模式并不会比LT模式更为高效,同时,ET模式带来了不好理解的语意,这样容易造成编程上面的复杂逻辑和坑点。因此,建议还是采用LT模式来编程更为舒爽。