Java NIO学习教程(五)

原文地址: link.

10.Java NIO ServerSocketChannel

Java NIO ServerSocketChannel是一个可以侦听传入TCP连接的通道,就像标准Java网络中的ServerSocket一样。 ServerSocketChannel类位于java.nio.channels包中。

这是一个例子:

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

serverSocketChannel.socket().bind(new InetSocketAddress(9999));

while(true){
    SocketChannel socketChannel = serverSocketChannel.accept();
    //do something with socketChannel...
}

打开ServerSocketChannel
通过调用ServerSocketChannel.open( )方法打开ServerSocketChannel。这是看起来如何:

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

关闭ServerSocketChannel
通过调用ServerSocketChannel.close( )方法来关闭ServerSocketChannel。这是看起来如何:

serverSocketChannel.close();

Listening for Incoming Connections
通过调用ServerSocketChannel.accept( )方法来侦听传入连接。当accept( )方法返回时,它返回一个SocketChannel与传入的连接。因此,该accept( )方法阻塞直到传入连接到达。

由于你通常不想只侦听一个连接,所以在while循环中调用accept( )。这是看起来如何:

while(true){
    SocketChannel socketChannel = serverSocketChannel.accept();
    //do something with socketChannel...
}

当然,你会在while循环中使用一些其他的stop标准而不是true。

非阻塞模式
ServerSocketChannel可以设置为非阻塞模式。在非阻塞模式下,accept( )方法立即返回,如果没有到达传入连接,则可以返回null。因此,必须检查返回的 SocketChannel是否为null。这是一个例子:

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

serverSocketChannel.socket().bind(new InetSocketAddress(9999));
serverSocketChannel.configureBlocking(false);

while(true){
    SocketChannel socketChannel = serverSocketChannel.accept();

    if(socketChannel != null){
        //do something with socketChannel...
    }
}

11.Java NIO: Non-blocking Server

即使你了解了Java NIO非阻塞功能的工作(怎么样Selector,Channel, Buffer等等),设计一个非阻塞服务器仍然很难。与阻塞IO相比,非阻塞IO包含若干挑战。这个非阻塞服务器教程将讨论非阻塞服务器的主要挑战,并为它们描述一些可能的解决方案。

找到有关设计非阻塞服务器的好文章很难。因此,本教程中提供的解决方案基于我自己的工作和想法。如果你有一些替代或更好的想法,我会很高兴听到他们!你可以在文章下撰写评论或给我发送电子邮件(请参阅我们的“ 关于”页面),或者在Twitter上关注我。

本教程中描述的概念设计是围绕Java NIO的。但是,我相信这些想法可以在其他语言中重复使用,只要它们具有某种类似的Selector结构。据我所知,这些构造是由底层操作系统提供的,因此很有可能你也可以使用其他语言访问它。

非阻塞服务器 - GitHub存储库
我已经在本教程中创建了一个简单的概念验证想法,并将其放在GitHub存储库中供你查看。这是GitHub存储库:

地址: link.

Non-blocking IO Pipelines
非阻塞IO管道是处理非阻塞IO的一系列组件。这包括以非阻塞方式读取和写入IO。以下是简化的非阻塞IO管道的说明:
图7
组件使用Selector来检查Channel何时 有要读取的数据。然后组件读取输入数据并根据输入生成一些输出。输出再次写入Channel

非阻塞IO管道不需要同时读取和写入数据。某些管道可能只读取数据,而某些管道可能只能写入数据。

上图仅显示单个组件。非阻塞IO管道可能有多个组件处理传入数据。非阻塞IO管道的长度取决于管道需要做什么。

非阻塞IO管道也可以同时从多个Channels读取。例如,从多个SocketChannels读取数据。

上图中的控制流程也得到了简化。它是通过Selector启动从Channel读取数据的组件。Channel不是将数据推入Selector并从那里推入组件,即使上图是这样描述的。

Non-blocking vs. Blocking IO Pipelines
非阻塞和阻塞IO管道之间的最大区别在于如何从底层Channel(套接字或文件)读取数据 。

IO管道通常从某些流(来自套接字或文件)读取数据,并将该数据拆分为相干消息。这类似于将数据流分解为令牌以使用标记化器进行解析。相反,你将数据流分解为更大的消息。我将调用该组件将流分解为Message Reader的消息。以下是将消息流分解为消息的,消息阅读器的示意图:
图8
阻塞IO管道可以使用类似InputStream的接口,其中一次可以从底层Channel读取一个字节,并且类似InputStream的接口阻塞,直到有数据准备好读取。这就是阻塞Message Reader的实现。

对流使用阻塞IO接口简化了Message Reader的实现。阻塞Message Reader永远不必处理从流中读取数据的情况,或者只从流中读取部分消息并且需要稍后恢复消息解析的情况。

