【每日一篇】【NIO学习】JAVA-NIO非阻塞服务器开发详解

    之前看到并发编程网上有这篇教程的翻译于是放弃了,但是猛然发现最重要的一篇竟然没翻译。在这里补一下。

    即使理解了前几篇中的NIO特性,开发一个无阻塞的服务器还是很难,与IO编程小臂有着诸多挑战,本篇主要就是讲这些难点已经如何解决。因为难度确实比较高,所以作者给出的也不一定是最优的做法,如果有更好的办法,可以联系作者:

About page catch me on Twitter

    本文是基于JAVA的,但是这个思路可以在包含着类似Selector结构的语言中复用。并且作者提供了一个github的范例地址,一下的详解也是基于该范例的,建议下载之后看一下,顺便下文中的message表示我们获取的一份有意义数据,Message Reader表示从数据源中将message解析出来的解析器:

github

非阻塞管道

一个非阻塞管道是一系列nio组件的组件链,能够以非阻塞方式进行读写,如下图:

A simplified non-blocking IO pipeline.

使用Selector来确认channel什么时候有数据可以读,读完之后将输出数据写入Channel中。这里只有一个组件用于处理数据,实际使用时链上可以有多个Component分步处理数据。一个管道也可以同时从多个Channel读数据。这个图是简化显示图,所以虽然Channel向Selector有一个箭头,不过实际上Channel并不会向Selector传数据啊。

阻塞IO管道

最大的区别在于他们读取数据的方式。传统IO通过stream读数据然后把数据分解成连贯的信息。就好像把数据流分解成tokens然后使用token分解器解析。下图是一个Messager Reader将Stream分解成信息的流程示意图:

A Message Reader breaking a stream into messages.

IO管道可以使用类似InputStream的一次一个字节的从底层管道读数据,并且阻塞到有数据可读为止,这就是上面的阻塞型Message Reader实现。当然这是一个简化实现,并没有考虑到没有数据或者只有部分数据的情况。

我们很容易实现这样的一个阻塞型Message Reader,但是它有一个很大的缺点,就是对每一个需要分解成message的stream都需要一个线程。因为是阻塞的特点使得线程在没有数据可读时只能等待。这一特性在有非常多连接的时候会造成极大的性能浪费,每一个线程都会占用320K(32位JVM)或者1024K(64位JVM)的栈空间,这还没考虑的每个线程处理信息时也需要占用空间,过多的线程毫无疑问的会造成OOM。

当然也不是完全没法解决这个问题,一个常见的解决方案就是线程池,通过保持一定数量的线程来读数据,新的连接保存在队列中,然后按顺序处理连接中的队列。如下图:

A pool of threads reading messages from streams in a queue.

但是线程池也有问题,它要求每一个连接都合理的发送数据,如果大量的连接非活动实际太长,可能会导致池中所有数据阻塞,这也或使得服务器运行缓慢或者无响应。我们也可以将线程次设为浮动数量,也就是允许池子满溢时再新开线程,但是不论如何设计,线程数量总有其极限。对于大量的缓慢连接无法处理。

基本非阻塞IO设计

非阻塞IO管道可使用一个线程管理多个stream,这需要stream可以切换为非阻塞状态。非阻塞状态的stream可以在返回0或者更多字节,没数据返回0个字节,有数据就返回数据。

然后为了不选中没有数据的stream,我们可以使用Selector进行管理,我们将Channel注册到Selector之后,就可以调用select()方法来获取准备好读数据的数据源来操作了,如下图:

A component selecting channels with data to read.

读取部分数据

我们从SelectableChannel中读数据时,可能不是正好读到一份数据,有可能读到一半,如下图:

A data block can contain less than or more than a single message.

这个情况下有两个难点

  1. 检测是否读取了完整数据
  2. 在另一部分数据被读到之前怎么办

检测完整数据需要Message Reader确认数据源中是否有一份或者两份message,如果有就读取。这个检测的过程会重复多次,所以越快越好。

如果数据源中有部分数据,不论什么情况,都应被保存直到读取了另一半数据为止,不管是检测数据完整性还是保存部分数据都是Message Reader的责任,为了避免不同Channel的数据混合,我们对每一个Channel都使用一个单独的Message Reader来处理。如下图:

A component reading messages via a Message Reader.

从Selector接收Channel之后,Message Reader判断数据是否完整,完整的话就进行读操作。这个Message Reader必然是针对特殊的传输协议的吗,只有知道数据格式才能进行读,如果我们想要范用的Message Reader实现,我们应该开发一个Message Reader Factory,根据配置的参数生成对应的Message Reader.

保存部分数据

