本文基于 Netty 4.1 展开介绍相关理论模型、使用场景、基本组件、整体架构,知其然且知其所以然,希望给大家在实际开发实践、学习开源项目方面提供参考。
Netty 是一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端。
JDK 原生也有一套网络应用程序 API,但是存在一系列问题,主要如下:
NIO 编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大。
官方声称在 JDK 1.6 版本的 update 18 修复了该问题,但是直到 JDK 1.7 版本该问题仍旧存在,只不过该 Bug 发生概率降低了一些而已,它并没有被根本解决。
Netty 对 JDK 自带的 NIO 的 API 进行封装,解决上述问题,主要特点有:
Netty 常见的使用场景如下:
典型的应用有:阿里分布式服务框架 Dubbo 的 RPC 框架使用 Dubbo 协议进行节点间通信,Dubbo 协议默认使用 Netty 作为基础通信组件,用于实现各进程节点之间的内部通信。
非常方便定制和开发私有协议栈,账号登录服务器,地图服务器之间可以方便的通过 Netty 进行高性能的通信。
Netty 作为异步事件驱动的网络,高性能之处主要来自于其 I/O 模型和线程处理模型,前者决定如何收发数据,后者决定如何处理数据。
用什么样的通道将数据发送给对方,BIO、NIO 或者 AIO,I/O 模型在很大程度上决定了框架的性能。
(1)、同步阻塞IO
阻塞指的是I/O方法调用(比如read)在数据或者状态还没有就绪的时候,一直等待,直到就绪才返回。比如阻塞模式下的Socket的read方法,如果没有接收到数据,read方法将会阻塞,程序就会停在那里,不能做其他的事情。这种模型下,如果服务器想处理多个连接,那么就要为每个Socket连接创建一个单独的线程,开销会很大。
传统阻塞型 I/O(BIO)可以用下图表示:
特点如下:
(2)、同步非阻塞IO
非阻塞模型是I/O方法调用(比如read)无论数据有没有就绪,会马上返回。如果有数据就会读到数据,如果没有数据就返回一个错误码。应用程序需要用轮询的方式不断去检测数据有没有就绪,但是程序不会被阻塞,除了轮询程序还可以做其他事情。但是这种轮询的方式会浪费CPU的时间,效率不够高。
(3)、IO多路复用
IO多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待的问题。
如上图《多路分离函数select》所示,用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。当数据到达时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续执行。
从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
然而,使用select函数的优点并不仅限于此。虽然上述方式允许单线程内处理多个IO请求,但是每个IO请求的过程还是阻塞的(在select函数上阻塞),平均时间甚至比同步阻塞IO模型还要长。如果用户线程只注册自己感兴趣的socket或者IO请求,然后去做自己的事情,等到数据到来时再进行处理,则可以提高CPU的利用率。
如上图《Reactor实现多路复用》所示,EventHandler抽象类表示IO事件处理器,它拥有IO文件句柄Handle(通过get_handle获取),以及对Handle的操作handle_event(读/写等)。继承于EventHandler的子类可以对事件处理器的行为进行定制。Reactor类用于管理EventHandler(注册、删除等),并使用handle_events实现事件循环,不断调用同步事件多路分离器(一般是内核)的多路分离函数select,只要某个文件句柄被激活(可读/写等),select就返回(阻塞),handle_events就会调用与文件句柄关联的事件处理器的handle_event进行相关操作。
如上图《Reactor实现多路复用》所示,通过Reactor的方式,可以将用户线程轮询IO操作状态的工作统一交给handle_events事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而Reactor线程负责调用内核的select函数检查socket状态。当有socket被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行handle_event进行数据读取、处理的工作。由于select函数是阻塞的,因此多路IO复用模型也被称为异步阻塞IO模型。注意,这里的所说的阻塞是指select函数执行时线程被阻塞,而不是指socket。一般在使用IO多路复用模型时,socket都是设置为NONBLOCK的,不过这并不会产生影响,因为用户发起IO请求时,数据已经到达了,用户线程一定不会被阻塞。
IO多路复用是最常使用的IO模型,但是其异步程度还不够“彻底”,因为它使用了会阻塞线程的select系统调用。因此IO多路复用只能称为异步阻塞IO,而非真正的异步IO。
(4)、异步IO
异步模型是指用户给I/O方法调用(比如read)提供一个回调方法,I/O方法调用会立刻返回,等到数据就绪时回调方法会执行。这个看上去很好用,把所有的处理都推给了系统,用户只需要关心回调方法中的数据处理就行了,但是在高并发情况下,处理好系统I/O程序和用户程序之间的CPU竞争比较困难。
如上图《Proactor设计模式》所示,Proactor模式和Reactor模式在结构上比较相似,不过在用户(Client)使用方式上差别较大。Reactor模式中,用户线程通过向Reactor对象注册感兴趣的事件监听,然后事件触发时调用事件处理函数。而Proactor模式中,用户线程将AsynchronousOperation(读/写等)、Proactor以及操作完成时的CompletionHandler注册到AsynchronousOperationProcessor。AsynchronousOperationProcessor使用Facade模式提供了一组异步操作API(读/写等)供用户使用,当用户线程调用异步API后,便继续执行自己的任务。AsynchronousOperationProcessor 会开启独立的内核线程执行异步操作,实现真正的异步。当异步IO操作完成时,AsynchronousOperationProcessor将用户线程与AsynchronousOperation一起注册的Proactor和CompletionHandler取出,然后将CompletionHandler与IO操作的结果数据一起转发给Proactor,Proactor负责回调每一个异步操作的事件完成处理函数handle_event。虽然Proactor模式中每个异步操作都可以绑定一个Proactor对象,但是一般在操作系统中,Proactor被实现为Singleton模式,以便于集中化分发操作完成事件。
如上图《Proactor实现异步IO》所示,异步IO模型中,用户线程直接使用内核提供的异步IO API发起read请求,且发起后立即返回,继续执行用户线程代码。不过此时用户线程已经将调用的AsynchronousOperation和CompletionHandler注册到内核,然后操作系统开启独立的内核线程去处理IO操作。当read请求的数据到达时,由内核负责读取socket中的数据,并写入用户指定的缓冲区中。最后内核将read的数据和用户线程注册的CompletionHandler分发给内部Proactor,Proactor将IO完成的信息通知给用户线程(一般通过调用用户线程注册的完成事件处理函数),完成异步IO。
相比于IO多路复用模型,异步IO并不十分常用,不少高性能并发服务程序使用IO多路复用模型+多线程任务处理的架构基本可以满足需求。况且目前操作系统对异步IO的支持并非特别完善,更多的是采用IO多路复用模型模拟异步IO的方式(IO事件触发时不直接通知用户线程,而是将数据读写完毕后放到用户指定的缓冲区中)。Java7之后已经支持了异步IO,感兴趣的读者可以尝试使用。
(1)、大部分网络服务包括以下处理步骤
(2)、Reactor多线程模型图
值得注意的是Handler中的read和send方法是在Reactor线程而不是worker thread中执行的。这意味着对socket数据的读取发送数据和对数据的处理是在不同的线程中进行的.
(3)、Reactor多线程模型的主要问题
(4)、实际应用中的多线程模型(改进后的模型图)
Reactor线程专门用于接受客户端连接(通过acceptor);创建多个Event Loop ,组成Event Loop Pool,每个Event Loop都有自己的Selector,并且运行在独立的线程上;Acceptor对于每一个客户端的连接从EventLoopPool中选择一个Event Loop进行处理,并且保证每个客户端连接在整个生命周期中都是由同一个Event Loop线程来处理,从而使得Handler中的实现-read,decode,process,encode,send-都在同一个线程中执行。整个线程模型除了高效的性能,还有非常重要的一点是Handler的实现不需要加锁,一方面对性能有帮助,另一方面避免多线程编程的复杂度。