Java NIO: Non-blocking Server 非阻塞网络服务器
本文翻译自 Jakob Jenkov 的 Java NIO: Non-blocking Server ,原文地址:http://tutorials.jenkov.com/java-nio/non-blocking-server.html
文中所有想法均来自原作者,学习之余,觉得很不错,对以后深入学习服务器有帮助,故翻译之,有错误还望指教
Non-blocking Server
即使了解 NIO 非阻塞功能如何工作(Selector,Channel,Buffer等),设计非阻塞服务器仍然很难。 与阻塞 IO 相比,非阻塞 IO 包含若干挑战。 本文将讨论非阻塞服务器的主要挑战,并为描述一些可能的解决方案。
找到有关设计非阻塞服务器的好资料很难。 因此,本文中提供的解决方案基于 Jakob Jenkov 的工作和想法。
本文中描述的想法是围绕 Java NIO 设计的。 但是,我相信这些想法可以在其他语言中重复使用,只要它们具有某种类似 Selector 的结构。 据我所知,这些构造是由底层操作系统提供的。
非阻塞 IO 管道
非阻塞 IO 管道是指处理非阻塞 IO 的一系列组件,包括以非阻塞方式读写 IO ,以下是简化的非阻塞IO管道的说明:
组件使用选择器来监听通道何时有可读数据。 然后组件读取输入数据并根据输入生成一些输出。 输出再次写入通道。
非阻塞 IO 管道不需要同时读写数据。 某些管道可能只读取数据,而某些管道可能只能写入数据。
上图仅显示单个组件。 非阻塞 IO 管道可能有多个组件处理传入数据。 非阻塞IO管道的长度取决于管道需要做什么。
非阻塞 IO 管道也可以同时从多个通道读取。 例如,从多个 SocketChannel 读取数据。
上图中的控制流程是已简化的。 它是通过 Selector 启动从 Channel 读取数据的组件。 不是 Channel 将数据推入 Selector 并从那里推入组件,即使这是上图所示。
非阻塞与阻塞 IO 管道
非阻塞和阻塞 IO 管道之间的最大区别在于如何从底层通道(套接字或文件)读取数据。
IO 管道通常从某些流(来自套接字或文件)读取数据,并将该数据拆分为相干消息。 这类似于将数据流分解为令牌以使用令牌解析器进行解析。 将流分解为消息的组件叫做消息读取器(Message Reader)。 以下是将消息流分解为消息的消息读取器(Message Reader)的示意图:
阻塞 IO 管道,是使用类似于 InputStream 的接口,每次从底层 Channel 读取一个字节,并且阻塞,直到有数据可读取。 这就是阻塞 Message Reader 的实现。
使用阻塞 IO 接口流可以简化 Message Reader 的实现。 阻塞 Message Reader 不必处理从流中读取数据,但是没有数据可读的情况,或者只读取了部分消息,以及稍后回复读取消息的情况。
类似地,阻塞 Message Writer(将消息写入流的组件)也不必处理只写入部分消息的情况,以及稍后必须恢复消息写入的情况。
阻止 IO 管道的缺陷
虽然阻塞的 Message Reader 更容易实现,但它有一个很大的缺点,就是需要为每个需要拆分成消息的流提供一个单独的线程,因为每个流的 IO 接口都会阻塞,直到有一些数据要从中读取。 这意味着单个线程无法胜任从一个流读取,如果没有数据,则从另一个流读取这种任务。 一旦线程尝试从流中读取数据,线程就会阻塞,直到实际上有一些数据要读取。
如果 IO 管道是必须处理大量并发连接的服务器的一部分,则服务器将需要每个活动进入连接一个线程,但是,如果服务器具有数百万个并发连接,则这种类型的设计不能很好地扩展。 每个线程将为其堆栈提供 320K(32位JVM)和 1024K(64位JVM)内存。 因此,100*10000 线程将占用 1 TB 内存!
为了减少线程数量,许多服务器使用一种设计,让服务器保留一个线程池(例如 100),该线程池一次一个地从入站连接(inbound connections)读取消息。 入站连接保留在队列中,并且线程按入站连接放入队列的顺序处理来自每个入站连接的消息。 这个设计如下图示:
但是,此设计要求入站连接合理地发送数据。 如果已连接的入站连接在较长时间内处于非活动状态,则大量非活动连接可能会阻塞(占用)线程池中的所有线程。 这意味着服务器响应缓慢甚至无响应。
某些服务器设计试图通过在线程池中的线程数量具有一定弹性来缓解此问题。 例如,如果线程池用完线程,则线程池可能会启动更多线程来处理负载。 此解决方案意味着需要更多数量的长时间连接才能使服务器无响应。 但请记住,运行的线程数仍然存在上限。 因此,这不会解决上述有 100*10000 线程的问题。
基础非阻塞 IO 管道设计
非阻塞 IO 管道可以使用单个线程来读取来自多个流的消息。 这要求流可以切换到非阻塞模式。 在非阻塞模式下,当从中读取数据时,如果流没有要读取的数据,则返回 0 字节。 当流实际上有一些要读取的数据时,返回至少 1 个字节。
为了避免检查有 0 字节的流来读取,我们使用 Selector 注册一个或多个 SelectableChannel 实例。 当在 Selector 上调用 select() 或 selectNow() 时,它只提供实际上有数据要读取的 SelectableChannel 实例。 这个设计的示意图:
读取部分消息
当我们从 SelectableChannel 读取数据块时,我们不知道该数据块是否包含了一条完整的消息,可能的情况有:比一条消息少、一条完整消息、比一条消息多,如下图:
处理上述情况有两个挑战:
- 检测数据块中消息完整性;
- 在消息的其余部分到达之前,已收到的部分消息如何处理;
检测完整消息要求消息读取器查看数据块中的数据是否包含至少一个完整消息。 如果数据块包含一个或多个完整消息,则可以沿管道发送这些消息以进行处理。 这个步骤将重复很多次,因此这个过程必须尽可能快。
每当数据块中存在部分消息时,无论是单独消息还是在一个或多个完整消息之后,都需要存储该部分消息,直到该消息的其余部分到达。
检测完整消息和存储部分消息都是 Message Reader 的职责。 为区分来自不同 Channel 的消息数据,需要为每个 Channel 使用一个 Message Reader 。 设计看起来像这样:
检索具有要从选择器读取的数据的通道实例后,与该通道关联的消息读取器读取数据并尝试将其分解为消息。如果有任何完整的消息被读取,则可以将这些消息沿读取管道传递给需要处理它们的任何组件。
一个消息阅读器当然是针对特定协议的。 消息读取器需要知道它尝试读取的消息的消息格式。 如果我们的服务器实现可以跨协议重用,则需要能够插入Message Reader 实现 ---- 可能通过以某种方式接受 Message Reader 工厂作为配置参数。
存储部分消息
既然我们已经确定消息阅读器负责存储部分消息,直到收到完整的消息,我们需要弄清楚应该如何实现部分消息的存储。
应该考虑两个设计考虑因素:
- 尽可能少地复制消息数据。 复制越多,性能越低。
- 将完整的消息存储在连续的字节序列中,使解析消息更容易。
每个消息读取器的缓冲区
显然,部分消息需要存储在某写缓冲区中。 简单的实现是在每个 Message Reader 中内部只有一个缓冲区。 但是,缓冲区应该有多大? 它需要足够大才能存储最大允许消息。 因此,如果允许的最大消息是 1MB ,那么每个 Message Reader 中的内部缓冲区至少需要 1MB 。
当我们达到数百万个连接时,每个连接使用 1MB 并不真正起作用。 100*10000 x 1MB 仍然是 1TB 内存! 如果最大消息大小为 16MB 怎么办? 那128MB?
可调整大小的缓冲区
另一个选择是实现一个可调整大小的缓冲区, 缓冲区将从较小的大小开始,如果消息对于缓冲区而言太大了,则会扩展缓冲区。 这样,每个连接不一定需要例如 1MB 缓冲区。 每个连接只占用保存下一条消息所需的内存。
有几种方法可以实现可调整大小的缓冲区。 所有这些都有优点和缺点,稍后会讨论它们。
1.通过复制消息调整大小
实现可调整大小的缓冲区的第一种方法是从一个小的缓冲区开始,例如, 4KB。 如果消息不能大于 4KB,则可以使用更大的缓冲区。 例如分配 8KB,并将来自 4KB 缓冲区的数据复制到更大的缓冲区中。
逐个复制缓冲区实现的优点是消息的所有数据都保存在一个连续的字节数组中。 这使得解析消息变得更加容易。逐个复制缓冲区实现的缺点是它会导致大量数据复制。
为了减少数据复制,可以分析流经系统的消息大小,以找到一些可以减少复制量的缓冲区大小。
例如,大多数消息是少于 4KB ,因为它们只包含非常小的请求/响应。 这意味着第一个缓冲区大小应为 4KB。然后如果消息大于 4KB,通常是因为它包含一个文件,流经系统的大多数文件都少于128KB,我们可以使第二个缓冲区大小为 128KB。最后,一旦消息高于 128KB,消息的大小就没有规律了,最终的缓冲区大小就是最大的消息大小。
根据流经系统的消息大小设置这3个缓冲区大小就可以减少数据复制。 永远不会复制低于 4KB 的消息。 对于一百万并发连接,导致 100*10000 x 4KB = 4GB,今天的大多数服务器中是能够满足这个内存值的。 4KB 到 128KB 之间的消息将被复制一次,并且只需要将 4KB 数据复制到 128KB 缓冲区中。 128KB 和最大消息大小之间的消息将被复制两次。 第一次 4KB 将被复制,第二次 128KB 将被复制,因此共有 132KB 复制为最大的消息。 如果没有那么多 128KB 以上的消息,这还可以接受。
消息完全处理完毕后,应再次释放已分配的内存。 这样,从同一连接接收的下一条消息再次以最小的缓冲区大小开始,这可以确保在连接之间更有效地共享内存。 并不是所有的连接都会在同一时间需要大的缓冲区。
2. 通过追加消息调整大小
另一种调整缓冲区大小的方法是使缓冲区由多个数组组成,当需要调整缓冲区大小时,只需继续分配另一个字节数组并将数据写入其中。
有两种方法来增加这样的缓冲区。 一种方法是分配单独的字节数组,并将这些字节数组的保存到一个列表中。 另一种方法是分配较大的共享字节数组的片段,然后将分配给缓冲区的每一个片段保存到一个列表。 就个人而言,我觉得第二种片段方法略好一些,但差别不大。
通过向其添加单独的数组或切片来增加缓冲区的优点是在写入期间不需要复制数据。 所有数据都可以直接从套接字(Channel)复制到数组或切片中。
以这种方式增长缓冲区的缺点是数据不存储在单个连续的数组中。 这使得消息解析更加困难,因为解析器需要同时查找每个单独数组的末尾和所有数组的末尾。 由于需要在写入的数据中查找消息的结尾,因此该模型不易使用。
TLV 编码消息
一些协议消息格式使用 TLV 格式(type,length,value)进行编码。 这意味着,当消息到达时,消息的总长度存储在消息的开头,这样就可以立即知道为整个消息分配多少内存。
TLV 编码使得内存管理更容易,因为可以知道要为消息分配多少内存,不会存在只有部分被使用的缓冲区,所以没有内存被浪费。
TLV 编码的一个缺点是在消息的所有数据到达之前为消息分配所有内存。 因此,发送大消息的一些慢连接可以分配可用的所有内存,从而使服务器无响应。
此问题的解决方法是使用包含多个 TLV 字段的消息格式。 因此,为每个字段分配内存,而不是为整个消息分配内存,并且仅在字段到达时分配内存。 但是,一个大字段可能会对内存管理产生与大消息相同的影响。
另一种解决方法是对未收到的消息设置超时时间,例如 10-15 秒,这可以使服务器从许多大的同时到达的消息中恢复过来,但它仍然会使服务器一段时间无响应。 此外,故意的 DoS(拒绝服务)攻击仍然可以导致服务器的内存被耗尽。
TLV 编码存在不同的形式。实际使用字节数,指定字段类型和长度取决于每个单独的 TLV 编码。 还有 TLV 编码先放置字段的长度,然后是类型,然后是值(LTV编码)。 虽然字段的顺序不同,但它仍然是 TLV 变体。
实际上,TLV 编码使内存管理更容易,是使得 HTTP 1.1 协议如此糟糕的原因之一。 这也是为什么在HTTP2.0 中在数据传输时使用 TLV 来编码帧的原因。
写入部分消息
在非阻塞 IO 管道中,写入数据也是一个挑战,在通道上调用 write(ByteBuffer)时,无法保证写入ByteBuffer 中的字节数。好在 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 实例,查看他们是否可以写数据也是很慢的。 首先,许多 Message Writer 实例中没有任何消息要发送,我们不想检查那些 Message Writer 实例。 其次,并非所有 Channel 实例都已准备好将数据写入,我们不想浪费时间尝试将数据写入无法接受任何数据的 Channel 。
要检查通道是否准备好写入,可以使用选择器注册通道。 但是,我们不希望使用 Selector 注册所有 Channel 实例。 想象一下,如果所有 100*10000 个通道都在 Selector 中注册,然后调用 select() 时,大多数这些 Channel 实例都是可写入的(它们大多是空闲的,还记得吗?),然后还必须检查所有这些连接的 Message Writer 以查看它们是否有要写入的数据。
为了避免检查没有数据需要写入的通道的 Message Writer 实例,我们使用这两步方法:
- 当消息写入消息编写器时,消息编写器将其关联的 Channel 注册到选择器(如果尚未注册)。
- 当服务器有时间时,它会检查选择器以查看哪些已注册的 Channel 实例已准备好进行写入,对于每个写就绪通道,请求其关联的消息编写器将数据写入通道。 如果 Message Writer 已经将其所有消息写入了其 Channel ,则 Channel 将从 Selector 中注销。
这样,只有具有要写入消息的 Channel 实例才能实际注册到 Selector 。
总结
非阻塞服务器需要不时检查传入数据,以查看是否收到任何新的完整消息。 服务器可能需要多次检查,直到收到一条或多条完整消息,仅仅检查一次是不够的。
同样,非阻塞服务器需要不时检查是否有任何要写入的数据。 如果是,则服务器需要检查相应的连接是否已准备好写入。 仅在第一次排队消息时检查是不够的,因为开始的时候消息可能只是数据的一部分。
总而言之,非阻塞服务器最终需要定期执行三个“管道”:
- 读取管道,用于检查来自打开连接的新传入数据。
- 处理管道,处理收到的任何完整消息的进程管道。
- 写入管道,检查是否可以将传出消息写入打开的连接。
这三个管道在循环中重复执行,还可能稍微优化它们的执行。 例如,如果没有排队的消息,可以跳过循环执行写入管道。 或者,如果我们没有收到新的完整消息,也许可以跳过处理管道。
这是一个完整服务器循环示意图:
如果仍然觉得这有点复杂,可以查看 GitHub 仓库:https://github.com/jjenkov/java-nio-server
也许看看代码有助于帮助理解。
服务器线程模型
GitHub 存储库中的非阻塞服务器实现使用具有 2 个线程的线程模型。 第一个线程接受来自 ServerSocketChannel 的传入连接。 第二个线程处理接受的连接,即读取消息,处理消息和将响应写回连接。 这个2线程模型如下所示: