NIO服务器与客户端交互解析 (nio 服务器交互)

NIO(Non-blocking I/O)是Java提供的非阻塞式I/O API,用于处理高并发的网络编程。在网络编程中,服务器与客户端之间需要进行大量的通信交互,NIO提供了更高效的I/O操作方式,可以显著提高系统的吞吐量和响应速度。本文将介绍NIO服务器与客户端之间的交互过程及相关实现细节。

一、NIO服务器和客户端的概述

NIO服务器和客户端的基本架构相似,都由以下几个组件组成:

1. Selector:负责监控多个Channel的状态,如是否有可读、可写、连接请求等事件。

2. Channel:负责与外部通信,如与客户端通信的SocketChannel、与其他服务器通信的ServerSocketChannel等。

3. Buffer:存储读取或写入的数据。

4. Handler:负责处理通道事件,如读取、写入数据、连接请求等。

在服务器端,使用ServerSocketChannel接收客户端请求,然后针对每个客户端分别创建一个SocketChannel进行通信。而客户端只需要创建一个SocketChannel连接服务器即可。

二、NIO服务器与客户端的流程

1. 服务器端启动

服务器启动时,首先需要创建一个Selector对象,并将ServerSocketChannel注册到Selector中,监听ACCEPT事件。然后进入循环,不断调用Selector.select()方法,阻塞等待事件发生。

2. 接受客户端连接请求

当有客户端连接请求到达时,服务器端会先调用accept()方法,获取一个新的SocketChannel,然后将这个新的SocketChannel也注册到Selector中,监听READ事件。接着,将这个新的SocketChannel与客户端生成的唯一标识绑定,以便后续识别。

3. 接收客户端消息

当有客户端发送消息时,服务器端会通过Selector检测到READ事件,进入Handler类处理。在Handler类中,首先需要创建一个ByteBuffer缓冲区用于读取客户端发送过来的数据。读取完成后,需要调用flip()方法将缓冲区置为读模式,以便后续的处理。然后,可以根据实际业务需求进行处理,如进行数据解析、转发等操作。

4. 向客户端发送消息

服务器端向客户端发送消息的流程与接收消息类似。在Handler类中,首先需要创建一个ByteBuffer缓冲区用于存储要发送的数据,然后调用SocketChannel.write()方法将数据写入缓冲区。写完数据后,需要调用flip()方法将缓冲区置为读模式,等待下一次写数据。

5. 关闭连接

当客户端主动关闭连接操作时,服务器端会通过Selector检测到CLIENT_CLOSE事件,进入Handler类处理。在Handler类中,需要将该客户端对应的SocketChannel从Selector中移除,并释放相关资源,如关闭SocketChannel等。

6. 服务器端关闭

当服务器需要关闭时,首先需要将所有客户端对应的SocketChannel从Selector中移除,然后关闭相关资源,如ServerSocketChannel等。

7. 客户端启动

客户端启动时,首先需要创建一个SocketChannel,并连接到指定的服务器IP和端口。连接成功后,将SocketChannel注册到Selector中,监听READ事件。接下来进入循环,不断调用Selector.select()方法,阻塞等待事件发生。

8. 发送消息

当客户端需要向服务器发送消息时,首先需要创建一个ByteBuffer缓冲区用于存储要发送的数据,然后调用SocketChannel.write()方法将数据写入缓冲区。写完数据后,需要调用flip()方法将缓冲区置为读模式,等待下一次写数据。

9. 接收消息

当有服务器向客户端发送消息时,客户端会通过Selector检测到READ事件,进入Handler类处理。与服务器端的处理类似,需要创建一个ByteBuffer缓冲区用于读取服务器发送过来的数据,并进行解析、转发等操作。

10. 关闭连接

当客户端主动关闭连接操作时,与服务器端的处理类似,需要将该客户端对应的SocketChannel从Selector中移除,并释放相关资源,如关闭SocketChannel等。

三、NIO服务器与客户端实现细节

1. 缓冲区的使用

在NIO编程中,缓冲区是处理数据的一个重要概念。通常情况下,我们需要使用ByteBuffer来保存读取和写入的数据。在从缓冲区读取数据时,需要调用flip()方法将缓冲区置为读模式;而在向缓冲区写入数据时,需要调用clear()方法将缓冲区置为写模式。此外,还可以使用Buffer的其他方法,如rewind()、mark()、reset()等来完成缓冲区的读写操作。

2. 多路复用器的使用

在NIO编程中,多路复用器Selector是实现非阻塞式I/O的关键。Selector通过监听多个Channel的状态,可以避免使用传统的阻塞式I/O模型中的死循环等待,从而大大提高系统的吞吐量和响应速度。在使用Selector时,需要将Channel注册到Selector中,并指定所要监听的事件类型。在轮询事件时,可以通过Selector.select()方法等待事件发生,然后通过Selector.selectedKeys()方法获取已经发生的事件。

