Netty 是Jboos 提供的java开源框架, 是基于非阻塞IO(NIO)的客户端/服务器编程框架, 它既能快速开发高并发、高可用、高可靠的网络服务器程序,也能开发高可用、高可靠的客户端程序。
Netty 作为异步框架, Netty 的所有IO操作都是异步非阻塞的, 通过feature-Listener机制,用户可以方便地主动获取或者通过通知机制获取IO操作结果。相比JDK原生NIO,Netty具有API使用简单、功能强大、定制能力强、性能高、成熟稳定等优点。
netty 主要用于两大场景:
场景一:web开发,在web应用中通讯协议基本都是http协议,web的后台基本使用的都是rest协议接口,rest协议采用的是http传输。 主要是集中在to C 的高并发应用中,主要是集中在基础性组件或 中台性质的组件,例如推送中台、服务总线。
场景二:非Http协议,典型的就是IM即时通讯。
首先,我们来看下在java程序中进行IO操作的代码是如何写的? 面向流的IO模型代码如下:
byte[] input = new byte[NioDemoConfig.SERVER_BUFFER_SIZE];
// 读取数据
socket.getInputStream().read(input);
Logger.info("收到:"+new String(input));
// 处理业务逻辑,获取处理结果
byte[] output =input;
// 写入结果
socket.getOutputStream().write(output);
面向缓冲区的NIO模型write代码如下:
/**
* 客户端
*/
public static void startClient() throws IOException {
InetSocketAddress address =
new InetSocketAddress(NioDemoConfig.SOCKET_SERVER_IP,
NioDemoConfig.SOCKET_SERVER_PORT);
// 1、获取通道(channel)
SocketChannel socketChannel = SocketChannel.open(address);
// 2、切换成非阻塞模式
socketChannel.configureBlocking(false);
//不断的自旋、等待连接完成,或者做一些其他的事情
while (!socketChannel.finishConnect()) {
}
Logger.info("客户端连接成功");
// 3、分配指定大小的缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
byteBuffer.put("hello world".getBytes());
byteBuffer.flip();
socketChannel.write(byteBuffer);
Logger.info("客户端写入成功");
socketChannel.shutdownOutput();
socketChannel.close();
}
面向缓冲区的read代码如下:
//若选择键的IO事件是“可读”事件,读取数据
SocketChannel socketChannel = (SocketChannel) selectedKey.channel();
// 读取数据
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int length = 0;
while ((length = socketChannel.read(byteBuffer)) > 0) {
byteBuffer.flip();
Logger.info(new String(byteBuffer.array(), 0, length));
byteBuffer.clear();
}
socketChannel.close();
以上代码是BIO /Nio的读写需要依赖操作系统的IO,java的IO操作属于应用程序,需要通过JNI调用C语言的库函数进行操作系统层面IO读写。如下图所示:
IO的本质是内核(CPU和内存)与磁盘、网卡等物理设备之间数据转移的过程。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内核空间,也有访问底层硬件设备的权限。为了避免用户进程直接操作内核空间,保证内核安全;操作系统将虚拟内存分为用户空间和内核空间,它们需要不同的执行权限。内核空间是内核代码运行的地方,用户空间是用户程序代码运行的地方。当进程运行在内核空间时处于内核态,当进程运行在用户空间时就处于用户态。
用户态、内核态的指令最终都是由CPU执行的,CPU指令会根据其重要程度划分了不同的权限,有些指令执行失败了没事,但是有些指令失败会导致操作系统的崩溃,甚至会重启系统。如果将这些指令随意开放给应用程序,就会让整个系统崩溃的概率大大增加。
应用程序是不允许直接在内核空间区域进行读写,也不允许直接调用内核代码定义的函数的。当用户态的程序需要向操作系统申请更高权限的服务时,就需要通过系统调用把用户态切换到内核态才可以向内核发起请求。
内核空间是驻留在内核中,它是为操作系统的内核保留的,按照访问权限,内核空间可以分为进程私有和进程共享两块区域。
每个用户进程都有一个单独的用户空间,用户空间的内存区域包括 运行时栈、运行时堆、代码段、未初始化的数据段、已初始化的数据段。
在TCP/IP网络分层模型中,整个协议栈被分成物理层、网络链路层、网络层、传输层和应用层。应用层属于用户空间, 对用的是我们常见的nginx、FTP等各种应用,以及我们写的各种服务端程序。 Linux 内核空间以及网卡驱动主要实现网络链路层、网络层和传输层的功能,内核为用户空间提供socket接口来支持用户进程的访问。 以linux 的视角看到的TCP/IP 网络分层模型如下图:
基于TCP/IP 为基础,网络数据收包和发包的路径示意图如下:
上图左半部分是读取网络数据包的过程,右半部分是发送数据网络的过程。
应用程序读取网络数据包的过程如下:
1、网卡收到数据以后,以DMA的方式把网卡数据写到内存的RingBuffer中;
2、向CPU发起一个中断,以通知CPU有数据到达;
3、当CPU收到中断请求后,简单处理后发出软中断请求。内核线程ksoftirqd发现有软中断请求到来,先关闭硬中断,尽快释放CPU资源。
4、将RingBuffer中的数据帧skb取下来放到内核申请一个内核态的skb内存中。
5、当数据帧skb 到达协议栈的网络层,取出数据帧的ip头,判断该数据帧该去哪里。去除IP头后,将数据包交给传输层处理。
6.当我们采用的是TCP协议时,数据包到达传输层后,取出TCP头后,根据四元组(源ip、源接口、目的IP、目的端口)查找对应的socket。
7、找到对应的socket后,将数据帧中的传输数据复制到socket的接收缓冲区中。
8.socket 接收缓冲区有数据后,CPU将socket 接收缓存冲的传输数据复制到用户空间的缓冲区。 最后系统调用read返回。应用程序读取数据。
应用程序发送网络数据包的过程如下:
1、当应用程序调用write系统调用发送数据时,用户线程从用户空间切换成内核态。
2、CPU把用户空间缓冲区的数据复制到Socket的发送缓冲区;
3、数据通过发送流程进入内核协议栈的传输层设置TCP头;
4、设置TCP头完成后后;通过内核函数进入内核协议栈的网络层进行IP头设置。
5、经过内核一些列的设置封装好一个完整的数据帧,将数据帧skb添加到RingBuffer中,然后通过DMA方式将数据帧通过物理网卡发送出去。
6、数据发送完成后,网卡设备向CPU发送一个硬中断,CPU简单处理完后,发出软中断对RingBuffer清理解除.
网路数据包读取和发送的过程中,涉及的性能开销主要有:
1、应用程序通过系统调用从用户态与内核态相互转换的开销;
2、内核缓存区与用户缓存区的数据相互复制的开销;
3、内核线程ksoftirqd响应软中断的开销;
4、CPU 响应硬中断的开销;
5、DMA拷贝网络数据包到内存缓冲区的开销;
也许你会问为什么不直接从网卡读写数据,我们 先来看下内存、机械硬盘、固态硬盘、网卡的IO性能对比情况,如下所示:
从上图中可以看出 ,网卡的读取速率是内存的百分之一,相比内存来说是很慢的,主要是从速率上考虑。
再了解网络数据包接收和发送的过程后,我们接下来谈谈阻塞、非阻塞和异步、同步的问题。
首先网络数据包的接收和发送过程可以分为两个阶段,如下图所示:
第一阶段是数据准备,此阶段是网卡与socket 之间通过DMA方式复制数据帧,CPU不参与其中,
第二阶段是数据复制, 内核缓冲区和用户缓冲区的数据相互复制的过程,同步与异步主要是这个阶段;同步是指用户空间主动发起IO请求,系统内核是被动接受方; 异步是指内核主动发起IO请求,用户空间是被通知的一方,属于被动接受方。
阻塞与非阻塞指的是用户进程的执行状态。阻塞是指用户进程一直等待,不能干别的事情;非阻塞是指用户进程拿到内核返回的状态就返回用户空间,可以去干其他事情。
IO模型的选择是构建一个高性能网络框架的基础,使用什么IO模型来读写数据将在很大程度上决定了网络框架的IO性能。IO模型有五种:同步阻塞IO(Blocking IO ,BIO)、同步非阻塞IO(Non-Blocking IO,NIO)、IO多路复用(IO Multiplexing)、信号驱动IO模型、异步IO(Asynchronous IO)
从用户进程发起创建Socket 到一个网络包到达网卡被用户进程接收,同步阻塞IO总体流程如下图:
从上图可以看出, 用户进程发起创建socket指令进入系统调用后,用户进程从用户态切换成了内核态,用户进程会先到socket对象的接收队列中查看是否有数据,没有数据的话就把自己添加到socket对应的等待队列中。当网络数据到达网卡后,通过DMA复制把数据复制到socket的缓存区,当socket 缓存区有数就,ksoftirqd 内核线程会通知等待队列上的用户进程。
在整个过程中, 由java应用程序(用户空间)主动发起IO请求,系统内核是被动接受方, 所以是同步IO; 在整个获取网络数据包的过程中,用户进程主动发起IO请求后,需要一直等待系统调用返回,在整个过程主动发起IO请求的用户进程状态是阻塞的。
同步阻塞IO的开销主要有以下:
(1)进程通过系统调用接收一个socket 上的数据时,如果数据没有达到进程就被从CPU撒花姑娘拿下来,然后再换上另一个进程,这导致一次进程上下文切换的开销;
(2)当连接上的数据就绪的时候,睡眠的进程又会被唤醒,导致进程切换的开销;
(3)一个进程同时只能等待一条连接,如果有很多并发,则需要很多进程。
同步阻塞IO 的特点是在内核进行IO执行的两个阶段,发起IO请求的用户进程被阻塞了;
同步阻塞Io的优点是应用的程序开发非常简单,在阻塞等待数据期间,用户线程挂起,用户线程基本不占用CPU资源。
阻塞IO 的缺点是:一般情况下,会为每个连接配备一个独立的线程,一个线程维护一个连接的IO操作。在并发量小的情况下,这样做没有什么问题。但是,当在高并发的应用场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。在高并发应用场景中,阻塞 IO 模型是性能很低的,基本上是不可用的。
从用户进程发起创建Socket 到一个网络包到达网卡被用户进程接收,同步非阻塞IO总体流程如下图:
与同步阻塞IO不同的时候, 没有等待队列,用户进程发起系统调用后,不管用没有数据会立即返回。
在NIO模型中,用户空间主动发起IO请求进行系统调用会出现以下两种情况:
(1 )在socket缓冲区中没有数据的情况下,系统调用会立即返回,返回一个调用失败的
信息。
(2 )在socket缓冲区中有数据的情况下,socket缓存区的数据复制到用户空间缓存区的过程是阻塞的,复制完成后,系统调用返回成功,用户进程可以开始处理用户空间的缓存数据。
同步非阻塞I并不是全程不阻塞,只是相对于与同步阻塞来说的。同步非阻塞IO 的特点:应用程序的线程需要不断地进行 IO 系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,直到完成 IO 系统调用为止。
同步非阻塞IO 的优点:每次发起的 IO 系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。
同步非阻塞IO 的缺点:不断地轮询内核,这将占用大量的 CPU 时间,效率低下。
在高并发应用场景中,同步非阻塞IO 是性能很低的,也是基本不可用的,一般 Web 服务器都不使用这种 IO 模型。在 Java 的实际开发中,也不会涉及这种 IO 模型。但是NIO的作用是为IO多路复用模型提供了基础。
IO 多路复用模型的出现主要是为了解决同步非阻塞IO模型中轮询等待的问题。大多数的高并发服务端的程序,一般都是基于linux系统的, 大多采用IO多路复用模型,包括netty框架也是采用的IO多路复用模型。
首先,我们来明确一下什么是多路?什么是复用?
多路:多路指的是需要处理的众多连接。
复用: 用有限的资源处理众多连接上的读写事件。即多个连接可以复用一个独立的线程去专门处理多个连接上的读写。
现在问题的关键是如何去实现复用,在同步非阻塞IO 模型中,是通过不断的轮询众多连接的socket缓存区看是是否有数据,如果有则处理,如果没有则继续轮询下一个Socket。这样就达到了用一个线程去处理众多连接上的读写事件了。但是频繁的系统调用也会带来大量的上下文切换开销,当并发量足够大的时候,就会导致性能下降。
其实我们可以把频繁的轮询操作交给操作系统来完成,从而避免用户空间频繁的系统调用带来的所带来的性能开销。目前支持IO多路复用的系统调用有select、poll和epoll。select 系统调用,几乎在所有的操作系统上都有支持,具有良好的跨平台特性。poll相当于改进版的select,工作原理基本上和select没有本质区别,只是解决了select 的1024个文件描述符fd的限制。epoll 是在 Linux 2.6 内核中提出的,是 select系统调用的 Linux 增强版本。
IO多路复用模型的特点: IO 多路复用模型的 IO 涉及两种系统调用,一种是 IO 操作的系 统调用,另一种是 select/epoll 就绪查询系统调用。 IO 多路复用模型建立在操作系统的基础设施之上,即操作系统的内核必须能够提供多路分离的系统调用 select/epoll 。
和 NIO 模型相似,多路复用 IO 也需要轮询。负责 select/epoll 状态查询调用的线程,需要 不断地进行 select/epoll 轮询,查找出达到 IO 操作就绪的 socket 连接。
IO 多路复用模型的优点:一个选择器查询线程,可以同时处理成千上万的网络连接, 所以,用户程序不必创建大量的线程,也不必维护这些线程,从而大大减小了系统的开销。 这是一个线程维护一个连接的阻塞 IO 模式相 比,使用多路 IO 复用模型的最大优势。
IO多路复用模型的缺点:本质上, select/epoll 系统调用是阻塞式的,属于同步 阻塞 IO 。 都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个事件的查询过程是 阻塞的。
epoll在select、poll、epoll三个中的性能表现是优秀的,能支持的并发量最大。所以在高性能的框架中都是使用epoll, 比如redis和netty。epoll 高性能最根本的原因是极大程度地减少了无用的进程上下文切换,让进程更专注地处理网络请求。
epoll相关的函数在内核环境中,可以分为两部分:
第一部分:用户进程内核态
在内核的硬、软中断上下文中,网络数据包到达网卡后,通过DMA复制到socket的缓存区,然后再找到socket关联的epitem,并把它添加到epoll对象的就绪链表中。
在用户进程调用epoll_create创建一个struct eventpoll的内核对象,eventpoll 有3个成员,分别是wq、rbr、rdllist。wq是等待队列链表,软中断数据就绪的时候会通过wq来找到阻塞在epoll对象上的用户进程。 rbr 是一个颗红黑树,为支持海量连接的高效查找、插入和删除,通过红黑树来管理用户进程下添加进来的socket连接。rdllist 是就绪的描述符的链表,当有连接就绪的时候,内核会把就绪的连接放到rdllist链表中。 这样应用进程只需要判断链表就能找出就绪连接。
通过epollctl中首先根据传入fd找到eventpoll、socket相关的内核对象。对于每个socket,调用epoll_ctl的时候,都会为socket分配一个epitem,epitem创建并初始化完成后,就会设置socket对象上的等待任务队列和数据就绪时的回调函数。分配完epitem对象后,紧接着把epitem插入红黑树。
在用户进程中,通过调用epollwait 来查看就绪链表中是否有事件到达,如果有,直接取走进行处理。处理完毕再次调用epollwait。在高并发的实战中,只要活足够多,epollwait根本不会让用户进程阻塞。用户进程会一直不同的干活,直到epoll_wait 中没活可干了才会主动让出CPU,这是epoll高效的核心。
第二部分:硬、软中断上下文
当网络数据包到达网卡的时候,将数据接收到socket的缓存区,在数据接收完成后,先查找等待队列上注册的回调函数,执行socket就绪回调函数,执行epoll就绪通知唤醒在socket上等待的用户进程返回事件。
epoll 有水平触发和边缘触发两站模式,这两种模式的关键区别在于当socket中的缓存区中还有数据可读时, epoll_wait 是否会情况rdllist。
水平触发:用户线程调用epoll_wait获取到IO就绪的socket后,对Socket进行系统IO调用读取数据,假设socket中的数据只读了一部分没有全部读完,这时再次调用epoll_wait,epoll_wait会检查这些Socket中的接收缓冲区是否还有数据可读,如果还有数据可读,就将socket重新放回rdllist。所以当socket上的IO没有被处理完时,再次调用epoll_wait依然可以获得这些socket,用户进程可以接着处理socket上的IO事件。JDK的NIO默认是水平触发模式。
边缘触发:epoll_wait就会直接清空rdllist,不管socket上是否还有数据可读。所以在边缘触发模式下,当你没有来得及处理socket接收缓冲区的剩下可读数据时,再次调用epoll_wait,因为这时rdlist已经被清空了,socket不会再次从epoll_wait中返回,所以用户进程就不会再次获得这个socket了,也就无法在对它进行IO处理了。除非,这个socket上有新的IO数据到达,根据epoll的工作过程,该socket会被再次放入rdllist中。在Netty中实现的EpollSocketChannel默认的就是边缘触发模式。
阻塞IO 模型、非阻塞IO模型、多路复用IO模型有个共同的特点都是同步的,三个IO模型都是基于用户空间与内核空间的交互进行划分的,此小节从用户空间的角度介绍一下同步IO线程模型Reactor线程模型,异步IO线程模型是Proactor 。
Reactor 模式也叫作反应器设计模式,Reactor模型是处理并发I/O比较常见的一种模式,中心思想是将所有要处理的I/O事件注册到一个中心I/O多路复用器上,同时主线程/进程阻塞在多路复用器上;一旦有I/O事件到来或是准备就绪(文件描述符或socket可读、写),多路复用器返回并将事先注册的相应I/O事件分发到对应的处理器中。简单概括就是将消息放到了一个队列中,通过异步线程池对其进行消费。简单来说Reactor模式的核心思想是IO多路复用与线程池相结合。
Reactor是一种事件驱动机制,和普通函数调用的不同之处在于:应用程序不是主动的调用某个API完成处理,而是恰恰相反,Reactor逆置了事件处理流程,应用程序需要提供相应的接口并注册到Reactor上,如果相应的事件发生,Reactor将主动调用应用程序注册的接口,这些接口又称为“回调函数”。
Reacotr模型主要以下角色:
(1)Reactor:把IO事件分配给对应的handler处理 ,是通过调度响应IO事件
(2)Handler:处理非阻塞的任务;完成真正的连接建立、通道的读取、处理业务逻辑、负责将结果写出到通道等。
(3)Acceptor:请求连接器,Reactor 接收到 client 连接事件后,会将其转发给 Acceptor,Acceptor 则会接受 Client 的连接,建立对应的Handler,并向 Reactor注册此Handler
根据Reactor的数量和处理资源的线程数量的不同,分为三类:
(1)单Reactor单线程模型
(2)单Reactor多线程模型
(3)多Reactor多线程模型
Reactor 反应器和 Handers 处理器处于一个线程中执行。它是最简单的反应器模型,如下图所示:
单线程Reactor 反应器模式,是基于 Java 的 NIO 实现的。相对于传统的多线程 OIO ,反应 器模式不再需要启动成千上万条线程,避免了线程上下文的频繁切换,服务端的效率自然是 大大提升了。 Reactor 线程通过select/epoll(IO多路复用接口)监听事件,收到事件后通过Dispatch 来分发事件,事件会分发给Acceptor和Handler两个组件,具体是哪个组件要看事件的类型。如果事件类型为建立连接,则将事件分发给Acceptor,Acceptor会通过 accept 方法 获取连接,并创建一个 Handler对象来处理后续的响应事件。如果时间类型不是建立连接,则将该事件交由当前连接的Handler来处理。
在单线程反应器模式中,Reactor反应器和 Handler处理器都执行在同一条线程上。这样, 带来了一个问题:当其中某个 Handler 阻塞时,会导致其他所有的 Handler 都得不到执行。在 这种场景下,被阻塞的 Handler 不仅仅负责输入和输出处理的传输处理器,还包括负责新连 接监听的 AcceptorHandler 处理器,这就可能导致服务器无响应。这个是非常严重的问题。因 为这个缺陷,因此单线程反应器模型在生产场景中使用得比较少。
单Reactor多线程模型升级了Handler处理器, 从高效率的角度考虑加入了线程池,单reactor多线程反应器如下图所示:
Reactor 接受请求后,根据请求类型来进行分发,分发逻辑与 单Reactor单线程 模型一样,不同之处在于Handler不在进行业务处理了,它只负责接受和发送,Handler接受数据后,会将数据发送给 Worker 线程池中的线程处理,该线程才是处理业务的真正线程,线程将业务处理完成后,将数据发送给Handler,然后Handler 再send出去。
优点
由于Handler使用了多线程模式,则可以利用充分利用CPU的性能。
缺点
Handler使用多线程模式,则会涉及到数据共享的问题,需要考虑互斥,实现肯定比 单Reactor单线程模式复杂一些;单Reactor,一个线程处理事件监听、分发、响应,对于高并发场景,容易造成性能瓶颈
单Reactor多线程模式解决了Handler单线程的性能问题,但是Reactor还是单线程的,对于高并发场景还是会有性能瓶颈,所以需要对Reactor调整为多线程模式。多Reactor多线程模型如下图所示:
主线程中的MainReactor对象通过select监听事件,接收到事件后通过Dispatch进行分发,如果事件类型为建立连接则将事件分发给Acceptor进行连接建立;如果收到的事件不是连接,则他将事件分发个某个SubReactor,SubrReactor将连接加入到连接队列进行监听,并创建Handler进行各种事件处理;如果有新的事件发生,SubReactor 则会调用当前连接的Handler来进行处理。Handler 通过read 读取数据后,将数据发送给Worker线程进行处理,Worker线程池则会分配线程进行业务处理,处理完成后返回结果,Handler接受结果后,通过send发送给客户端。
优点
该模式主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理,同时主线程和子线程的交互也很简单,子线程接收主线程的连接后,只管业务处理即可,无须关注主线程。
缺点
模型复杂。
这种模式适用于高并发场景,广泛运用于各种项目中,如大名鼎鼎的Netty。
一个IO事件从操作系统底层产生后,在Reactor反应器模式中的处理流程如下图所示:
步骤一:通道注册。IO事件源于通道(Channel),IO是和通道(对应于底层连接而言)
强相关的。一个IO事件一定属于某个通道。但是,如果要查询通道的事件,首先要将通道
注册到选择器。
步骤二:事件轮询;轮询Selector选择器中已注册的Channel的I/O事件;
步骤三:事件分发。如果查询到 IO 事件,则分发给与 IO 事件有绑定关系的 Handler 业务处
理器。
步骤四:任务处理;Reactor线程负责任务队列中的I/O任务,每个Worker线程从各自维护的任务队列中取出任务异步执行;
Netty高性能主要在于Reactor线程模型, EventLoop 是Netty Reactor 线程模型的核心处理引擎。
EventLoop是一种事件等待和处理的程序模型,可以解决多线程资源消耗高的问题。EventLoop运行模式:
1、应用程序将产生的事件放入事件队列;
2、EventLoop轮询队列中的事件,然后取出事件执行或者将事件分发给相应的事件监听者;
在Netty中,EventLoop可以理解为Reactor线程模型的事件处理引擎,每个EventLoop线程维护者一个Selector选择器和任务队列taskQueue。它主要负责处理I/O事件、普通任务和定时任务。
Netty主推NioEventLoop实现类,所以Netty的心脏就是NioEventLoop#run,每次循环的处理流程包含事件轮询select、事件处理processSelectedKeys、任务处理runAllTasks几个步骤,典型的Reactor线程模型的运行机制。
NioEventLoop采用的是无锁串行化的设计思路,其模型如下
根据Reactor的模型,NioEventLoop具体思路如下:
1、BoosEventLoopGroup和WorkerEventLoopGroup包含一个或者多个NioEventLoop。BootEventLoopGroup负责监听客户端的Accept事件,当事件触发时,将事件注册至WorkerEventLoopGroup中的一个NioEventLoop上。每新建一个Channel,就将Channel与NioEventLoop绑定。从而实现CHannel生命周期中的所有事件都有一个NioEventLoop处理。一个NioEventLoop可以处理多个Channel的事件,但一个Channel的事件只能被一个NioEventLoop处理。这就实现了不同的NioEventLoop线程之间不发生任何交集,从而不会发生线程切换,提高性能。
2、NioEventLoop完成数据读取后,会调用绑定的ChannelPipeline进行事件传播,ChannelPipeline也是线程安全的,数据会被传递到ChannelPipeline中第一个CHannelHandler中,执行完成后会传递给下一个CHannelHandler,从而实现串行化执行,不会发生线程上下文切换,提高性能。、
3、NIoEventLoop的优势:无锁串行化提高了系统吞吐量,降低用户开发业务逻辑难度,无须关注线程安全问题;劣势:不能执行时间过长的I/O操作,一旦某个事件I/O发生阻塞,后续的所有I/O事件都无法执行,造成事件积压。所以用Netty开发程序时,一定要对ChannelHandler的逻辑有充分的风险意识。
JDK中epoll的实现却是有漏洞的,其中最有名的就是NIO空轮询bug(该bug只存在于Linux,因为Linux中NIO底层是使用epoll实现的)。
理论上无客户端连接时Selector.select()方法会阻塞,但空轮询bug导致:即使无客户端连接,NIO照样不断的从select本应该阻塞的Selector.select()中wake up出来,导致CPU100%问题。
netty提供一种检测机制判断线程是否可能陷入空轮询,具体实现方式如下;
1、selector.select(timeoutMillis),调用了select方法,并默认设置1秒超时时间,同时记录轮询次数:selectCnt ++;
2、获取当前时间,计算select方法的操作时间是否真的阻塞了timeoutMillis,如果是就证明是一次正常的select(),重置selectCnt =1;如果不是,就可能触发了JDK的空轮询BUG,然后判断selectCnt 轮询次数是否大于默认的512,然后进行rebuildSelector()。
3、rebuildSelector()方法重新打开一个Selector;然后遍历oldSelector,将所有的key重新注册到新的Selector;然后重新赋值selector,selectCnt = 1;这时候已经规避了空轮询。