11 Java NIO Non-blocking Server-翻译

434 阅读16分钟

尽管你对Java NIO的工作原理很了解,但是设计一个非阻塞的服务器仍然困难。与阻塞的IO相比,非阻塞的IO也包含一些挑战。这里将会讨论一些非阻塞服务器所面临的一些挑战,以及一些可行的方案。

查找关于设计非阻塞服务器的有用信息是有点困难的。因此,教程中的解决方案是基于我的工作与想法。如果你有另外的解决方案或更好的想法,我将会乐于倾听。你可以在文章下面发表评论或发送邮件,或在推特上联系我。

这个教程里所描述的想法是围绕Java NIO的。但是这些想法也可以在其它语言中使用,只要它们都有一些Selector相似的结构。据我所知,这些结构是被底层的操作系统支持的。因此,用其他语言来实现也是有很大的可能的。

Non-blocking Server - GitHub Repository

在这个教程中我已经创建了基本概念在github仓库中。下面是地址:

https://github.com/jjenkov/java-nio-server

Non-blocking IO Pipelines

非阻塞的IO管道是一系列的组件链来处理非阻塞IO。这包括在非阻塞模式下的读写IO操作。下图说明了一个简单的非阻塞IO管道。

image

一个Component使用Selector来检查Channel中是否有数据可读。然后Component读取输入数据并基于输入的数据输出数据。输出数据被再次写入到Channel中。

一个非阻塞的IO管道并不需要同时进行数据读写操作。一些管理可能只读取数据,而另一些管理可能只写数据。

以上的图只展示了一个Component。一个非阻塞的IO管道可能包括一个或多个Component来处理输入的数据。非阻塞管道的长度由管道需要完成的任务来决定。

一个非阻塞的IO管道也可以同时从多个通道读取数据。例如,从多个SocketChannel读取数据。

以上的流程图只是简化版。Componetnt是通过Selector来读取Channel中的数据的。其实并不是Channel将数据放到Selector,再由Selector将数据放入到组件中,尽管上图中是这么说的。

Non-blocking vs. Blocking IO Pipelines

非阻塞与阻塞的IO管道最大的不同是底层的Channel的数据传输方式决定的。

IO管道一般是从一些流中读取数据,并将数据分隔成有序的消息。这有点像将数据按分隔符进行分隔。事实上,当将流中的数据分隔成更大的消息时,这个将流分割成消息的组件叫做Message Reader。下图是一个流分割成消息的示意图。

image

一个阻塞的IO管道在一次从底层通道读取一个字节,并且流将阻塞直接有数据准备好的情况下可以使用流式接口。这将导致Message Reader是阻塞的。

使用流的阻塞IO接口简化了Message Reader的实现。阻塞的Message Reader不用关心流中没有数据准备好,或者只有一部分数据从流中返回,消息解析必须之后重新恢复。

类似的,一个阻塞的Message Writer(一个将数据写入到流中的组件)不用处理只有部分数据写入到流中,并且数据需要在后续进行恢复的情况。

Blocking IO Pipeline Drawbacks

尽管阻塞的Message Reader容易实现,它要求每个流分配一个线程来分割线程。这样做的理由是每个流的IO接口在读取数据时会阻塞直到有数据为止。这意味着,一个线程不能先尝试从一个流中读取数据,如果没有数据,再尝试从其他流中读取数据。只要一个线程尝试从一个流中读取数据,这个线程将会阻塞直到有数据准备好可以读。

如果IO管道是需要处理大量连接的服务器的一部分,这个服务器将要为每个连接分配一个线程。这个当连接数不大时问题还不大。但是,如果这个服务器百万个连接,这种设计就不太好了。每个线程将会分配320K到1024K的栈内存。因此,1000000个线程将会消耗1TB的内存。而且,这个仅仅是在线程没有处理数据之前的情况。

为了使线程数下降,许多服务器采用了线程池来管理线程。所有进来的连接保存在一个队列里,线程池依次处理进来的连接。如下图所示:

image

然而,这种设计要求进来的连接发送数据相当频繁。如果进来的连接不活跃的时间相当长,这将导致不活跃的连接经常阻塞线程池中的线程。这意味着服务器会变慢甚至失去响应。

一个服务器设计尝试弹性地控制线程的数量来减轻这个问题。例如,如果线程的数量超过了线程池的数量,线程池将会启动更多的线程来处理负载。这意味着会产生更多的慢连接来使服务器失去响应。但是,请记住,线程的数量是有上限的,因此,慢连接的数据是不会达到1 000 000个的。

Basic Non-blocking IO Pipeline Design

一个基本的非阻塞管道可以使用一个线程来从多个流中读取数据。这就要求这些流需要能切换到非阻塞模式。当处在非阻塞模式时,在读取数据时, 一个流可能返回0或多个字节。当没有数据可读时会返回0个字节。当有数据可读时,会返回多个字节。

