目前,网元通信采用的方案,局部描述如下:
每个阅读器对应一个Socket链路,对于每个Socket链路,设置有工作线程(收消息线程、发消息线程、消息分发线程),以及消息队列(普通发送队列、紧急发送队列、接受队列),线程与队列相关联:
l 发消息线程负责将消息队列中的消息对象写入Socket;
l 收消息线程负责从Socket中读取消息对象放到收消息队列中;
l 消息分发线程负责将消息队列中的消息,分发给预定了此消息的模块(目前的实现是指适配模块)。
如图所示:
这是一个示意性的UML图,绿色模块表示队列、红色模块表示线程。
简单说,就是线程太多。
有关文献表明,Java虚拟机对线程的数目支持是有限度的,大约1000以内。此外,UEP平台在系统开启1000个线程时会自动重启。
显然,目前的实现方案存在问题,每个阅读器三个线程,开启线程太多了。
在当前方案的基础上,减少开启的线程数目:
比如,不区分普通消息、紧急消息,不设置发送队列,将消息同步写入Socket,可以减少两个发送线程。但总要有线程监听、读取Socket上来的数据。
即,每个Socket开一个线程。
还是感觉有点多。
或者,将Socket分组,多个线程在组内轮询多个Socket。实现起来比较麻烦,违反“简单设计”的实践原则。效果也不会特别好。
目前,单独线程管理单条Socket链路,之所以耗费资源的根本原因在于:每个线程以阻塞的方式工作,很可能在Socket链路没有数据传输的时候处于Idle状态,比较浪费;浪费的严重程度取决于处于Idle状态的时间长度。
举一个例子,非常不切实际,但希望能够说明问题。
银行柜台,十位工作人员同时工作,第一位负责接待帐号尾号为0的客户、第二位负责接待帐号尾号为1的客户……依此类推。这样,如果一整天都没有一个帐号尾号为0的客户上门,那么第一位工作人员就打盹儿一整天。
显然效率较低。
就这个例子,更好的模式应该是:一位工作人员负责接待所有客户,在没有客户上门的时候可以打盹儿,有客户上门时,类似Seven-Eleven门口的“欢迎光临”口号把打盹儿中的工作人员惊醒,开始接待工作。或者,客户较多,工作人员非但没有机会打盹儿,客户还总需要排队,就可以稍微多设置几位地位平等工作人员,每次客户上门,随意一位恰好闲着的就负责接待。类似于我们现实世界的情形。
Java NIO,就提供了机制采用后一种模式进行工作。
NIO是Java1.4相对于过去版本的一个较大的功能亮点,N表示New,相关API部署在java.nio.*包中。NIO更多地利用了现代操作系统所提供的底层I/O机制,为我们提供了一种更加高效的I/O解决方案。
Java1.4之前,Java的I/O相关的内容,均由java.io.*包解决,为了描述方便,暂称为“旧IO”。旧IO最重要的概念是流(InputStream和OutputStream)。旧IO以流的方式处理数据。旧IO以阻塞的方式操控流数据。
与本方案相关的,NIO最重要的概念是Buffer、Channel、Selector。NIO以块的方式处理数据。NIO可以按照非阻塞的方式操控数据。
下面简要说明一下NIO的工作模式。(注:仅介绍与本方案相关的NIO内容。)
1. Buffer:在NIO的世界中,所有数据都在Buffer中,从Buffer中读、写入Buffer。每中基本数据类型都有一个对应的Buffer类,其中ByteBuffer最为常用,也比较特殊——与Channel类联系最为紧密。
Buffer有三个状态变量:position、limit、capacity。Buffer可理解为比较高级的数据,这几个状态变量描述了数组中的数据装载情况,从变量名即可大约知道他们各自是什么意思。详细信息可参考本文末尾罗列的相关参考文献。
2. Channel:类似于旧IO的Stream,ServerSocket、Socket都有对应的Channel类,即ServerSocketChannel、SocketChannel,他们之间是双向关联的,即可以互相取得对方的句柄。与Stream不同的是,Channel是双向的。我们需要着重注意的是,SelectableChannel类,顾名思义,该类及其子类,表示“可以被选择”,说得更清楚一些,这样的Channel可以注册到某个Selector对象上,Selector对象可以在Channel关注的某些事件到来的时候,以某种方式给予通知。与著名的Observer模式在思路上惺惺相惜。事实上,我们所关心的ServerSocketChannel、SocketChannel都是SelectableChannel的子类。
3. Selector:可理解为Channel们的调度器,多个Channel可以注册到一个Selector对象,如前所述,Selector在Channel关注的事件发生的时候给予通知。比如,ServerSocketChannel在注册到Selector的时候表示,我关心accept操作,即关心哪些客户端试图与我建链;再比如,SocketChannel就关心read操作,看看什么时候会从Socket上读到数据。Selector提供的select方法是我们最为关注的,该方法是同步的,返回此时Selector收到的所有事件,返回形式是包含多个SelectionKey的Set。
4. SelectionKey:包裹Selector和Channel关联关系的类。从SelectionKey中可以得到如下信息:Channel、Selector、操作类型(accept、read、write)等。
1. 我们的基本设备:ServerSocketChannel*1、Selector*1、SocketChannel*N、SelectionKey*N、工作线程Worker*1(也可以是由少量线程组成的线程池,即Worker*M)。
2. 开启ServerSocketChannel,并打开某端口,等待客户端接入。
3. 将ServerSocketChannel注册到Selector上,表示自己关心accept操作,即关心哪些客户端来请求建链。
4. Worker启动,while-true调用Selector的select方法。
5. 有客户端请求上来的时候,select方法会返回相关的SelectionKey对象,从中取得ServerSocketChannel对象,并调用其accept方法获得SocketChannel。
6. 把获得的SocketChannel注册到Selector上,同时保存起来,并表示自己关心read操作,即关心何时、从该Socket上读到什么数据。
7. (此时,while-true调用Selector的select方法仍旧在马不停蹄地运行着)当有某个Socket有数据上来的时候,select方法会返回相关的SelectionKey对象,从中取得SocketChannel对象,调用read方法读出数据。
可以看出,利用NIO,一个工作线程可以完成所有Socket的数据读取,从根本上解决了上面的问题。当然,如果有需要,也可以设置线程池,或可进一步提高性能。