引问:NIO在服务端的应用已经被广为熟悉,但是在客户端的使用,其实给予的指导并不多。同时在我看来,NIO在客户端使用就是原来的长连接模式加上事件驱动的框架,而相对于短连接池模式来说,性能是否真的在任何环境都那么突出,其实不然。
最近正好要优化TB的Cache客户端,原始代码是用NIO写的,但是效率不高,性能也一般,因此反而拖累了服务端的表现,在整个优化过程中,看了NIO2,也就是JDK7中比较突出的AIO,同时也经过反复优化和测试,其中对于NIO应用到客户端来谈一下自己的一些收获。
传统IO操作和NIO操作的区别
简单来说:1.对于数据处理由Stream方式转变称为了Block方式。2.事件驱动机制替换了传统的一个线程处理到底的模式。
第一种转变不适合需要对于字节流做处理的场景。(需要对字节充分作处理,例如我在另一个优化中对于字节流采用lazy Analysis,通过边解析边交验的方式,提前过滤无效请求,降低由于分析大数据包无效请求带来的性能消耗)。但是Block传输和处理这种转变符合操作系统的真实模式,使Java可以充分利用各个操作系统的实现来优化性能,同时管道的思想也符合操作系统的真实实现(原来Java都是将双向通道拆分为In,Out的)。事件驱动使得完整的处理流程被拆分成为流水线作业,最大程度上利用了资源,防止后端处理成为了前端请求的瓶颈,降低了服务器的吞吐量,同时最大限度的给开发者优化流程,缩短关键路径的机会。
下面表格大致列举了传统IO和NIO在客户端使用的需求和各自的优势(两者都需要的就不列入其中了,例如容错恢复等)
|
需求 |
优势 |
IO(连接池) |
1. 连接池的管理。2. 高并发大压力下Socket数量庞大,对于文件句柄消耗也大 |
1. 数据发送接受处理简单,单线程模式。2.可以对字节流逐一解析,避免内存过分无谓消耗 |
NIO |
1. 交互的协议需要支持会话。(也可以不支持,这就会使得处理模式退化,效率下降,后续会谈到)2. 对于接收和发送需要支持多线程,提高效率3. 需要对Channel和Block有所熟悉 |
1. 资源利用率最大化,性能提升(消息通道的充分利用,操作系统IO优化的使用)2. 充分灵活的将处理分割为多个工作项,流水线作业,减小业务处理对服务器服务请求接收吞吐量的影响。 |
NIO在客户端
上图描述的是NIO实现的客户端和服务端之间的数据交互场景,可以看到由于要最大限度利用消息通道,NIO客户端需要具备以下几点:
1.消息会话支持。
2.多线程访问控制。
3.消息过滤容错。
4.超时控制。
5…..
消息会话支持,指的是消息的传输与接收需要通过通信协议方式来实现会话,NIO在服务端可以很容易使用就是因为,NIO Server自身维护了服务处理会话,而对于客户端来说,首先上图可以看到,不同的线程使用NIO Client的时候,发送消息后得到回复的顺序并不一定和消息发送的顺序一致,因此需要通过在协议中内嵌会话码,这样才能够在结果返回并解析以后通知消息接收者。
多线程访问控制,对于消息的发送和接收,可以通过单线程处理,但是这将成为高并发下的处理瓶颈(后续的优化过程会详细说明)。如果允许多线程发送和接收消息,那么对于发送队列和接收缓存都将存在着并发访问控制的需求,同时对于发送和接收在数据需要切分的时候,需要通过小事务来保证数据的一致性。
消息过滤容错,由于共享一个数据通道,当返回数据出现问题的时候,如何过滤出错的数据防止解析异常和死锁很重要,也是多线程处理不相互影响的保证。
超时控制,由于共享数据通道是异步服务接收模式,因此请求队列会成为在高并发下的消耗资源,必要的将队列中的超时请求删除,是在网络异常或者服务端异常的时候,不会被压垮的保证。
其他其实还有很多设计细节和要点,这里就不一一列出了。
归结起来,如何高效的协调好多线程数据发送和接收及分析是共享数据通道的NIO客户端最重要的实现关注点。
NIO客户端优化分析
在优化前,客户端的结构就是最早的传统的NIO模式,以下两张图就可以说明大致的结构。
第一张图是NIO的概念模型,第二张图则是从实际使用的角度去描述了NIO框架中各个角色在实际模型中所处的地位。具体的NIO角色工作介绍就不详细说了,这里描述一下问题,在高并发下,发现等待消息返回的线程池内的线程不断堆积,同时处理性能也不断下降,呈现为恶性循环的状态。
传统模式下,事件处理是单线程串行处理的,例如如果ReadEvent在WriteEvent之前,当ReadEvent出现服务超时或者本身就比较耗时,那么导致其他事件无法得到处理或者处理比较慢。
初步认定原因:串行化事件处理,导致事件之间处理效率将会相互影响。
考虑的解决方式:事件处理线程化,将事件触发与事件处理分离,提高不同事件处理能力。
从上图中看到Dispatcher调用Handler处理事件和事件监听已经剥离开来,这样应该看起来会很好的解决当前的问题。
三下五除二,在Dispatcher中新增加了一个Executor(没有采用Cache的,使用的是fix的,防止资源由于暴涨导致产生大量线程搞垮应用),然后将attachment中的Channel代理类作了线程安全改造。
测试结果让我大跌眼镜,在高并发下不仅没有提高效率,反而效率降低。仔细观察后发现,由于对于连接,读,写三种事件都做了注册,在高并发下,读写的事件频度很高,因此会产生大量的线程,同时线程池是fix的(如果是Cache就直接OOM了),在创建线程中消耗的远大于原来认为是瓶颈的顺序执行,加上对于Channel代理的线程安全改造(资源锁等安全优化),导致最终性能还不抵原来的初始架构。
看来在Dispatcher中通过线程池方式来剥离框架和业务逻辑并不合适,因此考虑从其他角度入手。从另一方面来考虑,其实如果能够做到将Read事件和Write事件内部处理作的足够轻量,就算是顺序处理也会有很好的效果,因此就有了下面的设计:
LightWeightHander是一个轻量级的消息处理Handler,对于原来的业务数据处理做了拆分,达到就算是串行事件处理也能够防止事件之间相互影响。
工作拆分实际上成了优化最重要的部分,下面描述了工作拆分的分析点(也是不断通过测试结果得出的最后的策略)
上图是长连接模式中最常见的方式,系统中存在着发送和接收的缓冲区,但是对于发送没有采用多线程处理,而仅仅采用批量处理(设定一定的阀值,在高并发下缓冲区内容突然增长则采用批量Flush的方式提高效率),写出不采用多线程其实也是经过测试发现性能在线程创建过程中会有损耗,而且写的动作消耗时间有限,不需要做此优化。但是对于读部分,则存在着很多数据分析,拷贝,对象反序列化等操作,因此需要切割工作,支持并行处理,提高反馈速度。这里主要将读取分析过程切分成三部分,前两部分都是单线程完成操作,最后一步交由多线程池来执行。
第一步是由LightWeightHandler通过读取事件了解有数据需要读入,然后将数据包分别读入挂接在读入队列中。此处不使用多线程有两个原因,第一个原因也是线程创建消耗问题,第二个原因是当数据包由于过大需要分包接收,此时采用多线程接收,则会导致消息顺序错乱(消息包根据设定的接收窗口大小读入,因此没有编排)。第二步是将读入的数据包按照报文协议规则划分逻辑单元(此处需要采用ByteBuffer的各种特性,尽量避免创建新的数据块和相互拷贝,提高效率)。这部分采用单线程原因是因为此处无法实行流水线作业,本身就是串行的工作,关键路径无法缩短。第三步,将逻辑切分后的数据报文分配给每一个线程执行最耗时的解析和回调工作。
最后测试效果还是不错的,总结出来的几点经验:
1.多线程消耗有时候超过你的瓶颈,同时还需要防范在高并发下资源申请的问题。
2.谨慎分割的任务,将任务处理串行和并行结合起来,找到最短路径。
3.NIO处理中充分利用ByteBuffer等Buffer,数据逻辑分段和隔离,尽量少用拷贝和新建方式,提高4.处理速度。(需要注意的是Buffer的相对操作函数使用方式,避免出现Buffer复用导致异常)
批量处理需要做好测试,找到合理的阀值,防止批量带来的延时效应和峰值效应。
5.多线程调试尽量多用打印输出运行期状态了解性能瓶颈。(调试就不必了,基本无法确切了解内部高速运转细节)
NIO2
最近也关注了JDK7中的很多信特性,当然NIO2不得不说,这里还是提一下和本文比较相关的AIO。在NIO2中正式的出现了异步IO的接口及实现,对于这种异步模式,框架提供了两种方式获取结果:
采用Future的方式来获取。
采用Callback的方式(CompletionHandler)来获取。
在上面优化的框架中也有两种机制的实现,第二种就直接将方法注册到key的attachment中。第一种的实现如下图:
就是通过会话码及对象的Wait和Notify来实现的。
下图就是AIO的结构图:
其实AIO是在NIO的几个版本结构更新以后增加了更好的异步回调封装,异步回调封装就不多说了,这里就谈谈NIO迭代的几个版本中新增加的几个角色。
ChannelFacade其实就和优化过程中使用长连接最通用的输入输出缓存设计一样,对Channel外部在作了一层封装,提供优化和拦截处理的入口。InputHandler也就是负责对于输入内容的一个逻辑分析,判断是否需要Flush,这和优化中的批量Flush也十分类似。
后话
总的看来其实NIO在客户端的应用和我起初的想法还是比较一致,就是长连接模式加上事件驱动框架,同时如何处理好多线程并发控制及任务切分才是体现出NIO信道复用的关键,否则还是简单的用客户端连接池来的省心。