Java NIO Selector
Java NIO Selector是一个组件,可以检查一个或多个Java NIO Channel实例,并确定哪些通道可供使用,读或写.这样,单个线程可以管理多个通道,从而可以管理多个网络连接。
为什么要使用选择器?
仅使用单个线程来处理多个通道的优点是只需要更少的线程来处理通道。 实际上,只能使用一个线程来处理所有通道。 线程之间的切换对于操作系统来说是昂贵的,并且每个线程也占用操作系统中的一些资源(内存)。 因此,使用的线程越少越好。
现代操作系统和CPU在多任务处理方面变得越来越好,因此,随着时间的推移,多线程的开销会越来越小。 实际上,如果一个CPU有多个内核,则可能由于不执行多任务处理而浪费了CPU的性能。 无论如何,该设计讨论属于不同的文本。可以使用选择器通过一个线程处理多个通道
这是使用选择器处理3通道的线程的图示:
图4.png创建一个选择器
可以通过调用Selector.open()方法创建一个Selector,如下所示:
Selector selector = Selector.open();
向选择器注册通道
为了将通道与选择器一起使用,必须将通道注册到选择器。 这是使用SelectableChannel.register()方法完成的,如下所示:
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
通道必须处于非阻塞模式才能与选择器一起使用。 这意味着不能将FileChannel与选择器一起使用,因为FileChannel不能切换到非阻塞模式。 套接字通道可以正常工作。
注意register()方法的第二个参数。 这是一个“兴趣组”,表示希望通过选择器在频道中收听哪些事件。 您可以听四种不同的事件:
-
Connect
-
Accept
-
Read
-
Write
也可以说“触发事件”的频道已为该事件“就绪”。 因此,已成功连接到另一台服务器的通道是“连接就绪”。 接受传入连接的服务器套接字通道已“接受”就绪。 准备读取数据的通道已“读取”就绪。 准备好向其写入数据的通道已“写入”就绪。
这四个事件由四个SelectionKey常量表示:
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
如果对多个事件或常量或多个常量感兴趣,如下所示:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
SelectionKey
如上一节所述,当使用Selector注册Channel时,register()方法将返回SelectionKey对象。 这个SelectionKey对象包含一些有趣的属性:
- The interest set
- The ready set
- The Channel
- The Selector
- An attached object (optional)
Interest Set
兴趣集是对“选择”感兴趣的事件集,如“在选择器中注册通道”一节中所述。 可以通过SelectionKey读取和写入该兴趣集,如下所示:
int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept = interestSet & SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite = interestSet & SelectionKey.OP_WRITE;
可以将兴趣集与给定的SelectionKey常数进行“与”运算,以确定某个事件是否在兴趣集中。
Ready Set
准备集是通道准备进行的一组操作。 选择后,将主要访问准备好的设置。 选择将在后面的部分中说明。 可以按以下方式访问就绪集:
int readySet = selectionKey.readyOps();
可以使用兴趣设定相同的方式测试频道准备就绪的事件/操作。 但是,也可以改用这四个方法,它们都产生一个布尔值:
selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();
Channel + Selector
从SelectionKey访问通道+选择器很简单。 这是完成的过程:
Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector()
Attaching Objects
可以将对象附加到SelectionKey,这是识别给定通道或将更多信息附加到该通道的便捷方法。 例如,可以将正在使用的缓冲区附加到通道,或包含更多聚合数据的对象。 这是附加对象的方式:
selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();
还可以在register()方法中向选择器注册通道时附加对象。 看起来是这样的:
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);
Selecting Channels via a Selector
使用选择器注册一个或多个通道后,可以调用select()方法之一。 这些方法返回感兴趣的事件(连接,接受,读取或写入)的“就绪”通道。 换句话说,如果对准备读取的通道感兴趣,那么将从select()方法接收准备读取的通道。
这是select()方法:
int select()
int select(long timeout)
int selectNow()
select()将阻塞,直到至少一个通道可用于您注册的事件。
select(long timeout)与select()相同,除了它会阻塞最大超时毫秒(参数)。
selectNow()完全不会阻塞。 无论准备好任何通道,它都会立即返回。
select()方法返回的int告诉您准备了多少个通道。 也就是说,自从您上次调用select()以来已准备好多少个通道。 如果您调用select()并返回1,因为一个通道已准备就绪,又调用一次select(),又有一个通道已准备就绪,则它将再次返回1。 如果您对第一个就绪的通道没有做任何事情,则现在有2个就绪的通道,但是在每个select()调用之间只有一个通道已就绪。
selectedKeys()
一旦调用了select()方法之一,并且其返回值指示一个或多个通道已就绪,则可以通过调用选择器selectedKeys()方法来通过“ selected key set”访问就绪通道。 看起来是这样的:
Set<SelectionKey> selectedKeys = selector.selectedKeys();
当您使用Selector注册通道时,Channel.register()方法将返回SelectionKey对象。 该键表示使用该选择器注册频道。 您可以通过selectedKeySet()方法访问这些键。 从SelectionKey。
可以迭代此选定的键集以访问就绪通道。 看起来是这样的:
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if(key.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
} else if (key.isConnectable()) {
// a connection was established with a remote server.
} else if (key.isReadable()) {
// a channel is ready for reading
} else if (key.isWritable()) {
// a channel is ready for writing
}
keyIterator.remove();
}
此循环迭代所选键集中的键。 对于每个键,它都会测试该键,以确定该键引用的通道已准备就绪。
请注意,每次迭代结束时都会调用keyIterator.remove()。 选择器不会从所选键集本身中删除SelectionKey实例。 处理完频道后,必须执行此操作。 下次频道变为“就绪”状态时,选择器将再次将其添加到所选键集中。
由SelectionKey.channel()方法返回的通道应强制转换为您需要使用的通道,例如ServerSocketChannel或SocketChannel等。
wakeUp()
即使尚未准备好任何通道,也可以使已调用被阻塞的select()方法的线程离开select()方法。 这是通过让另一个线程在Selector上调用Selector.wakeup()方法来完成的,第一个线程已在Selector上调用了Selector.wakeup()方法。 在select()中等待的线程将立即返回。
如果其他线程调用了wakeup(),并且当前在select()内没有任何线程被阻塞,则下一个调用select()的线程将立即“唤醒”。
close()
选择器完成后,将调用其close()方法。 这将关闭选择器,并使在此选择器中注册的所有SelectionKey实例无效。 通道本身未关闭。
Full Selector Example
这是一个完整的示例,其中将打开一个选择器,向其注册一个通道(不保留通道实例化),并继续监视选择器以了解四个事件(接受,连接,读取,写入)的“就绪”状态。
Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
while(true) {
int readyChannels = selector.selectNow();
if(readyChannels == 0) continue;
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if(key.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
} else if (key.isConnectable()) {
// a connection was established with a remote server.
} else if (key.isReadable()) {
// a channel is ready for reading
} else if (key.isWritable()) {
// a channel is ready for writing
}
keyIterator.remove();
}
}
SocketChannel
Java NIO SocketChannel是连接到TCP网络套接字的通道。 它与Java NIO等效于Java Networking的套接字。 有两种创建SocketChannel的方法:
-
打开一个SocketChannel并连接到Internet上的某个服务器。
-
当传入的连接到达ServerSocketChannel时可以创建SocketChannel。
Opening a SocketChannel
这是打开SocketChannel的方法:
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));
Closing a SocketChannel
使用后,通过调用SocketChannel.close()方法关闭SocketChannel。 这是完成的方式:
socketChannel.close();
Reading from a SocketChannel
要从SocketChannel读取数据,请调用read()方法之一。 这是一个例子:
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);
首先分配一个缓冲区。 从SocketChannel读取的数据被读入Buffer。
其次,调用SocketChannel.read()方法。 此方法将数据从SocketChannel读取到Buffer中。 read()方法返回的int告诉将多少字节传送到Buffer中。 如果返回-1,则到达流的末尾(连接已关闭)。
Writing to a SocketChannel
使用SocketChannel.write()方法将数据写入SocketChannel,该方法将Buffer作为参数。 这是一个例子:
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
channel.write(buf);
}
注意在while循环中如何调用SocketChannel.write()方法。 无法保证write()方法将多少字节写入SocketChannel。 因此,我们重复执行write()调用,直到Buffer没有其他要写入的字节为止。
Non-blocking Mode
您可以将SocketChannel设置为非阻塞模式。 这样做时,可以在异步模式下调用connect(),read()和write()。
connect()
如果SocketChannel处于非阻塞模式,并且您调用connect(),则该方法可能在建立连接之前返回。 要确定是否建立连接,可以调用finishConnect()方法,如下所示:
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));
while(! socketChannel.finishConnect() ){
//wait, or do something else...
}
write()
在非阻塞模式下,write()方法可能会在未写入任何内容的情况下返回。 因此,您需要循环调用write()方法。 但是,由于在前面的编写示例中已经完成了此操作,因此此处无需做任何其他操作。
read()
在非阻塞模式下,read()方法可以返回而根本不读取任何数据。 因此,您需要注意返回的int,该int指示读取了多少字节。
Non-blocking Mode with Selectors
SocketChannel的非阻塞模式与Selector的效果更好。 通过向选择器注册一个或多个SocketChannel,您可以向选择器询问准备好进行读取,写入等操作的通道。本教程后面的文章中将更详细地说明如何将选择器与SocketChannel一起使用。
ServerSocketChannel
Java NIO ServerSocketChannel是一个通道,可以侦听传入的TCP连接,就像标准Java Networking中的ServerSocket一样。 ServerSocketChannel类位于java.nio.channels包中。
Here is an example:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
while(true){
SocketChannel socketChannel =
serverSocketChannel.accept();
//do something with socketChannel...
}
Opening a ServerSocketChannel
您可以通过调用ServerSocketChannel.open()方法来打开ServerSocketChannel。 看起来是这样的:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
Closing a 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循环中使用除true之外的其他停止条件。
Non-blocking Mode
可以将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...
}
}
Non-blocking Server
无阻塞IO管道
无阻塞IO管道是处理无阻塞IO的组件链。 这包括以非阻塞方式读取和写入IO。 这是简化的无阻塞IO管道的说明:
图5.png组件使用选择器检查通道何时有要读取的数据。 然后,组件读取输入数据并根据输入生成一些输出。 输出再次写入通道。
无阻塞的IO管道不需要读取和写入数据。 一些管道可能仅读取数据,而某些管道可能仅写入数据。
上图仅显示了一个组件。 无阻塞IO管道可能具有多个组件处理传入数据。 无阻塞IO管道的长度取决于管道需要执行的操作。
非阻塞IO管道也可能同时从多个通道读取。 例如,从多个SocketChannel读取数据。
上图中的控制流程也得到了简化。 它是启动通过选择器从通道读取数据的组件。 即使是上图所示,也不是将数据推入选择器以及从那里推入组件的通道。
非阻塞与阻塞IO管道
非阻塞和阻塞IO管道之间的最大区别是如何从基础通道(套接字或文件)读取数据。
IO管道通常从某个流(从套接字或文件)中读取数据,并将该数据拆分为一致的消息。 这类似于将数据流分成令牌以使用令牌生成器进行解析。 相反,将数据流分解为更大的消息。 我将调用将该流分解为消息阅读器的消息的组件。 这是消息阅读器将流分成消息的说明:
图6.png阻塞的IO管道可以使用类似InputStream的接口,可以一次从底层Channel读取一个字节,并且可以使用类似InputStream的接口进行阻塞,直到准备好读取数据为止。 这导致阻塞的消息阅读器实施。
对流使用阻塞的IO接口可以大大简化消息阅读器的实现。 阻塞的消息阅读器永远不必处理从流中未读取任何数据,或者仅从流中读取了部分消息并且以后需要恢复消息解析的情况。
同样,阻塞消息编写器(将消息写入流中的组件)永远不必处理仅写入消息的一部分且稍后必须恢复消息写入的情况。
阻塞IO管道的缺点
尽管阻塞消息阅读器更易于实现,但它有一个不幸的缺点,即需要为每个需要拆分为消息的流分配一个单独的线程。之所以需要这样做,是因为每个流的IO接口都会阻塞,直到有一些数据要从中读取为止。这意味着单个线程无法尝试从一个流中读取,并且如果没有数据,则从另一个流中读取。一旦线程尝试从流中读取数据,线程就会阻塞,直到实际上有一些数据要读取为止。
如果IO管道是必须处理大量并发连接的服务器的一部分,则该服务器将为每个活动的传入连接需要一个线程。如果服务器在任何时候都只有几百个并发连接,这可能不是问题。但是,如果服务器具有数百万个并发连接,则这种设计的扩展性就不太好。每个线程将为其堆栈占用320K(32位JVM)和1024K(64位JVM)内存。因此,1.000.000线程将占用1 TB内存!也就是在服务器使用任何内存来处理传入消息之前(例如,为消息处理期间使用的对象分配的内存)。
为了减少线程数量,许多服务器使用一种设计,其中服务器保留一个线程池(例如100),该线程池一次从入站连接中读取消息。 入站连接保留在队列中,线程按入站连接放入队列的顺序处理来自每个入站连接的消息。 此设计在此处说明:
图7.png但是,此设计要求入站连接合理地发送数据。 如果入站连接可能长时间处于非活动状态,则大量的非活动连接实际上可能会阻塞线程池中的所有线程。 这意味着服务器的响应速度变慢甚至不响应。
一些服务器设计试图通过使线程池中的线程数具有一定的弹性来缓解此问题。 例如,如果线程池用完了线程,则线程池可能会启动更多线程来处理负载。 此解决方案意味着需要大量的慢速连接才能使服务器无响应。 但是请记住,可以运行的线程数仍然有上限。 因此,如果连接速度为1.000.000,则扩展效果不佳。
基本的无阻塞IO管道设计
非阻塞IO管道可以使用单个线程从多个流中读取消息。 这要求流可以切换到非阻塞模式。 在非阻塞模式下,当您尝试从流中读取数据时,流可能返回0个或多个字节。 如果流中没有要读取的数据,则返回0字节。 当流实际上有一些数据要读取时,将返回1+字节。
为了避免检查要读取的0字节流,我们使用Java NIO选择器。 可以向一个选择器注册一个或多个SelectableChannel实例。 当您在Selector上调用select()或selectNow()时,它仅为您提供实际上具有要读取的数据的SelectableChannel实例。 此设计在此处说明:
图8.png读取部分消息
当我们从SelectableChannel读取数据块时,我们不知道该数据块包含的是少于还是多于一条消息。 数据块可能包含部分消息(少于一条消息),完整消息或多于一条消息,例如1.5或2.5条消息。 此处显示了各种部分消息的可能性:
图9.png处理部分消息有两个挑战:
-
检测数据块中是否有完整的消息。
-
在部分消息到达之前如何处理部分消息。
要检测完整消息,需要消息阅读器查看数据块中的数据,以查看数据中是否至少包含一条完整消息。 如果数据块包含一个或多个完整消息,则可以将这些消息沿管道发送以进行处理。 查找完整消息的过程将重复很多,因此该过程必须尽可能快。
每当数据块中有部分消息时,无论是本身还是在一个或多个完整消息之后,都需要存储该部分消息,直到该消息的其余部分从通道到达为止。
消息阅读器负责检测全部消息和存储部分消息。 为了避免混合来自不同Channel实例的消息数据,我们将在每个Channel上使用一个Message Reader。 设计看起来像这样:
图10.png在检索到具有要从选择器读取的数据的Channel实例之后,与该Channel相关联的消息阅读器将读取数据,并尝试将其分解为消息。 如果这导致读取任何完整的消息,则可以将这些消息沿读取管道向下传递到需要处理它们的任何组件。
消息阅读器当然是特定于协议的。 消息阅读器需要知道它试图读取的消息的消息格式。 如果我们的服务器实现要可跨协议重用,则它需要能够插入Message Reader实现-可能通过某种方式接受Message Reader工厂作为配置参数。
存储部分消息
既然我们已经确定了消息阅读器的职责是存储部分消息,直到收到完整消息为止,我们需要弄清楚应该如何实现部分消息存储。
我们应考虑两个设计注意事项:
-
我们希望尽可能少地复制消息数据。 复制越多,性能越低。
-
我们希望将完整的消息存储在连续的字节序列中,以使解析消息更加容易。
每个消息阅读器的缓冲区
显然,部分消息需要存储在某种缓冲区中。 直接的实现是在每个消息阅读器内部简单地具有一个缓冲区。 但是,该缓冲区应该有多大? 它必须足够大才能存储最大允许的消息。 因此,如果允许的最大消息为1MB,则每个消息阅读器中的内部缓冲区至少需要为1MB。
当我们达到数百万个连接时,每个连接使用1MB并不能真正起作用。 1.000.000 x 1MB仍是1TB内存! 如果最大邮件大小为16MB,该怎么办? 还是128MB?
可调整大小的缓冲区
另一个选择是实现可调整大小的缓冲区,以供在每个Message Reader中使用。 可调整大小的缓冲区将从较小的缓冲区开始,并且如果消息对于该缓冲区而言太大,则缓冲区将被扩展。 这样,每个连接将不一定需要例如 1MB缓冲区。 每个连接仅占用它们容纳下一条消息所需的内存。
有几种方法可以实现可调整大小的缓冲区。 它们都有优点和缺点
按副本调整大小
实现可调整大小的缓冲区的第一种方法是从一个较小的缓冲区开始,例如 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存储库的链接,其中包含显示有效实施的代码
通过追加调整大小
调整缓冲区大小的另一种方法是使缓冲区包含多个数组。当需要调整缓冲区大小时,您只需分配另一个字节数组,然后将数据写入其中即可。
有两种增加缓冲区的方法。一种方法是分配单独的字节数组,并保留这些字节数组的列表。另一种方法是分配更大的共享字节数组的切片,然后保留分配给缓冲区的切片的列表。就个人而言,我认为切片方法稍好一些,但差异很小。
通过将单独的数组或切片附加到缓冲区中来增加缓冲区的优点是,在写入过程中无需复制任何数据。可以将所有数据直接从套接字(通道)直接复制到数组或切片中。
以这种方式增长缓冲区的缺点是数据不会存储在单个连续的数组中。这使消息解析更加困难,因为解析器需要同时查找每个单个数组的结尾和所有数组的结尾。由于您需要在书面数据中查找消息的结尾,因此使用此模型不太容易
TLV编码消息
某些协议消息格式使用TLV格式(类型,长度,值)进行编码。这意味着,当消息到达时,消息的总长度将存储在消息的开头。这样,您立即知道要为整个消息分配多少内存。
TLV编码使内存管理更加容易。您立即知道要为该消息分配多少内存。在仅部分使用的缓冲区的末尾不会浪费任何内存。
TLV编码的一个缺点是,您必须在消息的所有数据到达之前为消息分配所有内存。因此,一些发送大消息的慢速连接可以分配所有可用的内存,从而使服务器无响应。
解决此问题的方法是使用一种消息格式,其中包含多个TLV字段。因此,为每个字段分配内存,而不是为整个消息分配内存,并且仅在字段到达时才分配内存。尽管如此,大字段对大容量消息的影响仍与大消息相同。
另一个解决方法是超时,例如,在服务器内部未接收到消息。 10-15秒。这可以使您的服务器从同时出现的许多大消息中恢复过来,但仍会使服务器在一段时间内无响应。此外,故意的DoS(拒绝服务)攻击仍可能导致为服务器完全分配内存。
TLV编码存在不同的变体。确切地使用了多少字节,因此指定字段的类型和长度取决于每个单独的TLV编码。也有TLV编码将字段的长度放在首位,然后是类型,然后是值(LTV编码)。尽管字段的顺序不同,但这仍然是TLV的变体。
TLV编码使内存管理更容易的事实是HTTP 1.1如此糟糕的协议的原因之一。这是他们试图在HTTP 2.0中解决的问题之一,在HTTP 2.0中,数据以LTV编码的帧进行传输。这也是为什么我们为使用TLV编码的VStack.co项目设计了自己的网络协议的原因。
编写部分消息
在无阻塞的IO管道中,写入数据也是一个挑战。当您以非阻塞模式在通道上调用write(ByteBuffer)时,无法保证ByteBuffer中有多少字节被写入。 write(ByteBuffer)方法返回已写入的字节数,因此可以跟踪已写入的字节数。这就是挑战:跟踪部分写入的消息,以便最后发送一条消息的所有字节。
为了管理将部分消息写入通道,我们将创建一个消息编写器。就像使用消息阅读器一样,我们在每个向其写入消息的通道上都需要一个消息编写器。在每个Message Writer内,我们都精确跟踪当前正在写入的消息已写入多少字节。
如果到达消息编写器的消息超过了直接写入通道的消息数量,则需要在消息编写器内部将消息排队。然后,消息编写器将消息尽快写入通道
这是显示到目前为止如何设计部分消息的图:
图11.png为了使Message Writer能够发送仅部分发送的较早消息,需要不时调用Message Writer,以便可以发送更多数据。
如果您有很多连接,则将有很多Message Writer实例。 检查例如 一百万个Message Writer实例查看它们是否可以写入任何数据很慢。 首先,许多Message Writer实例很多没有任何要发送的消息。 我们不想检查那些Message Writer实例。 其次,并非所有Channel实例都准备好向其中写入数据。 我们不想浪费时间尝试将数据写入仍然无法接受任何数据的Channel。
要检查通道是否已准备好写入,可以向选择器注册该通道。 但是,我们不想向选择器注册所有Channel实例。 想象一下,如果您有1.000.000个连接(大多数是空闲的)并且所有1.000.000个连接都已在选择器中注册。 然后,当您调用select()时,这些Channel实例中的大多数实例将为可写状态(它们大部分处于空闲状态,还记得吗?)。 然后,您必须检查所有这些连接的消息编写器,以查看它们是否有任何数据要写入。
为了避免检查所有Message Writer实例中的消息,以及所有始终没有任何消息要发送给它们的Channel实例,我们使用以下两步方法:
- 将消息写入消息编写器时,消息编写器向选择器注册其关联的通道(如果尚未注册)。
- 当您的服务器有时间时,它将检查选择器,以查看哪些已注册的Channel实例已准备好进行写入。 对于每个可写通道,要求其关联的消息编写器将数据写入该通道。 如果消息编写器将其所有消息写入其通道,则该通道将再次从选择器中注销。
这种分两步走的小方法可确保只有将要写入消息的Channel实例实际上已在Selector中注册。
汇总
如您所见,非阻塞服务器需要不时检查传入数据,以查看是否接收到任何新的完整消息。 服务器可能需要多次检查,直到收到一个或多个完整的消息。 仅检查一次是不够的。
同样,非阻塞服务器需要不时检查是否有任何数据要写入。 如果是,则服务器需要检查是否有任何相应的连接已准备好将数据写入其中。 仅在消息第一次排队时进行检查是不够的,因为消息可能会被部分写入。
总而言之,一个非阻塞服务器最终需要执行三个“管道”:
- 读取管道,用于检查来自打开的连接的新传入数据。
- 处理接收到的所有完整消息的处理管道。
- 用于检查是否可以将任何传出消息写入任何打开的connectio的写管道
这三个管道在一个循环中重复执行。 您也许可以在某种程度上优化执行。 例如,如果没有消息排队,则可以跳过写管道。 或者,如果没有收到新的完整消息,则可以跳过流程管道。
这是说明整个服务器循环的图:
图12.png参考资料
http://tutorials.jenkov.com/java-nio/non-blocking-server.html
网友评论