类似地,阻塞Message Writer(将消息写入流的组件)永远不必处理只写入部分消息的情况,以及稍后必须恢复消息写入的情况。

Blocking IO Pipeline Drawbacks
虽然阻塞的Message Reader更容易实现,但它有一个不幸的缺点,就是需要为每个需要拆分成消息的流提供一个单独的线程。必要的原因是每个流的IO接口都会阻塞,直到有一些数据要从中读取。这意味着单个线程无法尝试从一个流中读取,如果没有数据,则从另一个流中读取。一旦线程尝试从流中读取数据,线程就会阻塞,直到实际上有一些数据要读取。

如果IO管道是必须处理大量并发连接的服务器的一部分,则服务器将需要为每个活动的连接分配一个线程。如果服务器在任何时候只有几百个并发连接,这可能不是问题。但是,如果服务器具有数百万个并发连接,则这种类型的设计不能很好地扩展。每个线程将为其堆栈提供320K(32位JVM)和1024K(64位JVM)内存。因此,1.000.000线程将占用1 TB内存!这只是服务器处理传入消息之前使用的内存(例如,为消息处理期间使用的对象分配的内存)。

为了减少线程数量,许多服务器使用一种设计,其中服务器保留一个线程池(例如100),该线程池一次一个地从入站连接读取消息。入站连接保留在队列中,并且线程按入站连接放入队列的顺序处理来自每个入站连接的消息。这个设计在这里说明:
图9

但是,此设计要求入站连接的数量要合理。如果入站连接可能在较长时间内处于非活动状态,则大量非活动连接实际上可能会阻塞线程池中的所有线程。这意味着服务器响应缓慢甚至无响应。

一些服务器设计试图通过在线程池中的线程数量有一些弹性来缓解这个问题。例如,如果线程池用完线程,则线程池可能会启动更多线程来处理负载。此解决方案意味着需要更多数量的慢速连接才能使服务器无响应。但请记住,运行的线程数仍有一个上限。因此,这不会很好地扩展1.000.000慢速连接。

Basic Non-blocking IO Pipeline Design
非阻塞IO管道可以使用单个线程来读取来自多个流的消息。这要求流可以切换到非阻塞模式。在非阻塞模式下,当你尝试从中读取数据时,流可能返回0或更多字节。如果流没有要读取的数据,则返回0个字节。当流实际上有一些要读取的数据时,返回1+个字节。

为了避免检查要读取0字节的流,我们使用Java NIO Selector。可以使用Selector注册一个或多个SelectableChannel实例。当你在Selector上调用select( )或selectNow( )时,它只为你提供实际上有数据要读取的SelectableChannel实例。这个设计在这里说明:

图10

Reading Partial Messages
当我们从SelectableChannel读取数据块时,我们不知道该数据块是少于还是多于一条消息。数据块可能包含部分消息(少于消息),完整消息或多于消息,例如1.5或2.5个消息。这里说明了各种部分消息可能性:
图11

处理部分消息有两个挑战:

  • 检测数据块中是否有完整的消息。
  • 部分消息如何处理,直到消息的其余部分到达。

检测完整消息要求消息读取器查看数据块中的数据以查看数据是否包含至少一个完整消息。如果数据块包含一个或多个完整消息,则可以沿管道发送这些消息以进行处理。寻找完整消息的过程将重复很多,因此这个过程必须尽可能快。

每当数据块中存在部分消息时,无论是单独消息还是在一个或多个完整消息之后,都需要存储该部分消息,直到该消息的其余部分从该Channel到达。

检测完整消息和存储部分消息都是Message Reader的职责。为避免混合来自不同Channel实例的消息数据,我们将为每一个Channel使用一个Message Reader。设计看起来像这样:
图12

在检索具有要从选择器读取的数据的Channel实例之后,与该Channel关联的Message Reader读取数据并尝试将其分解为消息。如果读取的是完整的消息,则可以将这些消息沿读取管道传递给需要处理它们的任何组件。

消息阅读器当然是特定于协议的。消息读取器需要知道它尝试读取的消息的消息格式。如果我们的服务器实现可以跨协议重用,则需要能够插入Message Reader实现——可能通过以某种方式接受Message Reader工厂作为配置参数。

Storing Partial Messages
既然我们已经确定消息阅读器负责存储部分消息,直到收到完整的消息,我们需要弄清楚应该如何实现这个部分消息存储。

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

  • 我们希望尽可能少地复制消息数据。复制越多,性能越低。
  • 我们希望将完整的消息存储在连续的字节序列中,以使解析消息更容易。