3. 关闭连接的细节

在一次通信结束后,服务器端和客户端都需要关闭相关的连接和资源。在关闭SocketChannel时,需要先调用SocketChannel.shutdownOutput()方法或SocketChannel.shutdownInput()方法,以确保彻底关闭连接。此外,还需要将SocketChannel从Selector中移除,否则会出现空轮询等问题。

四、

通过本文的介绍,我们了解了NIO服务器与客户端之间的交互过程及相关实现细节。NIO通过非阻塞式I/O的方式提高了系统的吞吐量和响应速度,是处理高并发网络编程的好帮手。在实际开发中,还需要结合具体的业务需求进行相应的优化和扩展,以满足不同的应用场景。

相关问题拓展阅读:

Tomcat的NIO线程模型

这种问题其实到官方文档上查看一简悄番就可以知道,tomcat很早的版本还是使用的BIO,之后就支持NIO了,具体版本我也不记得了,有兴趣的自己可以去查下。本篇的tomcat版本是tomcat8.5。可以到这里看下 tomcat8.5的配置参数

我们先来简单回顾下目前一般的NIO服务器端的大致实现,借鉴infoq上的一篇文章 Netty系列之Netty线程模型 中的一张图

所以一般参数就是Acceptor线程个数,Worker线程个数。来具体看下参数

文档描述为:

The maximum queue length for incoming connection requests when all possible request processing threads are in use. Any requests received when the queue is full will be refused. The default value is 100.

这个参数就立马牵涉出一块大内容:TCP三次握手的详细过程,这个之后再详细探讨(操作系统的接收队列长度默认为100)。这里可以简单理解为拦吵渣:连接在被ServerSocketChannel accept之前就暂存在这个队列中,acceptCount就是这个队列的更大长度碰纳。ServerSocketChannel accept就是从这个队列中不断取出已经建立连接的的请求。所以当ServerSocketChannel accept取出不及时就有可能造成该队列积压,一旦满了连接就被拒绝了

文档如下描述

The number of threads to be used to accept connections. Increase this value on a multi CPU machine, although you would never really need more than 2. Also, with a lot of non keep alive connections, you might want to increase this value as well. Default value is 1.

Acceptor线程只负责从上述队列中取出已经建立连接的请求。在启动的时候使用一个ServerSocketChannel监听一个连接端口如8080,可以有多个Acceptor线程并发不断调用上述ServerSocketChannel的accept方法来获取新的连接。参数acceptorThreadCount其实使用的Acceptor线程的个数。

文档描述如下

The maximum number of connections that the server will accept and process at any given time. When this number has been reached, the server will accept, but not process, one further connection. This additional connection be blocked until the number of connections being processed falls below maxConnections at which point the server will start accepting and processing new connections again. Note that once the limit has been reached, the operating system may still accept connections based on the acceptCount setting. The default value varies by connector type. For NIO and NIO2 the default is 10000. For APR/native, the default is 8192.

Note that for APR/native on Windows, the configured value will be reduced to the highest multiple of 1024 that is less than or equal to maxConnections. This is done for performance reasons. If set to a value of -1, the maxConnections feature is disabled and connections are not counted.

这里就是tomcat对于连接数的一个控制,即更大连接数限制。一旦发现当前连接数已经超过了一定的数量(NIO默认是10000,BIO是200与线程池更大线程数密切相关),上述的Acceptor线程就被阻塞了,即不再执行ServerSocketChannel的accept方法从队列中获取已经建立的连接。但是它并不阻止新的连接的建立,新的连接的建立过程不是Acceptor控制的,Acceptor仅仅是从队列中获取新建立的连接。所以当连接数已经超过maxConnections后,仍然是可以建立新的连接的,存放在上述acceptCount大小的队列中,这个队列里面的连接没有被Acceptor获取,就处于连接建立了但是不被处理的状态。当连接数低于maxConnections之后,Acceptor线程就不再阻塞,继续调用ServerSocketChannel的accept方法从acceptCount大小的队列中继续获取新的连接,之后就开始处理这些新的连接的IO事件了

文档描述如下

The maximum number of request processing threads to be created by this Connector, which therefore determines the maximum number of simultaneous requests that can be handled. If not specified, this attribute is set to 200. If an executor is associated with this connector, this attribute is ignored as the connector will execute tasks using the executor rather than an internal thread pool.

这个简单理解就算是上述worker的线程数,下面会详细的说明。他们专门用于处理IO事件,默认是200。

上面参数仅仅是简单了解了下参数配置,下面我们就来详细研究下tomcat的NIO服务器具体情况,这就要详细了解下tomcat的NioEndpoint实现了

