Java NIO: Non-blocking Server 非阻塞网络服务器

本文翻译自 Jakob Jenkov 的 Java NIO: Non-blocking Server ,原文地址:http://tutorials.jenkov.com/java-nio/non-blocking-server.html
文中全部想法均来自原做者,学习之余,以为很不错,对之后深刻学习服务器有帮助,故翻译之,有错误还望指教html

Non-blocking Server

即便了解 NIO 非阻塞功能如何工做(Selector,Channel,Buffer等),设计非阻塞服务器仍然很难。 与阻塞 IO 相比,非阻塞 IO 包含若干挑战。 本文将讨论非阻塞服务器的主要挑战,并为描述一些可能的解决方案。java

找到有关设计非阻塞服务器的好资料很难。 所以,本文中提供的解决方案基于 Jakob Jenkov 的工做和想法。git

本文中描述的想法是围绕 Java NIO 设计的。 可是,我相信这些想法能够在其余语言中重复使用,只要它们具备某种相似 Selector 的结构。 据我所知,这些构造是由底层操做系统提供的。github

非阻塞 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 读取数据块时,咱们不知道该数据块是否包含了一条完整的消息,可能的状况有:比一条消息少、一条完整消息、比一条消息多,以下图:

处理上述状况有两个挑战:

  1. 检测数据块中消息完整性;
  2. 在消息的其他部分到达以前,已收到的部分消息如何处理;

检测完整消息要求消息读取器查看数据块中的数据是否包含至少一个完整消息。 若是数据块包含一个或多个完整消息,则能够沿管道发送这些消息以进行处理。 这个步骤将重复不少次,所以这个过程必须尽量快。

每当数据块中存在部分消息时,不管是单独消息仍是在一个或多个完整消息以后,都须要存储该部分消息,直到该消息的其他部分到达。

检测完整消息和存储部分消息都是 Message Reader 的职责。 为区分来自不一样 Channel 的消息数据,须要为每一个 Channel 使用一个 Message Reader 。 设计看起来像这样:

检索具备要从选择器读取的数据的通道实例后,与该通道关联的消息读取器读取数据并尝试将其分解为消息。若是有任何完整的消息被读取,则能够将这些消息沿读取管道传递给须要处理它们的任何组件。

一个消息阅读器固然是针对特定协议的。 消息读取器须要知道它尝试读取的消息的消息格式。 若是咱们的服务器实现能够跨协议重用,则须要可以插入Message Reader 实现 ---- 可能经过以某种方式接受 Message Reader 工厂做为配置参数。

存储部分消息

既然咱们已经肯定消息阅读器负责存储部分消息,直到收到完整的消息,咱们须要弄清楚应该如何实现部分消息的存储。

应该考虑两个设计考虑因素:

  1. 尽量少地复制消息数据。 复制越多,性能越低。
  2. 将完整的消息存储在连续的字节序列中,使解析消息更容易。
每一个消息读取器的缓冲区

显然,部分消息须要存储在某写缓冲区中。 简单的实现是在每一个 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 实例,咱们使用这两步方法:

  1. 当消息写入消息编写器时,消息编写器将其关联的 Channel 注册到选择器(若是还没有注册)。
  2. 当服务器有时间时,它会检查选择器以查看哪些已注册的 Channel 实例已准备好进行写入,对于每一个写就绪通道,请求其关联的消息编写器将数据写入通道。 若是 Message Writer 已经将其全部消息写入了其 Channel ,则 Channel 将从 Selector 中注销。

这样,只有具备要写入消息的 Channel 实例才能实际注册到 Selector 。

总结

非阻塞服务器须要不时检查传入数据,以查看是否收到任何新的完整消息。 服务器可能须要屡次检查,直到收到一条或多条完整消息,仅仅检查一次是不够的。

一样,非阻塞服务器须要不时检查是否有任何要写入的数据。 若是是,则服务器须要检查相应的链接是否已准备好写入。 仅在第一次排队消息时检查是不够的,由于开始的时候消息可能只是数据的一部分。

总而言之,非阻塞服务器最终须要按期执行三个“管道”:

  1. 读取管道,用于检查来自打开链接的新传入数据。
  2. 处理管道,处理收到的任何完整消息的进程管道。
  3. 写入管道,检查是否能够将传出消息写入打开的链接。

这三个管道在循环中重复执行,还可能稍微优化它们的执行。 例如,若是没有排队的消息,能够跳过循环执行写入管道。 或者,若是咱们没有收到新的完整消息,也许可​​以跳过处理管道。

这是一个完整服务器循环示意图:

若是仍然以为这有点复杂,能够查看 GitHub 仓库:https://github.com/jjenkov/java-nio-server
也许看看代码有助于帮助理解。

服务器线程模型

GitHub 存储库中的非阻塞服务器实现使用具备 2 个线程的线程模型。 第一个线程接受来自 ServerSocketChannel 的传入链接。 第二个线程处理接受的链接,即读取消息,处理消息和将响应写回链接。 这个2线程模型以下所示:

相关文章
相关标签/搜索