A Buffer Per Message Reader
显然,部分消息需要存储在某种缓冲区中。简单的实现是在每个Message Reader中内部只有一个缓冲区。但是,缓冲区应该有多大?它需要足够大才能存储最大的允许消息。因此,如果允许的最大消息是1MB,那么每个Message Reader中的内部缓冲区至少需要1MB。

当我们达到数百万个连接时,每个连接使用1MB并不真正起作用。1.000.000 x 1MB仍然是1TB内存!如果最大邮件大小为16MB怎么办?还是128MB?

Resizable Buffers
另一个选择是实现一个可调整大小的缓冲区,以便在每个Message Reader中使用。可调整大小的缓冲区将从小开始,如果消息对于缓冲区而言太大,则扩展缓冲区。这样,每个连接不一定需要例如1MB缓冲器。每个连接只占用保存下一条消息所需的内存。

有几种方法可以实现可调整大小的缓冲区。所有这些都有优点和缺点,所以我将在以下部分讨论它们。

Resize by Copy
实现可调整大小的缓冲区的第一种方法是从一个小的缓冲区开始,例如,4KB。如果消息不能适合4KB缓冲区,则可以分配更大的缓冲区,例如8KB,并将4KB缓冲区中的数据复制到更大的缓冲区中。

逐个复制缓冲区实现的优点是消息的所有数据都保存在一个连续的字节数组中。这使得解析消息变得更加容易。

逐个复制缓冲区实现的缺点是它会导致大量数据复制以获得更大的消息。

为了减少数据复制,你可以分析流经系统的消息大小,以找到一些可以减少复制量的缓冲区大小。例如,你可能会看到大多数消息少于4KB,因为它们只包含非常小的请求/响应。这意味着第一个缓冲区大小应为4KB。

然后你可能会看到,如果一条消息大于4KB,通常是因为它包含一个文件。你可能会注意到流经系统的大多数文件都少于128KB。然后有意义的是使第二个缓冲区大小为128KB。

最后,你可能会看到,一旦消息高于128KB,消息的大小就没有真正的模式,因此最终的缓冲区大小可能只是最大的消息大小。

根据流经系统的消息大小,这3个缓冲区大小,你将减少数据复制。永远不会复制低于4KB的消息。对于1.000.000并发连接,导致1.000.000 x 4KB = 4GB,这在今天的大多数服务器中是可能的(2015)。4KB到128KB之间的消息将被复制一次,并且只需要将4KB数据复制到128KB缓冲区中。128KB和最大邮件大小之间的邮件将被复制两次。第一次4KB将被复制,第二次128KB将被复制,因此共有132KB复制为最大的消息。假设没有那么多128KB以上的消息,这可能是可以接受的。

消息完全处理后,应再次释放已分配的内存。这样,从同一连接接收的下一条消息再次以最小的缓冲区大小开始。这是确保在连接之间更有效地共享内存所必需的。很可能并非所有连接都需要同时使用大缓冲区。

我有一个关于如何在这里实现支持可调整大小数组的内存缓冲区的完整教程: Resizable Arrays。本教程还包含指向GitHub存储库的链接,其中的代码显示了正在运行的实现。

Resize by Append
调整缓冲区大小的另一种方法是使缓冲区由多个数组组成。当你需要调整缓冲区大小时,你只需分配另一个字节数组并将数据写入其中。

有两种方法来增加这样的缓冲区。一种方法是分配单独的字节数组并保留这些字节数组的列表。另一种方法是分配较大的共享字节数组的片段,然后保留分配给缓冲区的片段列表。就个人而言,我觉得切片的方法略好一些,但差别很小。

通过向其添加单独的数组或切片来增加缓冲区的优点是在写入期间不需要复制数据。所有数据都可以直接从socket(Channel)直接复制到数组或切片中。

以这种方式增长缓冲区的缺点是数据不存储在单个连续的数组中。这使得消息解析更加困难,因为解析器需要同时查找每个单独数组的末尾和所有数组的末尾。由于你需要在书面数据中查找消息的结尾,因此该模型不易使用。

TLV Encoded Messages
一些协议消息格式使用TLV格式(类型-Type,长度-Length,值-Value)进行编码。这意味着,当消息到达时,消息的总长度存储在消息的开头。这样你就可以立即知道为整个消息分配多少内存。

TLV编码使内存管理更容易。你立即知道要为消息分配多少内存。在仅部分使用的缓冲区的末尾没有浪费内存。

TLV编码的一个缺点是在消息的所有数据到达之前为消息分配所有内存。因此,发送大消息的一些慢速连接可以分配你可用的所有内存,从而使你的服务器无响应。

此问题的解决方法是使用包含多个TLV字段的消息格式。因此,为每个字段分配内存,而不是为整个消息分配内存,并且仅在字段到达时分配内存。尽管如此,大字段对内存管理的影响与大消息相同。