先来借鉴看下 tomcat高并发场景下的BUG排查 中的一张图

这张图勾画出了NioEndpoint的大致执行流程图,worker线程并没有体现出来,它是作为一个线程池不断的执行IO读写事件即SocketProcessor(一个Runnable),即这里的Poller仅仅监听Socket的IO事件,然后封装成一个个的SocketProcessor交给worker线程池来处理。下面我们来详细的介绍下NioEndpoint中的Acceptor、Poller、SocketProcessor

获取指定的Acceptor数量的线程

可以看到就是一个while循环,循环里面不断的accept新的连接。

先来看下在accept新的连接之前,首选进行连接数的自增,即countUpOrAwaitConnection

当我们设置maxConnections=-1的时候就表示不用限制更大连接数。默认是限制10000,如果不限制则一旦出现大的冲击,则tomcat很有可能直接挂掉,导致服务停止。

这里的需求就是当前连接数一旦超过更大连接数maxConnections,就直接阻塞了,一旦当前连接数小于更大连接数maxConnections,就不再阻塞,我们来看下这个功能的具体实现latch.countUpOrAwait()

具体看这个需求无非就是一个共享锁,来看具体实现:

目前实现里算是使用了2个锁,LimitLatch本身的AQS实现再加上AtomicLong的AQS实现。也可以不使用AtomicLong来实现。

共享锁的tryAcquireShared实现中,如果不依托AtomicLong,则需要进行for循环加CAS的自增,自增之后没有超过limit这里即maxConnections,则直接返回1表示获取到了共享锁,如果一旦超过limit则首先进行for循环加CAS的自减,然后返回-1表示获取锁失败,便进入加入同步队列进入阻塞状态。

共享锁的tryReleaseShared实现中,该方法可能会被并发执行,所以释放共享锁的时候也是需要for循环加CAS的自减

上述的for循环加CAS的自增、for循环加CAS的自减的实现全部被替换成了AtomicLong的incrementAndGet和decrementAndGet而已。

上文我们关注的latch.countUpOrAwait()方法其实就是在获取一个共享锁,如下:

从上面可以看到在真正获取一个连接之前,首先是把连接计数先自增了。一旦TCP三次握手成功连接建立,就能从ServerSocketChannel的accept方法中获取到新的连接了。一旦获取连接或者处理过程发生异常则需要将当前连接数自减的,否则会造成连接数虚高,即当前连接数并没有那么多,但是当前连接数却很大,一旦超过更大连接数,就导致其他请求全部阻塞,没有办法被ServerSocketChannel的accept处理。该bug在Tomcat7.0.26版本中出现了,详细见这里的一篇文章 Tomcat7.0.26的连接数控制bug的问题排查

然后我们来看下,一个SocketChannel连接被accept获取之后如何来处理的呢?

处理过程如下:

下面就来详细介绍下Poller

前面没有说到Poller的数量控制,来看下

如果不设置的话更大就是2

来详细看下getPoller0().register(channel):

就是轮训一个Poller来进行SocketChannel的注册

这里又是进行一些参数包装,将socket和Poller的关系绑定,再次从缓存中取出或者重新构建一个PollerEvent,然后将该event放到Poller的事件队列中等待被异步处理

在Poller的run方法中不断处理上述事件队列中的事件,直接执行PollerEvent的run方法,将SocketChannel注册到自己的Selector上。

并将Selector监听到的IO读写事件封装成SocketProcessor,交给线程池执行

我们来看看这个线程池的初始化:

就是创建了一个ThreadPoolExecutor,那我们就重点关注下核心线程数、更大线程数、任务队列等信息

核心线程数更大是10个,再来看下更大线程数

默认就是上面的配置参数maxThreads为200。还有就是TaskQueue,这里的TaskQueue是LinkedBlockingQueue的子类,更大容量就是Integer.MAX_VALUE,根据之前ThreadPoolExecutor的源码分析,核心线程数满了之后,会先将任务放到队列中,队列满了才会创建出新的非核心线程,如果队列是一个大容量的话,也就是不会到创建新的非核心线程那一步了。

但是这里的TaskQueue修改了底层offer的实现

这里当线程数小于更大线程数的时候就直接返回false即入队列失败,则迫使ThreadPoolExecutor创建出新的非核心线程。

TaskQueue这一块没太看懂它的意图是什么,有待继续研究。

本篇文章描述了tomcat8.5中的NIO线程模型,以及其中涉及到的相关参数的设置。

nio 服务器交互的介绍就聊到这里吧,感谢你花时间阅读本站内容,更多关于nio 服务器交互,NIO服务器与客户端交互解析,Tomcat的NIO线程模型的信息别忘了在本站进行查找喔。


数据运维技术 » NIO服务器与客户端交互解析 (nio 服务器交互)