最近一年用NIO写了不少网络程序,也研究了一些开源NIO网络框架netty、mina等,总结了一下NIO的架构特点。
无论是netty还是mina它们都在java原生NIO的基础上进行了完善的封装,虽然细节有所不同,但总体架构思路一致,都大概划分出了以下几个组成部分:
- - transport:传输层的抽象
- - protocol: 协议codec的抽象
- - event model:统一事件模型
- - buffer:底层buffer封装
在完全屏蔽底层API的同时,对上层应用提供了自身的统一API接口。
框架进行黑盒封装的同时,再进行通用化的接口开放,带来的好处是统一化,但坏处是程序的透明度降低,抽象度提高,增加理解难度和实现难度。
下面说说每个部分的一些设计考量:
transport传输层抽象都是对java原生NIO API的封装,在这一层封装的程度在于框架的实现目标。例如mina立足于通用的网络框架,因此完全屏蔽了原生的API,提供了自身的统一接口,因为它不仅需要封装NIO的API还有一系列其他类型的IO操作的API,提供统一API接口。为了通用兼顾各类传输通道因此可能不得不暴露多余的API接口,使用方需甄别传输通道的不同,增加了理解难度。
protocol封装各类常用协议的codec操作,但目前这些网络框架的codec实现都与自身的API紧密绑定,降低了可重用性。
event model 事件模型的设计通常不能完全独立,例如java NIO本身的模型是事件驱动的,但传统阻塞型IO并非事件驱动,要兼顾二者通常要付出额外的代价和开销。
有一种说法是让异步IO同步化使用(因为同步化使用更简单,异步导致了业务处理的碎片化)到底对不对值得商榷?模型阻抗导致的代价和开销屏蔽在了黑盒中,也容易误导应用程序员对本该采用同步化处理的业务却滥用了异步化机制,并不会带来什么好处。
buffer 通常都用来配合底层IO数据流和协议codec使用,本身是否适合暴露给应用方取决于框架是否整合codec,因为codec本身带有业务性质,而纯粹的IO数据流处理使用的buffer则完全无需暴露给应用方。
以上简单说了下NIO框架各部分的设计考量,可以看出目前流行的NIO框架(netty和mina)都在走一条类似“瑞士军刀”的路线,集各种功能与一身(多种IO封装、协议封装),但你又很难把瑞士军刀上的某个刀片拆下来单独使用。
在实践中感觉,考虑从单一性、简洁性、重用性、组合性、透明性几个方面去设计原子化的IO组件也许更可取,更像是一种“工具箱”路线。
典型的事件驱动模型NIO框架组件交互图如下:
Acceptor: 负责监听连接事件负责接入
Processor:负责IO读写事件处理
EventDispatcher:负责事件派发
Handler:业务处理器
后面将通过一个系列文章来讨论一个原子化的NIO组件实现的细节及设计考量。
注:本文适合对象需对java NIO API的使用及异步事件模型(Reactor模式)有一定程度的了解,主要讲述使用java原生NIO实现一个TCP监听绑定的过程及细节设计。
我们一开始设计了一个TCP接入服务类,这个类提供了一个API方法提供对本地一系列地址(端口)的监听绑定,类初始化后完成Selector的open操作如下:
提供的绑定API,其方法签名如下:
参数中可以传递多个本地地址(端口)同时进行监听绑定。
在NIO的绑定过程中需进行事件注册(对OP_ACCEPT感兴趣),如下:
由于注册过程中除了涉及锁竞争还可能产生死锁,所以一般的做法都是将绑定地址放在队列中进行异步注册由reactor线程进行处理,例如:
如上面代码片段中的wait0()方法就是等待绑定结果,若出现绑定异常则抛出
至此,完成了TCP服务监听过程,下文将进一步讲述服务接入和数据传输相关设计细节。
注:本文适合对象需对java NIO API的使用及异步事件模型(Reactor模式)有一定程度的了解,主要讲述使用java原生NIO实现一个TCP服务的过程及细节设计。
前文讲述了NIO TCP服务绑定过程的实现机制,现在可以开始讲述服务监听启动后如何和处理接入和数据传输相关的细节设计。
在NIO的接入类中有一个Reactor线程,用于处理OP_ACCEPT事件通知,如下:
当有客户端接入时selector.select()方法返回大于0的整数,并进入accept()方法进行处理,具体如下:
注意:此时与客户连接的通道尚未注册对读/写事件感兴趣,因为它的注册与前文绑定过程一样需要异步进行。
因此将封装通道的session转交给一个processor对象(io读写处理器,该概念也是来自mina),processor内部维持了一个新建session的队列,在其内部reactor线程循环中进行注册处理。
有关processor处理读写事件的细节设计见下文。
注:本文适合对象需对java NIO API的使用及异步事件模型(Reactor模式)有一定程度的了解,主要讲述使用java原生NIO实现一个TCP服务的过程及细节设计。
上文讲到当客户端完成与服务端的连接建立后,为其SocketChannel封装了一个session对象代表这个连接,并交给processor处理。
processor的内部有3个重要的队列,分别存放新创建的session、需要写数据的session和准备关闭的session,如下:
1. selector.select(),其中为了处理连接超时的情况,select方法中传递了超时参数以免其永久阻塞,通常是1秒。该方法即时在没有事件发生时每秒返回一次,进入循环检测超时
3. 有读/写事件时,进行相关处理,每次读写事件发生时更新一次最后的IO时间。
读取数据时有一个小技巧在于灵活自适应buffer分配(来自mina的一个实现策略),每次判断读取到的字节数若乘以2依然小于buffer大小,则收缩buffer为原来一半,若读取的字节数已装满buffer则扩大一倍。
处理写操作其实是异步的,总是放入flushSessions中等待写出。
4. 若有需要写数据的session,则进行flush操作。
写事件一般默认都是不去关注的,因为在TCP缓冲区可写或远端断开或IO错误发生时都会触发该事件,容易诱发服务端忙循环从而CPU100%问题。为了保证读写公平,写buffer的大小设置为读buffer的1.5倍(来自mina的实现策略),每次写数据前设置为对写事件不再感兴趣。限制每次写出数据大小的原因除了避免读写不公平,也避免某些连接有大量数据需要写出时一次占用了过多的网络带宽而其他连接的数据写出被延迟从而影响了公平性。
- - buffer一次写完,则派发消息已经发送事件
关闭session的操作具体来说就是对channel.close()和key.cancel(),这2个操作后其实还没有完全释放socket占用的文件描述符,需等到下次select()操作后,一些NIO框架会主动调用,由于我们这里select(TIMEOUT)带有超时参数会自动唤醒,因此不存在这个问题。
前文讲述了NIO数据读写处理,那么这些数据最终如何被递交给上层业务程序进行处理的呢?
NIO框架一般都采用了事件派发模型来与业务处理器交互,它与原生NIO的事件机制是模型匹配的,缺点是带来了业务处理的碎片化。需要业务程序开发者对事件的生命周期有一个清晰的了解,不像传统方式那么直观。
事件派发器(EventDispatcher)就成为了NIO框架中IO处理线程和业务处理回调接口(Handler)之间的桥梁。
由于业务处理的时间长短是难以确定的,所以一般事件处理器都会分离IO处理线程,使用新的业务处理线程池来进行事件派发,回调业务接口实现。
下面通过一段示例代码来说明事件的派发过程:
这是processor从网络中读取到一段字节后发起的MESSAGE_RECEIVED事件,调用了eventDispatcher.dispatch(Event e)方法。
dispatch的方法实现有以下关键点需要考虑:
1. 事件派发是多线程的,派发线程最终会调用业务回调接口来进行事件处理,回调接口由业务方实现自身去保证线程并发性和安全性。
2. 对于TCP应用来说,由同一session(这里可代表同一个连接)收到的数据必须保证有序派发,不同的session可无序。
3. 不同session的事件派发要尽可能保证公平性,例如:session1有大量事件产生导致派发线程繁忙时,session2产生一个事件不会因为派发线程都在忙于处理session1的事件而被积压,session2的事件也能尽快得到及时派发。
下面是一个实现思路的代码示例:
有一组worker线程在监听阻塞队列,一旦有session进入队列,它们被激活对session进行事件派发,如下:
退出临界区后,进入事件派发处理方法fire(),在fire()方法退出前其他线程都没有机会对该session进行处理,保证了同一时刻只有一个线程进行处理的约束。
如果某个session一直不断有数据进入,则派发线程可能在fire()方法中停留很长时间,具体看fire()的实现如下:
当前线程释放对session的控制权只需简单置事件处理状态为false,其他线程就有机会重新获取该session的控制权。
在最后退出前为了避免事件遗漏,因为可能当前线程因为处理事件达到上限数被退出循环而又没有新的事件进入阻塞队列触发新的线程激活,则由当前线程主动去重新将该session放入阻塞队列中激活新线程。
原文:http://www.cnblogs.com/wnlja/p/4368754.html