为了避免检查一个流中是否有数据可读,我们使用了Java的NIO选择器。一个或多个SelectableChannel可以被注册到一个Selector上。当调用select或selectNow()方法时,它会返回有数据可读的SelectableChannel实例。设计如下图所示。

image

Reading Partial Messages

当我们从SelectableChannel中读取数据时,我们并不知道数据是否包含小于或大于一条消息。一个数据块可能只包含小于一条消息,一条消息或者多条消息(如1.5条,2.5条)。各种形式的部分消息如下图所示。

image

处理部分消息有两个主要难点:

  • 检测数据块中是否有整条消息。
  • 在剩余的消息到达之前如果处理部分消息。

检测是否有整条消息需要Message Reader进入数据块中查看数据块中是否包含一条完整的消息。如果数据块中包含一条或多条消息,这些消息将会发送到管道进行处理。检测完整消息的过程将会不断重复,所以这个过程将越快越好。

无论何时数据块中有部分消息,无论什么情况,都需要将这些数据保存到下一次有数据到来之前。

检测完整消息和保存部分数据都是由Message Reader完成的。为了避免不同通道的数据混合,需要为每一个Channel分配一个Message Reader。

image

在Selector获取到有数据可读的Channel之后,Message Reader会尝试从所关联的Channel中读取数据,并尝试将数据分割成一条条的消息。当分割出完整的一条消息时,这条消息会传递到下一个需要处理这条消息的组件。

Message Reader是跟具体的协议有关的。Message Reader需要了解它所要读的消息的具体格式。如果需要服务器支持跨协议,那么需要将Message Reader设计成可插拔。通常可以传入一个MessageReaderFactory作为参数。

Storing Partial Messages

现在我们已经确定了Message Reader需要在新数据到达之前保存部分消息数据,我们需要解决部分消息格式怎么存储的问题。

我们需要考虑以下两个因素:

  1. 消息的复制越少越好。复制越多,性能越低。
  2. 我们需要将消息存储在连续的字节序列中来使分析数据更加容易。

A Buffer Per Message Reader

很明显,部分消息需要存储在某种类型的Buffer中。最简单的做法是为每个Message Reader分配一个Buffer。然而,Buffer的大小应该是多大呢?它应该能够存储最大的消息。因此,最大的消息是1M,每个Message Reader中的Buffer应该分配1M。

每一个连接分配1M内存在连接数上亿时并不可行。1000000*1MB仍然需要10M。如果最大的消息是16M,甚至128M?

Resizable Buffers

另一种方式是Message Reader内部实现可伸缩的Buffer大小。一个可伸缩的Buffer开始时很小,如果一条消息对buffer来说太大的话,Buffer可以扩容。这种方式每个连接并不需要1M的内存。每个连接只需要下一条消息所需要的最小内存即可。

实现可伸缩的Buffer有好几种方式,每种都有优势与劣势。所以,我将在以下部分进行介绍。

Resize by Copy

实现可伸缩的Buffer的第一种方式是开始分配4K的大小。如果消息不能放在4k的Buffer中,将会分配一个8k的Buffer。原来4k的数据将会复制到8K的Buffer中。

这个resize-by-copy的Buffer实现的优势是所有的数据都会被保存在连续的字节序列中。这会使分析数据更加容易。

这种方式的缺点是会导致大量的数据复制操作。

为了减少数据拷贝,可以分析流经系统的消息大小来减少复制次数。例如,你可能会发现大部分的消息都是小于4kb的,因为它们都只包含小的请求/响应。这意味着开始的buffer大小应该为4kb。

然后,如果你发现如果消息的长度大于4kb,它经常包含一个文件。你还会注意到大部分流经系统的文件的大小都小于128k。那么可以确定下一个Buffer的大小为128k。

最后,一旦一条消息的长度超过128k,文件的大小基本上没规律可循。那么最终的Buffer大小应该为最大的消息大小。

基于上面的这三种Buffer大小,从某种程度上能减少文件的拷贝。小于4Kb的消息将不会被拷贝。对于1000000个并发连接,这只会消耗4GB内存,这对服务器来说都是可能的。在4KB与128KB的消息只会复制一次,并且只有4KB的数据会复制到128KB的Buffer中。在128KB与最大消息长度的Buffer将会复制两次。第一次是复制4KB的内容,然后,128KB的数据将会被复制。所以最大的消息会复制132KB。如果超过128KB的消息不是特别多,这种方案是可行的。

一旦生成了一条消息,它所分配的内在需要被清空。这种方式能够保证同一个连接所接收的下一条消息以最小的Buffer大小开始。非常有必要保证内存在不同的连接之间能够共享。很有可能不是所有的连接在同一时间都需要大Buffer。

我有一个完整的教程关于怎么实现这样的内存Buffer

Resize by Append

另一种调整Buffer的方法是让buffer由多个数组组成。当需要调整Buffer的大小时,只需要分配另一个字节数组并新数据写入到那个数组中。

扩充这样的Buffer这两种方式,一种是分配独立的字节数组,并维护这一系统这样的字节数组。另一种是分配连接的,较大的字节数组,并维护这些字节数组。个人觉得,slices方式更好一点,但是差别很小。

