最近一年用NIO写了很多网络程序,也研究了一些开源NIO网络框架netty、mina等,总结了一下NIO的架构特色。java
不管是netty仍是mina它们都在java原生NIO的基础上进行了完善的封装,虽然细节有所不一样,但整体架构思路一致,都大概划分出了如下几个组成部分:react
- - transport:传输层的抽象程序员
- - protocol: 协议codec的抽象缓存
- - event model:统一事件模型安全
- - buffer:底层buffer封装网络
在彻底屏蔽底层API的同时,对上层应用提供了自身的统一API接口。session
框架进行黑盒封装的同时,再进行通用化的接口开放,带来的好处是统一化,但坏处是程序的透明度下降,抽象度提升,增长理解难度和实现难度。多线程
下面说说每一个部分的一些设计考量:架构
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放入阻塞队列中激活新线程。