上文说到在只有部分message传入时我们要进行保存,但是保存数据时也有两个要点:

  1. 我们应该尽可能少的复制数据,我们复制的越多,性能就越差
  2. 我们应该吧完整的message保存在字节序列中,这样我们就能更加轻松的解析

Buffer的使用

显然我们应该把部分message保存在某种Buffer中,但是如果我们在每一个Message Reader中创建一个Buffer,这个Buffer应该有多大?如果Message Reader 大小为1MB,Buffer的大小也不能小于1MB,否则可能装不下。而上文又说到我们会为每一个Channel建立Message Reader,显然这个空间占用也是不能接受的。所以我们应该使用可变容量Buffer。有好几种方法用于建立一个可变容Buffer,以下会一一列举:

1.通过copy改变大小

顾名思义,buffer满溢时创建更大的Buffer然后把旧数据copy进来,优点是数据顺序没变,解析更容易,缺点是message比较大的时候,我们会花很大的功夫来copy。所以为了减少copy次数,我们必须仔细的设计Buffer的大小,例如一个request一般是非常小的,我们可以把初始大小设为4kb。如果大于4k可能是包含文件,一般文件不会大于128k,所以第二次可以设为128k。如果还是不够,此时应该把大小设为最大.

通过可变大小Buffer,我们可以保证哪怕在多连接情况下也不会占用过多空间,一个message处理结束后,我们应该释放空间,下个message到来时再重复上述步骤。作者给出了一个可变buffer的git实现:

Resizable Arrays

2.通过append改变大小

这种做法将Buffer视为队列的集合,需要拓展时新建分配另一个Buffer然后存入即可。有两种方法,

有两种方法可以增长这种缓冲区。 一种方法是分配单独的字节数组并保留这些字节数组的列表。 另一种方法是分配更大的共享字节数组的片,然后保留分配给缓冲区的切片的列表。 就个人而言,我觉得切片方法稍好一些,但差异很小。

这么做的有点是不需要copy操作。缺点是因为message被存入了数组,所以取数据解析的过程更难了

TLV(tag length value)编码message

某些message是使用TLV编码的,这意味着,这个message的长度信息已经保存在message的头部了,你可以通过这种方式获取你应该为这个message分配多大的空间。这种编码的可以让你更容易的多的管理空间,但是也有其缺点.

写入消息

在NIO管道中写入数据也是一个挑战,我们使用Message Writer来监控Channel,通过write方法可以返回写入了多少字节,我们需要保证每一个信息都被完整写入,如果需要写入的message很多,他们应在Message Writer 中排队等待一一写入,具体的流程如下图:

A component sending messages to a Message Writer which queue them up and send them to a Channel.

将数据写入Channel时,我们也需要使用Selector进行管理,如果我们有1.000.000个连接,这些连接大多处于空闲状态,并且所有1.000.000连接都已在Selector中注册。然后,当你调用select()时,这些Channel实例中的大部分都将准备好写入(它们大多是空闲的)。然后,我们必须检查所有这些连接的Message Writer,以查看它们是否有任何要写入的数据,显然这么做是不合理的,为了避免检查全部Message Writer,我们使用以下步骤写入:

  1. 当信息写入Message Writer时,Message Writer 将相关联的Channel注册到Selector中。

  2. 当服务器空闲时,检查Selector中准备好写入的Channel,写入完成后,将Channel从Selector中取消注册。

最终方案

如上,非阻塞服务器需要不时检查传入数据,以查看是否收到任何新的完整message。服务器可能需要多次检查,直到收到一条或多条完整message。检查一次是不够的。
同样,非阻塞服务器需要不时检查是否有任何数据要写入。如果是,服务器需要检查是否有任何相应的连接准备好将数据写入它们。仅在message第一次排队时进行检查是不够的,因为消息可能会被部分写入。
总而言之,非阻塞服务器最终需要定期执行三条“管道”:
1.读取管道:用于检查来自打开的连接的新传入数据。
2.处理任何收到的完整消息的流程管道。
3.写入管道:检查是否可以将任何传出消息写入任何打开的连接。
这三条管道是循环重复执行的。可以稍微优化执行。例如,如果没有排队的消息,则可以跳过写入管道。或者,如果我们没有收到新的完整消息,也许可以跳过流程管道。

以下是说明完整服务器循环的图表:

The full server loop of a non-blocking server.

还有疑问的话可以访问作者的github,看看他的事例

Server Thread Model

GitHub上中的事例非阻塞服务器实现使用2线程的线程模型。 第一个线程接受来自ServerSocketChannel的传入连接。 第二个线程处理接受的连接,即读取消息,处理消息并将响应写回连接。 这2个线程模型如下所示:

The 2 thread model for the non-blocking server implemented in the GitHub repository.


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值