这种方式的优势是在写数据的时候没有数据复制这一步骤。数据能够直接socket复制到array或slice。

这种方式的劣势是数据并不是存储在单个,连续的数组。这会数据解析变得困难,由于解析者需要同时分析所有数组的末尾。由于需要分析写入数据的消息末尾,这个模型并不容易处理。

TLV Encoded Messages

一些消息协议支持TLV编码。这意味着,如果一条消息到达了,消息的整个长度是存储在消息的开始处。这种方式能够立即知道需要分配多少内存。

TLV编码使内存管理更加容易。可以立即知道需要分配多少内存。不会浪费额外的内存。

TLV编码的缺点是需要在消息到达之前为消息分配内存。一些少量,慢连接发送大数据会导致分配完所有的内存,最终导致服务器失去响应。

这个问题的应急方案是为消息分配多个TLV字段。因此,内存分配是由每个字段决定的,而不是整个消息。内存只在某个字段到达时才会分配。但是,一个在字段还是出现上述问题。

另一种解决方案是为还没有收到的消息设置超时时间,如10-15秒。这会让服务器从同时收到大量的数据,但这仍然会让服务器一段时间失去响应。 另外,Dos攻击也会造成服务器的内存消耗。

TLV编码有多种形式。字节的使用量跟TLV的类型与长度有关。也有一些TLV编码会将长度放在最初位置,然后类型,然后具体值。尽管字段的顺序不一样,它仍然是一种TLV形式。

事实上TLE编码使得内存管理变得更加容易.

Writing Partial Messages

在一个非阻塞的管道写数据也是一个挑战。当你调用非阻塞Channel上的write(ByteBuffer)方法时,ByteBuffer中有多少数据被写入是无法保证的。方法返回值说明了有多少字节被写入。因此,程序需要跟踪被写入的字节数。为了保证消息的所有字节最终都被写入,跟踪部分数据的写入也是一个挑战。

为了管理对通道的部分数据的写入,我们创建了一个Message Writer。同Message Reader一样,需要为写入数据的每一个Channel分配一个Message Writer。在Messager Writer内部需要记录消息已经有多少字节被写入到Channel中。

为了防止Messager Writer收到的消息数量大于它能直接写到Channel中情况,消息需要在Message Writer中放入队列中。Message Writer负责将消息尽快写入到Channel中。

下图显示的是目前部分消息写入的设计:

image

对于只发送部分消息的Message Writer来说,它需要不停地调用来保证发送更多的数据。

如果有大量的连接就必须创建大量的Message Writer实例。检查百万个Message Writer实例来判断它们是否可以发送数据是很慢的。毕竟,许多的Message Writer并没有数据需要发送。我们并不想检查这个Messager Writer实例。其次,并不是所有的通道都已经准备好写数据了,我们并不想在不能接收任何数据的通道上花费时间。

为了检查一个通道是否已经准备好写数据,可以为通道注册一个Selector。然而,我们并不想让所有的能将都注册通道。想像一下,如果有一百万个连接,它们绝大部分都是空闲的,并且它们都注册了Selector。然后,当调用select()方法时大多数的通道都能准备好写。你那时必须检查这些连接的Message Writer是否有数据可写。

为了避免检查Message Writer是否有消息发送并且所有的通道是否有数据发送给它们,我们采取了两步的方式。

  1. 当向Message Writer写入消息时,我们向这个Message Writer所对应的通道注册Selector。
  2. 如果服务器有时间,它会检查Selector中的哪个Channel已经准备好写了。对于每个准备好写的通道来说,由所对应的Message Writer负责写入数据到Channel。如果一个Message Writer已经将所有的数据都写入到能将了,它会将Channel从Selector中注销。

这两个小步骤保证了只有需要被写入数据的通道才会注册到Selector上。

Putting it All Together

正如你所看到的,一个非阻塞的NIO服务器需要不停地检查是否有新的完整的消息接收到了。服务器可以需要多次检测来判断是否有一条或多条消息已经被接收了。仅检查一次是不够的。

类似的,一个非阻塞的服务器也需要不停地检查是否有数据需要写入。如果是,服务器需要检查对应的连接是否已经准备好写入数据。仅查检消息队列中的消息一次是不够的,因为消息可能写入是不完整的。

总的来说,一个非阻塞的服务器如果需要执行的有规律需要有三个管道。

  • 读管道用来检查新进来的连接。
  • 处理管道用来处理任何完整的消息接收。
  • 写管道用来检查是否可以将数据写入到打开的连接中。

下图展示了完整的服务器工作循环。

image

Server Thread Model

在Github上的非阻塞服务器实现使用了2个线程的线程模型。第一个线程从ServerSocketChannel接收新进来的连接。第二个线程处理已经接收的连接,这意味着读取消息,处理消息并响应消息到对应的连接上。两个线程的模型如下图所示:

image

前一小节中介绍的服务器处理循环是由Processing thread执行的。