前言
本文主要从 select 和 epoll 系统调用入手,来打开 Netty 的大门,从认识 Netty 的基础原理 —— I/O 多路复用模型开始。
Netty 的通信原理
Netty 底层的通信机制是基于I/O多路复用模型构建的,简单一句话概括就是多路网络连接可以复用一个I/O线程,在 Java 层面也就是封装了其 NIO API,但是 JDK 底层基于 Linux 的 epoll 机制实现(其实是三个函数)。注意在老旧的 Linux 上,可能还是 select,没考证过,但是时下主流版本,肯定早就是 epoll 机制了,不妨就认为 JDK NIO 底层是基于 epoll 模型。
想象这样一个场景:老师站在讲台上提问,下面100个学生把答案写在纸上,谁写完谁举手示意,让老师来检查,完成的好就可以放学回家。如果学生张三举手,李四也举手,就表示他们已经完成了,老师就立即依次去检查张三和李四的答案,检查完毕,老师就可以返回讲台休息或者溜达等等,接着王五,赵四儿又举手,然后老师马上去检查他们的答案。。。以此往复。
如上这种生活现象就是 I/O 多路复用模型,Linux下的 select、poll,和epoll 就是实现的这种机制,这样就避免了大量的无用操作,比如,老师不需要依次的等待一个学生写完了,然后检查一个学生,检查完毕,再去等待下一个学生。。。(对应多客户端单线程模型),也不需要请100个老师,每个老师对应1个学生(一客户端一线程的 BIO 模型),而是让所有学生先自己闷头写答案,写完才主动举手示意,老师在去检查答案,处理完毕,老师就可以走了,继续等待其它学生举手,全程一个老师就能处理(epoll 函数),这就是所谓的非阻塞模式。另外,老师也不需要顺序的询问每个学生的问题完成情况(select 函数)只需要看谁举手。。。这样老师不烦躁,学生也能专心答题。
类比到通信,整个I/O过程只在调用 select、poll、epoll 这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,从而使得系统在单线程(进程)的情况下,可以同时处理多个客户端请求,这就是I/O 多路复用模型。与传统的多线程(单线程)模型相比,I/O多路复用的最大优势就是系统开销小,系统不需要创建新的额外线程,也不需要维护这些线程的运行、切换、同步问题,降低了系统的开发和维护的工作量,节省了时间和系统资源。
主要的应用场景,服务器需要同时处理多个处于监听状态或多个连接状态的套接字,服务器需要同时处理多种网络协议的套接字。
支持I/O多路复用的系统调用主要有select、pselect、poll、epoll。而当前推荐使用的是epoll,优势如下:
- 支持一个进程打开的socket fd(file description)不受限制
- I/O效率不会随着fd数目的增加而线性下将
- 使用mmap加速内核与用户空间的消息传递。
- epoll拥有更加简单的API。
而常见的一种 I/O 多路复用模型有所谓的 reactor 模式,Netty 就实现了多线程的 reactor 模型(reactor 模型有三种,单线程,多线程和主从),即当有感兴趣的事件(event)发生,就通知对应的事件处理器(ChannelHandler)去处理这个事件,如果没有就不处理。故用一个线程(NioEventLoop)做轮询就可以了。如果要获得更高性能,可以使用少量的线程,一个负责接收请求(boss NioEventLoopGroup),其他的负责处理请求(worker NioEventLoopGroup),对于多 CPU 时效率会更高(Netty 的线程池会默认启动 2 倍的 CPU 核数个线程)。
后续笔记会详细分析。
Socket 的抽象层次
Socket是一种"打开—读/写—关闭"模式的实现,服务器和客户端各自维护一个"文件",在建立连接打开后,可以向自己文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
不同层次的抽象,对 Socket 的解释是不一样的,在计算机网络中,解释 Socket 是 ip 地址+端口号,都对,主要看是哪一层次的抽象。
在网络编程层次,这些Socket函数是操作系统内核实现的,用户代码无法触及,只能使用,这些内核代码把TCP/IP协议栈和网卡封装,暴露出来对用户友好的API,就成了所谓的 Socket 函数,用户代码可以用这些 Socket 函数操纵本地的TCP/IP协议栈和网卡,和服务器通信。
回到网络层次,OSI 的上三层等价于 TCP/IP 协议族的应用层(典型的 Telnet、FTP 等应用), OSI 下两层等价于 TCP/IP 协议族中随系统提供的设备驱动程序和硬件。在一个网络程序中, 对应OSI 模型,上三层处理应用本身的细节,却对应用底层的通信细节了解很少;下四层可以处理所有的底层网络的通信细节。OSI 的上三层可以对应所谓的用户进程,下四层通常对应操作系统内核的一部分,因此,把第4层和第5层之间的接口抽象为 Socket API 是自然而然的一个过程,即所谓的 Socket 所处的位置就是 TCP/IP 协议族应用层和传输层的交界处。
从编程角度看TCP协议状态转移过程
在 Linux 的网络编程这个层次中,客户机和服务器各有一个Socket文件,当两台主机通信时,客户机里的客户端应用进程 A 发送消息,通过 TCP协议数据包头的 SYN 标志位置1,进行主动打开,经 A 主机的 TCP/IP 协议栈发送到 LAN,然后经 WAN 中的路由器传给服务端应用进程 B 的目的主机所在的 LAN,之后经目的主机的 LAN 将报文传给目的主机,最后经目的主机的 TCP/IP 协议栈处理,服务器被动打开,将消息递交给目的应用程序 B。
具体分析如下,在连接建立阶段,客户端调用 connect() 函数发起主动连接——触发客户端的 TCP 协议栈发送 SYN 报文,此时客户端处于 SYN-SENT 态,如下。而在此之前,服务端的 Socket 需要已经处于监听态(LISTEN),在 Linux 上就是调用 listen() 函数即可实现监听 Socket。
服务端的 TCP 协议栈收到该 SYN 报文后,发送给处于 LISTEN 状态的服务端 Socket,服务端应用进程通过调用 accept() 函数触发其 TCP 协议栈发送 SYN+ACK 报文返回给客户端,此时服务端从 LISTEN 态转移到 SYN-RCVD 态。
客户端收到服务端的 SYN+ACK 报文后,发送确认的 ACK 报文,此时客户端从 SYN-SENT 态进入 ESTABLISHED 态,当服务端收到客户端的 ACK 报文后,同样会从 SYN-RCVD 态也进入 ESTABLISHED 态,此时服务端的 accept() 函数返回。
经过如上三个报文交互,TCP 连接建立,然后就可以进行数据传输。
在数据传输阶段,客户端的 Socket 可以调用 send() 函数发送数据,然后服务端的 Socket 接到客户端 Socket 传来的请求,调用 read() 函数读取,调用 write() 函数写入响应。
在连接断开阶段,以客户端主动关闭为例子。
客户端的 TCP 协议栈主动发送一个 FIN 报文,主动关闭到服务端方向的连接,此时客户端状态从 ESTABLISHED 态转移到 FIN-WAIT-1 态。通过调用 close() 函数即可实现。
服务端 TCP 协议栈收到 FIN 报文,就发回客户端一个 ACK 报文确认关闭,此时,服务端状态从 ESTABLISHED 态转移到 CLOSE-WAIT 态(因为是被动关闭),和 SYN 一样,一个 FIN 也占用一个序号,同时服务端还向客户端传送一个文件结束符。当客户端接受到服务端确认关闭的报文后,客户端状态从 FIN-WAIT-1 态转移到 FIN-WAIT-2 态。
接着这个服务端程序就关闭它的连接,这会导致服务端的 TCP 协议栈也会发送一个 FIN 报文给客户端,这里也能清楚看到,ACK 不消耗序号。此时,服务端状态从 CLOSE-WAIT 转移到 LAST-ACK 态。
客户端收到服务端的 FIN 报文,也必须发回一个ACK 确认报文。此时,客户端状态从 FIN-WAIT-2 态转移到 TIME-WAIT 态。
至此,TCP 连接关闭。
Linux 网络编程中的系统调用函数
对于运行在 Java 虚拟机上的 Java 语言来说,其自身的 Socket 函数,就是对操作系统的这些系统调用函数的封装而已。看看这些系统调用函数,有助于理解非阻塞通信原理,先认识一些辅助的 Socket 系统调用函数。
socket 函数:对应于普通文件的打开操作,要知道 Linux 中,一切都是文件,包括 Socket 本身也是一个文件,分别存在于客户端和服务端机器上。前面也提到了 fd,即普通文件的打开操作会返回一个文件描述符——file description,即 fd,socket() 函数就是用来创建 Socket 描述符(socket descriptor,即 sd) 的,它唯一标识一个 Socket。这个 sd 跟 fd 一样,后续的操作都会用到它,把它作为参数,通过它来进行一些 Socket 的读写操作。
bind 函数:给一个 sd 绑定一个协议和地址+端口号。
listen 函数:socket() 函数创建的 Socket 默认是一个主动类型的,listen 函数将 Socket 变为被动类型的,用于等待客户的连接请求。
connect 函数:客户端通过调用 connect 函数来建立与 TCP 服务器的连接。
accept 函数:TCP 服务端依次调用 socket()、bind()、listen() 之后,就会监听指定的 Socket 地址了,TCP 客户端依次调用 socket()、connect() 之后就向 TCP 服务端发送一个连接请求。服务端监听到这个请求后,调用 accept() 函数接收请求,如果 accept() 函数成功返回,则标识服务端与客户端已经正确建立连接,此时服务端可以通过 accept 函数返回的 Socket 来完成与客户端的通信,之后的操作就和普通的 I/O 操作(read 函数和 write 函数)没什么区别。
Linux select 函数
Linux 提供了 select/poll 函数,这些系统调用的进程通过将一个或多个 fd(文件描述符,Linux 的一切都是文件) 传递给 select 或 poll 系统调用,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的 I/O 系统调用上,这样 select/poll 可以帮我们侦测多个 fd 是否处于就绪状态。
具体的说,联系老师和学生考试的例子,select/poll 顺序扫描 fd 是否就绪,但是 select 支持的 fd 数量有限,因此它的使用受到了一些制约。Linux 还提供一个 epoll 系统调用,两个东西本质是一样的,只不过 epoll 高级一些,能力更强一些,是基于事件驱动方式代替顺序扫描,因此性能更高——当有 fd 就绪时,立即回调函数rollback。该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。
也就是说,我们调用 select/epoll 告知内核对哪些描述符(读、写或异常条件〉感兴趣以及等待多长时间。我们感兴趣的描述符不局限于套接字,任何描述符都可以使用select来测试。
乍一看上面的解释,可能会懵逼,当然,懂得就略过。下面就详细分析下,毕竟人家都黑我们 Javaer 不懂。。。
众所周知,read、write、recv, 和 recvfrom 等函数都是阻塞的函数,所谓阻塞,简单说,就是当函数不能成功执行完毕的时候,程序就会一直停在这里,无法继续执行以后的代码。
严格的说,Linux 对一个 fd 指定的文件或设备, 有两种工作方式: 阻塞与非阻塞方式。阻塞方式是指当试图对该 fd 进行读写时,如果当时没有数据可读,或者暂时不可写,程序就进入等待状态,直到可读或者可写为止。非阻塞方式是指如果 fd 没有数据可读,或者不可写,读/写的函数马上返回,不会等待结果。使用 selcet/epoll 函数就可以实现非阻塞编程。
先看 selcet 函数,它本质是一个轮循函数,即当循环询问fd时,可设置超时时间,超时时间到了就跳过代码继续往下执行。
1 fd_set readfd;
2 struct timeval timeout;
3
4 FD_ZERO(&readfd); // 初始化 readfd
5 FD_SET(gps_fd, &readfd); // 把 gps_fd 加入 readfd
6 timeout.tv_sec = 3; // 设置 3 秒超时
7 timeout.tv_usec = 0;
8
9 j = select(gps_fd+1, &readfd, NULL, NULL, &timeout); // 用 select 对 gps_fd 进行轮循
10 if(j>0){
11 if( FD_ISSET(gps_fd, &readfd) ){ // 如果 gps_fd 可读
12 i = read(gps_fd, buf, SIZE);
13 buf[i] = '\0';
14 }
15 }
C 系列的代码 ztm 的很繁琐,实现个简单的聊天 demo,都要很多代码和繁琐的考虑。。。到了如今,Java 强大的生态系统愈发完善,其 Netty 已经可以和 C++ 实现的异步非阻塞服务器抗衡,愈发想不通,为什么还有人要用 C++ 语言来实现类似项目(纯属个人吐槽。。。),这里只是依靠之前的知识基础,其实我也忘得差不多了。。。写了一个小小的方法,直观感受下,我们重点还是在 Java 这块。
View Code
主要看 select 函数的参数,帮助我们理解它的工作原理。先看参数类型,fd_set 是一个集合(struct),其中存放的是 fd,有的书也叫文件句柄。timeval 也是一个 struct,代表时间值
int select(int maxfdp, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);
第一个参数 int maxfdp:指fd_set集合中所有 fd 的范围,即所有文件描述符的最大值加1,不能错。
第二个参数 fd_set *readfds:集合中包括 fd,select 会监视这些 fd 是否可读,看名字 readfds 也能看出来,如果 readfds 中有一个文件可读,select 就会返回一个大于 0 的值,表示有文件可读,如果没有可读的,则根据 timeout 参数判断是否超时,若超时,select 返回 0,若发生错误直接返回负值。也可以传入 NULL 值,表示不关心任何文件的读变化。
第三个参数 fd_set *writefds:集合中包括 fd,select 监视这些 fd 是否可写,如果有一个 fd 可写,select 就返回一个大于 0 的值,否则根据 timeout 判断是否超时,后续和 readfds 一样。
第四个参数 fd_set *errorfds:同上,select 可以监视 fd 的错误异常。
第五个参数 struct timeval *timeout:是 select 的超时时间,它可使 select 处于三种状态;
1、传入 NULL,select 变为阻塞函数,一定等到被监视的 fd 集合中,某个 fd 发生变化为止;
2、设为 0,select 变成非阻塞函数,不管 fd 是否有变化,都立刻返回,fd 无变化返回 0,有变化返回一个正值;
3、大于 0,select 的阻塞超时时间,时间内有事件到来就返回,否则在超时后就一定返回,返回值同上。
前面说了,selcet 函数本质是一个轮循函数,即 select 内部会循环询问参数集合里的 fd,原理其实也很简单,每次轮询发现有 fd 发生变化,就会返回,否则一直轮询直到超时,如果没有超时就直接返回,不阻塞。轮询的目的就是发现 fd 可读或者可写,然后可以让单个进程去处理 I/O 事件,避免 fork 多个客户进程。
Linux epoll 机制
熟悉 Java NIO 编程的都知道,JDK 里也有 select() 方法,一般也叫它I/O多路复用器(网上有人翻译为选择器,个人感觉并不能突出其实现思想,我采纳了《Netty 权威指南》作者的翻译),它实际上底层并不是基于 Linux 的 select 系统函数实现,不要被名字误导,它是基于 epoll 系统函数而实现。下面就学习下这个系统函数,帮助我们理解 Java NIO 编程思想。
epoll 是 Linux 下 I/O多路复用器——select/poll 的增强版,最早出现在 Linux 内核 2.5.44中,其实现与使用方式与 select/poll 有一些差异,epoll 是通过了一组函数来完成有关任务,而不是类似 select 函数那样,只依靠一个函数。
select 函数的缺陷
简单的看下 select 的执行流程;首先要设置 maxfdp,将 fd 加入 select 监控集,使用一个 array 保存放到 select 监控集中的 fd,一是用于在 select 返回后,array 作为源数据和 fdset 进行 fd_isset 判断。二是在 select 返回后会把以前加入的但并无事件发生的 fd 清空,则每次开始 select 都要从 array 取得 fd 逐一加入。select 的模型必须在 select 前循环 array(加fd,取 maxfd),返回后循环 array。下面的 demo 只一次调用,很简单。
1 int main() {
2 char buf[10] = "";
3 fd_set rdfds; // 监视可读事件的 fd 集合
4 struct timeval tv; // 超时时间
5 int ret;
6 FD_ZERO(&rdfds); // 初始化 readfds
7 FD_SET(0, &rdfds); // fd==0 表示键盘输入
8 tv.tv_sec = 3;
9 tv.tv_usec = 500;
10 ret = select(1, &rdfds, NULL, NULL, &tv); // 第一个参数是 maxfdp,值是监控的 fd 号 + 1,本例就是 0 + 1
11 if(ret < 0)
12 printf("selcet error \r\n");
13 else if(ret == 0)
14 printf("timeout \r\n");
15 else
16 printf("ret = %d \r\n", ret);
17
18 if(FD_ISSET(0, &rdfds)){ // 说明监控的 fd 可读,stdin 输入已经发生
19 printf(" reading 。。。");
20 read(0, buf, 9); // 从键盘读取输入
21 }
22 write(1, buf, strlen(buf)); // 在终端回显
23 printf(" %d \r\n", strlen(buf));
24 return 0;
25 }
显然,可以发现 select 会做很多无用功。
1、即使只有一个 fd 就绪,select 也要遍历整个 fd 集合,这显然是无意义的操作。
2、如果事件需要循环处理,那么每次 select 后,都要清空以前加入的但并无事件发生的 fd 数组(本例子就一个),在每次重新开始 select 时,都要再次从 array 取得 fd 逐一加入 fd_set 集合,每次这样的操作都需要做一次从进程的用户空间到内核空间的内存拷贝,使得 select 的效率较低。
3、select 能够处理的最大 fd 数目是有限制的,而且限制很低,一般为 1024,如果客户端过多,会大大降低服务器响应效率。
epoll 高效的原因
select 函数将当前进程轮流加入每个 fd 对应设备的等待队列去询问该 fd 有无可读/写事件,无非是想,在哪一个设备就绪时能够通知当前进程退出调用,Linux 的开发者想到,找个“代理”的回调函数代替当前进程,去加入 fd 对应设备的等待队列,让这个代理的回调函数去等待设备就绪,当有设备就绪就将自己唤醒,然后该回调函数就把这个设备的 fd 放到一个就绪队列,同时通知可能在等待的轮询进程来这个就绪队列里取已经就绪的 fd。当前轮询的进程不需要遍历整个被侦听的 fd 集合。
简单说:
- epoll 将用户关心的 fd 放到了 Linux 内核里的一个事件表中,而不是像 select/poll 函数那样,每次调用都需要复制 fd 到内核。内核将持久维护加入的 fd,减少了内核和用户空间复制数据的性能开销。
- 当一个 fd 的事件发生(比如说读事件),epoll 机制无须遍历整个被侦听的 fd 集,只要遍历那些被内核 I/O 事件异步唤醒而加入就绪队列的 fd 集合,减少了无用功。
- epoll 机制支持的最大 fd 上限远远大于 1024,在 1GB 内存的机器上是 10 万左右,具体数目可以 cat/proc/sys/fs/file-max查看。
epoll 机制的两种工作方式:ET 和 LT
epoll 由三个系统调用组成,分别是 epoll_create,epoll_ctl 和 epoll_wait。epoll_create 用于创建和初始化一些内部使用的数据结构,epoll_ctl 用于添加,删除或修改指定的 fd 及其期待的事件,epoll_wait 就是用于等待任何先前指定的fd事件就绪。
服务端使用 epoll 步骤如下:
- 调用 epoll_create 在 Linux 内核中创建一个事件表;
- 将 fd(监听套接字 listener)添加到所创建的事件表;
- 在主循环中,调用 epoll_wait 等待返回就绪的 fd 集合;
int main() {
struct epoll_event ev, events[20];
struct sockaddr_in clientaddr, serveraddr;
int epfd;
int sd;
int maxi;
int nfds;
int i;
int sock_fd, conn_fd;
char buf[10];
epfd = epoll_create(2560); // 生成 epoll 句柄,size 告诉内核监听的 fd 数目最大值
// 当创建 epoll 句柄后,它就会占用一个 fd 值,所以在使用完 epoll,必须调用 close() 释放资源,否则可能导致 fd 被耗尽。
sd = socket(AF_INET, SOCK_STREAM, 0); // 创建 Socket
ev.data.fd = sd; // 设置与要处理事件相关的 fd,这里就是 Socket 的 sd
ev.events = EPOLLIN; // 设置感兴趣的 fd 事件类型, EPOLLIN 表示 fd 可读(包括对端 Socket 正常关闭)事件
epoll_ctl(epfd, EPOLL_CTL_ADD, sd, &ev);// EPOLL_CTL_ADD:注册新的 fd 到 epfd 中
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons(SERV_PORT);
bind(sd, (struct sockaddr*)&serveraddr, sizeof(serveraddr)); // 绑定 Socket
socklen_t clilen;
listen(sd, 20); // 转为监听的 Socket
int n;
while(1) {
nfds = epoll_wait(epfd, events, 20, 500); //等待 fd 感兴趣的事件发生,函数返回需要处理的事件数目
for(i = 0; i < nfds; i++) {
if(events[i].data.fd == sd) { // 新连接到了
clilen = sizeof(struct sockaddr_in);
conn_fd = accept(sd, (struct sockaddr*)&clientaddr, &clilen);
printf("accept a new client : %s\n", inet_ntoa(clientaddr.sin_addr));
ev.data.fd = conn_fd;
ev.events = EPOLLIN; // 设置 fd 的监听事件为可写
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev); // 注册新的 Socket 到 epfd
} else if(events[i].events & EPOLLIN) { // 可读事件被触发
if((sock_fd = events[i].data.fd) < 0)
continue;
if((n = recv(sock_fd, buf, 10, 0)) < 0) {
if(errno == ECONNRESET) {
close(sock_fd);
events[i].data.fd = -1;
} else {
printf("readline error \n");
}
} else if(n == 0) {
close(sock_fd);
printf("关闭 \n");
events[i].data.fd = -1;
}
printf("%d -- > %s\n",sock_fd, buf);
ev.data.fd = sock_fd;
ev.events = EPOLLOUT;
epoll_ctl(epfd, EPOLL_CTL_MOD, sock_fd, &ev); // 修改监听事件为可读
} else if(events[i].events & EPOLLOUT) { // 可写事件被触发
sock_fd = events[i].data.fd;
printf("OUT\n");
scanf("%s",buf);
send(sock_fd, buf, 10, 0);
ev.data.fd = sock_fd;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_MOD,sock_fd, &ev);
}
}
}
}
View Code
把 fd 加入到 epoll 的监听队列中, 当 fd 可读/写事件,这个条件发生时,在经验看来,epoll_wait() 当然会立即返回(也叫被触发),事实上确实是这样的,而且返回值是需要处理的 fd 事件的数目。这里要讨论的是 epoll_wait() 函数返回的条件到底都有什么。
如果 epoll_wait() 只在读/写事件发生时返回,就像前面举的经验例子,该触发叫做边缘触发——ET(edge-triggered),也就是说如果事件处理函数只读取了该 fd 的缓冲区的部分内容就返回了,接下来再次调用 epoll_wait(),虽然此时该就绪的 fd 对应的缓冲区中还有数据,但 epoll_wait() 函数也不会返回。
相反,无论当前的 fd 中是否有读/写事件反生了,只要 fd 对应的缓冲区中有数据可读/写,epoll_wait() 就立即返回,这叫做水平触发——LT(level-triggered)。
一句话总结,在 ET 模式下,只有 fd 状态发生改变,fd 才会被再次选出。ET 模式的特殊性,使 ET 模式下的一次轮询必须处理完本次轮询出的 fd 缓冲区里的的所有数据,否则该 fd 将不会在下次轮询中被选出。
select/poll 使用的触发方式是 LT,相对来说比较低效,而 ET 是 epoll 的高速工作方式。
epoll 缺点
epoll 每次只遍历活跃的 fd (如果是 LT,也会遍历先前活跃的 fd),在活跃 fd 较少的情况下就会很有优势,如果大部分 fd 都是活跃的,epoll 的效率可能还不如 select/poll。
Java NIO 的触发模式
如果写过 Java NIO 代码,那么就能推测到 JDK NIO 的 epoll 模型是 LT,在 Netty 的实际开发中,也能体会到 NioServerSocketChannel 是 LT,当然如果使用 Netty 自己实现的 epoll Channel,就是 ET。
Netty 的 NioEventLoop 模型中,每次轮询都会进行负载均衡,限制了每次从 fd 中读取数据的最大值,造成一次读事件处理并不会 100% 读完 fd 缓冲区中的所有数据。在基于 LT 的 NioServerSocketChannel 中,Netty 不需要做特殊处理,在处理完一个 I/O 事件后直接从 SelectionKey 中移除该事件即可,如果有未读完的数据,下次轮询仍会获得该事件。而在EpollServerSocketChannel,如果一次事件处理不把数据读完,需要手动地触发一次事件,否则下次轮询将不会读取先前活跃的 fd 遗留的数据。