尽管你已经知道了Java NIO的非阻塞功能是如何工作的(Selector
、Channel
、Buffer
等),但设计一个非阻塞IO的服务器还是非常的难。与阻塞IO相比,非阻塞IO有几个挑战。本文将讨论非阻塞IO服务器的主要挑战,以及一些可能的解决方案。
找到关于设计非阻塞IO服务器的资料很难。因此本文讨论的方案都是基于本人的工作经验和想法。如果你有更好想法,我会很高兴。你可以给我发邮件,或者发Twitter。
本文讨论的方案都是围绕Java NIO来的。但是,我相信这个想法可以在其他语言中复用,只要它也有类似于Selector
的结构。据我所知,这个结构是由底层操作系统提供的,所以很有可能你用其他语言也能使用它。
Non-blocking Server - GitHub Repository
我已经创建了本文讨论的想法的概念性验证,并将其上传到了GitHub供你查看。这是其GitHub地址:https://github.com/jjenkov/java-nio-server
Non-blocking IO Pipelines
一个非阻塞IO管道就是一个处理非阻塞IO的组件链。包括了以非阻塞IO方式进行读或写。以下是一个简单的非阻塞IO管道图示:组件通过Selector
检查Channel
何时有数据需要读取。然后组件读取流入的数据,并根据数据生成一些输出。输出数据又再次被写入Channel
。
非阻塞IO管道不需要同时读和写数据。有些管道只读数据,有些管道只写数据。
上图只展示了一个组件。非阻塞IO管道可以有一个或多个组件来处理流入的数据。非阻塞IO管道的长度取决于这个管道具体要做什么。
非阻塞IO管道可以同时从多个Channel
读取数据。比如,同时从多个SocketChannel
读取数据。
上图所示的控制流也进行了简化处理。它是通过Selector
从Channel
开始读取数据。它不是从Channel
将数据推入Selector
,然后从Selector
推给组件,及时图示看起来是这样。
Non-blocking VS Blocking IO Pipelines
阻塞型和非阻塞型IO管道之间最大的不同在于其从底层Channel
(Socket
或File
)读取数据的方式。
通常,IO管道从数据流(Socket
或File
)中读取数据,并将数据分解成连续的消息。类似于将数据流分解成token,然后用tokenizer进行解析。相反,你将数据流分解成更大的消息。我把这个将数据分解为消息的组件称为Message Reader。以下是Message Reader将数据流分解为消息的图示:
阻塞IO管道可以使用像InputStream
这样的接口每次只从底层Channel
读取一个字节,并且这样的接口会阻塞至有数据可读为止。这就是Message Reader的阻塞式实现。
使用阻塞式的流接口会使Message Reader的实现简单很多。阻塞式的Message Reader不需要处理无数据可读的情况,或只从流中读取到一部分消息使消息解析需要延后的情况。
同样,阻塞式的Message Writer(向流中写数据的组件)也不需要处理只写入了一部分数据,以及稍后必须恢复消息写入的情况。
Blocking IO Pipeline Drawbacks
虽然阻塞式Message Reader更容易实现,但它有个不幸的缺点。即需要为每个流的处理提供一个单独的线程。之所以必须这样做,是因为每个流的IO接口都会阻塞至有数据可读。这意味着,单个线程不能在从一个流中读取数据时,发现没有数据,就从另一个流中读取。因为当线程试图从流中读取数据时,线程就会阻塞,直至流中真有数据需要读取为止。如果这个IO管道是那种需要处理很多并发连接的服务器的一部分,这个服务器就需要为每个链接配一个线程。如果该服务器有数百万并发的连接,这种设计就不能很好的扩展。每个线程会占用320K(32位JVM)和640K(64位JVM)的内存。因此一百万个线程会占用1TB的内存!并且这是在服务器使用任何内存处理流入的消息之前(例如,为处理消息期间产生的对象分配内存)。
为了让线程数下降,许多服务器使用了一种线程池设计。线程池里的线程(比如100个)同时从接入的连接读取数据。接入连接被保存在队列中,线程按照接入连接的被放入队列的顺序来处理消息。本设计图示如下:但是,该设计要求接入连接合理的发送数据。如果接入连接可能长期不活动,那么大量的非活动连接可能会阻塞线程池里的所有线程。意味着服务器会变得响应缓慢,甚至无法响应。
有些服务器通过设计一个弹性数量的线程池来缓解这个问题。例如,如果线程池中线程耗尽,线程池可能启动更多的线程来处理负载。这个方案意味着你需要更多的慢连接才能使服务器无响应。但是请记住,你能运行的线程数量仍然有上限。因此,该方案在有一百万个连接的时候仍然无法扩展。
Basic Non-blocking IO Pipeline Design
一个非阻塞IO管道可以用一个线程从多个流读取数据。这需要我们的流能切换成非阻塞模式。在非阻塞模式下读取数据时,可以返回0个或多个字节。如果此时流中没有数据可读就会返回0个字节。当流中确实有数据可读时,就会返回1+个字节。我们使用Java NIO Selector来避免检查流中是否有数据可读。可以向Selector
注册一个或多个SelectableChannel
实例,当你调用select()
或selectNow()
方法时,Selector
只会返回那些确实已经有数据可读的SelectableChannel
实例。本设计如下图所示:
Reading Partial Messages
当我们从SelectableChannel
读取数据块时,我们不知道这个数据块包含的消息是多还是少。数据块可能包含部分消息(少于一条消息)、一整条的消息或多于一条的消息。比如,1.5或2.5条消息。各种可能的情况如下图所示:
处理部分消息有两个挑战:
- 检测数据块中是否有完整的消息;
- 如何处理部分消息,直到消息的其他部分到达。
完整消息检测需要Message Reader深入数据块内部的数据,看这些数据是否包含一条完整的消息。如果数据块包含一条或多条完整的消息,就将它们推入管道进行处理。寻找完整消息的过程可能会被重复的执行很多次,因此这个过程必须进可能的快。
只要数据块里有部分消息,无论是数据块本身只有部分消息还是在一条或多条完整的消息之外还包含部分消息,它都应该被存储起来,直到剩下的部分从Channel
传来为止。
完整消息检测和部分消息存储都是Message Reader的职责。为了避免混合来自不同Channel
的消息数据,我们对每一个Channel
使用一个Message Reader。设计如下图所示:
从Selector
获取到已有数据可读的Channel
后,与其对应的Message Reader便从中读取数据并尝试将其分解成消息。如果确实读取到了任意数量的完整消息,则可以将这些消息推给管道下游的无论什么样的组件来处理它们。
Message Reader当然是特定于协议的。Message Reader需要知道它读取的消息格式。如果我们的实现要跨协议可重用,那么我们的Message Reader要可扩展。比如通过接收一个Message Reader工厂来进行一些配置。
Storing Partial Messages
现在我们已经明确Message Reader需要存储部分消息,直到接收到全部消息。我们需要弄清楚怎么实现部分消息的存储。
有两个注意事项需要我们在设计时考虑:
- 我们要尽可能少的拷贝数据,拷贝越多,性能越差;
- 我们要将完整的消息存储在连续的字节序列中,以便进行消息解析。
A Buffer Per Message Reader
显然,部分消息需要存储在某种Buffer中。最直接的实现就是每个Message Reader内部都包含一个Buffer。但是,这个Buffer应该多大?它需要足够大,甚至要能存储允许的最大消息。因此,如果消息最大1MB,那么每个Message Reader内部的Buffer最小都需要1MB。
当我们有一百万个连接的时候,每个连接1MB就不行了。1,000,000 * 1MB依然是1TB内存!如果最大的消息有16MB或128MB又会怎么样呢?
Resizable Buffers
另一个选项是实现一个大小可变的Buffer给Message Reader内部使用。大小可变的Buffer初始时应该比较小,当消息大到装不下时,它会自动扩充。这样的话,每个连接就不需要一个1MB大小的Buffer了。每个链接只需要占用下一条消息所需的内存(这句话我也不太懂,原文:Each connection only takes as much memory as they need to hold the next message)。
有几种方法可以实现大小可变的Buffer。它们都有优点和缺点,所以我们接下来就详细的讨论下它们。
Resize by Copy
实现大小可变的Buffer的一种方法是先提供一个比较小的Buffer,比如4KB。如果消息无法被装入4KB大小的Buffer,就再分配一个大一点的Buffer,比如8KB,并且将4KB的Buffer里的数据拷贝到大一点的Buffer中。
这种resize-by-copy的实现方式的一个优点是,消息的所有数据都存储在一个连续的字节数组中,这会让消息解析变得很容易。
为了减少数据拷贝,你需要分析流经你系统的消息大小分布情况,以找到一个合适的Buffer大小来减少拷贝次数。比如,你发现大部分消息小于4KB,因为他们只包含很小的Request/Response,这意味着Buffer的初始大小应该是4KB。
然后你发现如果一条消息大于4KB通常是因为它包含了文件。你可能注意到流经你系统的文件大部分都小于128KB。因此,可以设置第二个Buffer的大小为128KB。
最后你还发现,一旦消息大于128KB,就没有什么规律了。因此最终的Buffer大小可设置为最大消息的大小。
有了这三个根据流经你系统的消息大小设定的Buffer大小,可能可以减少一些数据拷贝次数。小于4KB的消息不会被拷贝。一百万并发需要1,000,000 * 4KB = 4GB内存,这对于今天的大多数服务器都是可以接受的。大于4KB小于128KB的消息会被拷贝一次,且只有4KB的数据需要拷贝到128KB的Buffer中。大于128KB的消息会拷贝两次。首先拷贝4KB,然后拷贝128KB。因此对于最大的消息一种拷贝了132KB,在没有很多大的消息的前提下,这是可以接受的。
一旦消息处理完毕后,为其分配的内存应该被释放。那样的话,下一条同样连接来的消息将再次从最小的Buffer开始分配。这可以确保在不同的连接间内存可以有效的共享。大多数情况不是所有的连接在同一时间都需要打的Buffer。
我有一个完整的关于如何实现这样的支持大小可变数组的内存Buffer的教程。这个教程里有一个GitHub仓库地址(Resizable Arrays
),其中代码展示了一个具体的实现。
Resize by Append
另一种改变Buffer大小的方法是,让这个Buffer包含多个数组。当你需要改变Buffer大小的时候,你只需要分配另一个字节数组并将数据写进去。
有两种方法可以生成这样的Buffer。一种是分配单独的字节数组并保存到一个列表里。另一种是分配更大的贡献字节数组的片,然后保留分配给Buffer的片的列表。个人而言,我觉得切片方法好一些,但差别很小。
切片方法的优点是在数据写入过程中无需拷贝。所有的数据都可以直接从Socket(Channel)拷贝到数组切片中。
切片方法的缺点是,数据没有存储在一个单一的连续的数组中。使得消息解析变得困难,因为解析器需要同时查找每个单独的数组的末尾和每个数组的末尾(原文:since the parsers need to look out for both the end of every individual array and the end of all arrays at the same time)。由于需要在写入数据中查找末尾,因此该模型不太容易使用。
TLV Encoded Messages
一些协议消息格式使用TLV(Type(类型)、Length(长度)、Value(值))格式进行了编码。意味着当消息到达时,消息的总长度存储在消息的头部。那样你就能立刻的知道需要为整条消息分配多大的内存。
TLV编码使内存管理变得简单。你第一时间就能知道要为消息分配多少内存。在仅部分使用的Buffer末尾,不会有任何的内存浪费。
TLV编码的一个缺点是,在整条消息到达之前你就为它分配好了所有内存。少量的慢连接发送大消息可能导致占有你所有的内存,使你的服务器变得无法响应。
一个变通方案是使用一种包含多个TLV字段的消息格式。因此我们为每个字段分配内存,而不是整个消息,并且只在字段数据到达的时候分配。 尽管如此,大字段对内存管理的影响依然和大消息一样。
另一种变通方案是,让那些比如10-15s都没有数据到达的消息超时。这可以让你的服务器从突然的同时的大消息请求中恢复,但它仍然会让你的服务器无响应一小会儿。此外,可以的DoS攻击仍然会耗掉你的服务器的所有内存。
TLV编码存在不同的变体。到底使用多少字节来指定字段的类型(Type)和长度(Length)取决于各个TLV编码。也存在把长度(Length)放在第一位,然后是类型(Type),最后是值(Value)(LTV编码)这样的TLV编码。虽然顺序不一样,但仍然是TLV编码的一种变体。
TLV编码让内存管理变得容易的事实,也是说明HTTP1.1是一个如此糟糕的协议的原因之一。这也是HTTP2.0要解决的问题之一,HTTP2.0以TLV编码格式传输数据。这也是为什么我们在我们的VStack.co项目中使用TLV编码设计我们自己的网络协议。
Writing Partial Message
在非阻塞IO管道中,数据的写入也是一项挑战。当你调用处在非阻塞模式下的Channel
的write(ByteBuffer)
方法时,无法保证Buffer
里到底有多少字节的数据被写入了Channel
。write(ByteBuffer)
方法回返回有多少字节被写入,所以跟踪已写入的字节数量是可以做到的。这就是我们面临的挑战:跟踪部分写入的消息,以便最后发送消息的所有数据。
为了管理对Channel
的部分消息写入,我们需要创建一个Message Writer。就像Message Reader一样,每个Channel
都需要一个Message Writer来写入消息。在每个Message Writer中我们精确的跟踪它正在写入的消息的已写入字节数。
如果Message Writer接收到比它能直接写入到Channel
的还要多的消息,这些消息可以在Message Writer内部排队。然后Message Writer会尽可能快的把他们写入Channel
。
为了使Message Writer能继续发送之前只发送了部分的消息,Message Writer会不时地被调用,以便发送更多的消息。
如果你有大量的连接,你就会有大量的Message Writer实例。我们来看下比如有一百万个链接的时候,它们是否会变得缓慢。首先,很多Message Writer实例可能没有消息可写,我们并不想检查这些实例。其次,并不是所有Channel
都做好了写入数据的准备,我们也不想浪费时间往还不能接收数据的Channel
写入数据。
通过向Selector
注册Channel
,可以检查Channel
是否已做好写入数据的准备。然而,我们不想把所有Channel
注册到Selector
。想象一下,你有一百万个链接,其中大部分都是空闲的,并且全都注册到了Selector
中。然后,当你调用select()
方法的时候,大部分Channel
都是写入就绪状态(还记得吗,它们大部分都是空闲的)。你就不得不检查所有链接的Message Writer,看看它们是否有数据可写。为了避免检查所有的Message Writer是否有消息,以及所有Channel
实例是否有消息要发送给它们,我们采用两步法:
- 当消息被写入Message Writer时,Message Writer将关联的
Channel
注册到Selector
(如果尚未注册的话)。 - 当你的服务器空闲的时候,检查
Selector
中注册的Channel
看哪些已准备好写入数据。对于每个准备好写入数据的Channel
,都让其关联的Message Writer将数据写入Channel
。当Message Writer将它们全部写入Channel
后,将该Channel
从Selector
注销。
两步法保证了只有那些有消息可写的Channel
才会注册到Selector
。
Putting it All Together
如你所见,非阻塞服务器需要不时的检查流入数据,看看是否已接收到一条完整的消息。在有一条或多条完整的消息到达之前,服务器可能需要检查多次,只检查一次是不够的。
同样,非阻塞服务器还要不时的检查是否有消息可写。如果有,服务器还需检查其关联的连接是否已经准备好写入。仅仅在消息的第一次排队时检查是不够的,因为消息可能已经被写入一部分。
总之,一个非阻塞服务器最少需要三个“管道”定期执行:
- 从连接中检查流入数据的读管道;
- 处理收到的完整消息的处理管道;
- 检查是否可将流出消息写入连接的写管道。
这三个管道在一个循环中反复的执行。也许你也可以优化优化。比如,如果没有消息排队的话,你可跳过写管道。或者,如果没有接收到新的完整消息的话,你可以跳过处理管道。
以下是整个服务器的循环处理图示:如果你仍然觉得有点复杂,记得查看GitHub仓库:https://github.com/jjenkov/java-nio-server
可能看到具体代码能帮你理解怎么实现它。
Server Thread Model
GitHub仓库上的非阻塞服务器实现采用了一个有两个线程的线程模型。第一个线程负责接收从ServerSocketChannel
接入的连接。第二个线程负责处理连接,即都消息、处理消息及写响应到连接中。如下图所示:
前面介绍的服务器处理循环是在处理现场中执行。
说明
发现貌似有人在看这个系列文章了,有必要说明下,这个Java NIO系列来源于jenkov.com,本文只是翻译,希望大家千万不要误会,本文不是原创。原文地址:Java NIO。