另一种解决方法是将例如10-15秒内未收到的消息超时。这可以使你的服务器从许多大消息同时到达的巧合中恢复,但它仍然会使服务器在一段时间内没有响应。此外,故意的DoS(拒绝服务)攻击仍然可以导致服务器的内存完全分配。

TLV编码存在于不同的变体中。确切地说使用了多少字节,因此指定字段的类型和长度取决于每个单独的TLV编码。还有TLV编码,首先是字段的长度,然后是类型,然后是值(LTV编码)。虽然字段的顺序不同,但它仍然是TLV变体。

TLV编码使内存管理变得更容易,这就是为什么HTTP1.1协议如此糟糕的原因之一。这是他们在HTTP2.0中试图解决的问题之一,在HTTP2.0中,数据以LTV编码的帧传输。这也是我们为使用TLV编码的vstack.co项目设计自己的网络协议的原因。

Writing Partial Messages
在非阻塞IO管道中,写入数据也是一个挑战。当你在非阻塞模式下在通道上调用write(ByteBuffer)时,无法保证写入ByteBuffer中的字节数。write(ByteBuffer)方法返回写入的字节数,因此可以跟踪写入的字节数。这就是挑战:跟踪部分写入的消息,以便最终发送消息的所有字节。

为了管理将部分消息写入Channel,我们将创建一个Message Writer。和Message Reader一样,我们在每个向其写入消息的Channel上都需要一个Message Writer。在每个Message Writer中,我们记录了它当前正在写入的消息的确切字节数。

如果有更多消息到达Message Writer而不是直接写入Channel,消息需要在Message Writer内部排队。然后,Message Writer尽可能快地将消息写入Channel

下面是一个图表,显示到目前为止部分消息写入是如何设计的:

图13

为使Message Writer能够发送之前仅部分发送的消息,需要不时调用Message Writer,以便它可以发送更多数据。

如果你有很多连接,你会有很多Message Writer实例。检查例如一百万个Message Writer实例以查看它们是否可以写入任何数据是很慢的。首先,许多Message Writer实例中没有任何消息要发送。我们不想检查那些Message Writer实例。其次,并非所有Channel 实例都准备好将数据写入。我们不想浪费时间尝试将数据写入Channel无法接受任何数据的数据。

要检查Channel是否已准备好写入,可以使用Selector注册Channel。但是,我们不希望使用Selector注册所有Channel实例。想象一下,如果你有大约空闲的1.000.000连接,并且所有1.000.000个连接都已在Selector中注册。然后,当你调用select( )方法,大多数这些Channel 实例它们都是可以写入的(它们大多是空闲的,记得吗?)。然后,你必须检查所有这些连接的Message Writer,以查看它们是否有任何要写入的数据。

为了避免检查所有消息编写器实例中的消息,以及任何情况下都没有任何消息要发送给它们的所有通道实例,我们使用以下两步方法:

  • 当消息写入消息编写器时,消息编写器将其关联的Channel注册到选择器(如果尚未注册)。
  • 当你的服务器有时间限制时,它会检查Selector以查看哪些已注册的Channel 实例已准备好进行写入。对于每个写就绪的通道,请求其关联的消息编写器将数据写入通道。如果Message Writer将其所有消息写入其Channel,则Channel将再次从Selector中注销。

这个小的两步方法确保只有要向其写入消息的通道实例才实际注册到选择器中。

Putting it All Together
如你所见,非阻塞服务器需要不时检查传入的数据,以查看是否收到任何新的完整消息。服务器可能需要多次检查,直到收到一条或多条完整消息。检查一次是不够的。

同样,非阻塞服务器需要不时检查是否有任何要写入的数据。如果是,则服务器需要检查是否有任何相应的连接准备好将数据写入它们。仅当消息第一次排队时进行检查是不够的,因为消息可能是部分写入的。

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

  • 检查来自打开连接的新传入数据的读取管道。
  • 处理接收到的所有完整消息的进程管道。
  • 检查是否可以向任何打开的连接写入任何传出消息的写入管道。

这三个管道在循环中重复执行。您可以稍微优化执行。例如,如果没有排队的消息,则可以跳过写入管道。或者,如果我们没有收到新的完整消息,也许你可​​以跳过流程管道。

下图说明了完整的服务器循环:
图14

如果你仍然觉得这有点复杂,请记得查看GitHub存储库:

地址: link.

也许看到代码的实际应用可以帮助你理解如何实现这一点。

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

前一节中解释的服务器处理循环由处理线程执行。

【上篇】Java NIO学习教程(四)==>点击

【下篇】Java NIO学习教程(六)==>点击

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值