Tomcat 异步组件 —— Nio2Endpoint

如果大家觉得文章有错误内容,欢迎留言或者私信讨论~

  之前我们提到 I/O 的 5 种模型,在 Tomcat 里支持 BIO、NIO、NIO.2,BIO 是同步阻塞,NIO 是同步非阻塞,那 NIO.2 又是什么呢?NIO 已经足够好了,为什么还要 NIO.2 呢?

  NIO 与 NIO.2 的最大区别是一个同步的另一个是异步。在之前的博客我们讲到,异步最大的特点是不需要自己去触发数据从内核空间到用户空间的拷贝。因为用户不能直接访问内核空间,因此数据的拷贝需要内核空间自己来做,

  谁来触发这个动作, 是内核主动将数据拷贝到用户空间并通知应用程序。还是等待应用程序通过 Selector 来查询,当数据就绪后,应用程序再发起一个 read 调用,这时内核再把数据从内核空间拷贝到用户空间。

  我们再说回异步模式的工作过程。首先,应用程序在调用 read API 的同时告诉内核两件事情:数据准备好了以后拷贝到哪个 Buffer,以及调用哪个回调函数去处理这些数据。之后,内核接到这个 read 指令之后,等待网卡数据的到达,数据到达之后,将数据拷贝到内核空间,接着做 TCP/IP 协议层面的数据解包和重组,再把数据拷贝到应用程序指定的 Buffer,最后调用应用程序指定的回调函数。

  你可能通过下面这张图来回顾一下同步与异步的区别:
在这里插入图片描述

Java Nio.2 回顾

  先来简单回顾一下 Java 的 NIO.2 是如何来编写一个服务端程序的:

public class Nio2Server {

   void listen(){
      //1.创建一个线程池
      ExecutorService es = Executors.newCachedThreadPool();

      //2.创建异步通道群组
      AsynchronousChannelGroup tg = AsynchronousChannelGroup.withCachedThreadPool(es, 1);
      
      //3.创建服务端异步通道
      AsynchronousServerSocketChannel assc = AsynchronousServerSocketChannel.open(tg);

      //4.绑定监听端口
      assc.bind(new InetSocketAddress(8080));

      //5. 监听连接,传入回调类处理连接请求
      assc.accept(this, new AcceptHandler()); 
   }
}

  上面的代码主要做了 5 件事情:

  1. 创建一个线程池,这个线程池用来执行来自内核的回调请求。
  2. 创建一个 AsynchronousChannelGroup,并绑定一个线程池。
  3. 创建 AsynchronousServerSocketChannel,并绑定到 AsynchronousChannelGroup。
  4. 绑定一个监听端口。
  5. 调用 accept 方法开始监听连接请求,同时传入一个回调类去处理连接请求。

  你可能会问为什么需要创建一个线程池呢?其实在异步 I/O 模型里,应用程序不知道数据在什么时候到达,因此向内核注册回调函数,当数据到达时,内核就会调用这个回调函数。同时为了提高内核的工作速度,内核只需要把工作交给线程池就立即返回了。

  我们再来看看处理连接的回调类 AcceptHandler 是什么样的。


//AcceptHandler类实现了CompletionHandler接口的completed方法。它还有两个模板参数,第一个是异步通道,第二个就是Nio2Server本身
public class AcceptHandler implements CompletionHandler<AsynchronousSocketChannel, Nio2Server> {

   //具体处理连接请求的就是completed方法,它有两个参数:第一个是异步通道,第二个就是上面传入的NioServer对象
   @Override
   public void completed(AsynchronousSocketChannel asc, Nio2Server attachment) {      
      //调用accept方法继续接收其他客户端的请求
      attachment.assc.accept(attachment, this);
      
      //1. 先分配好Buffer,告诉内核,数据拷贝到哪里去
      ByteBuffer buf = ByteBuffer.allocate(1024);
      
      //2. 调用read函数读取数据,除了把buf作为参数传入,还传入读回调类
      channel.read(buf, buf, new ReadHandler(asc)); 
	}
}

  接下来我们看看 CompletionHandler 接口的定义:

public interface CompletionHandler<V,A> {

    void completed(V result, A attachment);

    void failed(Throwable exc, A attachment);
}

  CompletionHandler 接口有两个模板参数 V 和 A,分别表示 I/O 调用的返回值和附件类。比如 accept 的返回值就是 AsynchronousSocketChannel,而附件类由用户自己决定,在 accept 的调用中,我们传入了一个 Nio2Server。因此这里AcceptHandler 带有了两个模板参数:AsynchronousSocketChannel 和 Nio2Server。

  CompletionHandler 有两个方法:completed 和 failed,分别在 I/O 操作成功和失败时调用。completed 方法有两个参数,其实就是前面说的两个模板参数。也就是说,Java 的 NIO.2 在调用回调方法时,会把返回值和附件类当作参数传给 NIO.2 的使用者。

  下面我们再来看看处理读的回调类 ReadHandler 长什么样子。

public class ReadHandler implements CompletionHandler<Integer, ByteBuffer> {   
    //读取到消息后的处理  
    @Override  
    public void completed(Integer result, ByteBuffer attachment) {  
        //attachment就是数据,调用flip操作,其实就是把读的位置移动最前面
        attachment.flip();  
        //读取数据
        ... 
    }  

    void failed(Throwable exc, A attachment){
        ...
    }
}

  read 调用的返回值是一个整型数,所以我们回调方法里的第一个参数就是一个整型,表示有多少数据被读取到了 Buffer 中。第二个参数是一个 ByteBuffer,这是因为我们在调用 read 方法时,把用来存放数据的 ByteBuffer 当作附件类传进去了,所以在回调方法里,有 ByteBuffer 类型的参数,我们直接从这个 ByteBuffer 里获取数据。

Nio2Endpoint

  我们先通过一张图来了解 Nio2Endpoint 有哪些组件:

在这里插入图片描述

  总体流程是跟 NioEndpoint 一致的,LimitLatch 依旧是连接控制器。

  Nio2Accpetor 扩展了 Acceptor,用异步 I/O 的方式来接收连接,跑在一个单独的线程里,也是一个线程组。Nio2Acceptor 接收新的连接后,得到一个 AsynchronousSocketChannel,Nio2Acceptor 把 AsynchronousSocketChannel 封装成一个 Nio2SocketWrapper,并创建一个 SocketProcessor 任务类交给线程池处理,并且 SocketProcessor 持有 Nio2SocketWrapper 对象。

  Executor 在执行 SocketProcessor 时,SocketProcessor 的 run 方法会调用 Http11Processor 来处理请求,Http11Processor 会通过 Nio2SocketWrapper 读取和解析请求数据,请求经过容器处理后,再把响应通过 Nio2SocketWrapper 写出。

  Nio2Endpoint 与 NioEndpoint 有一个明显的不同点,就是 Nio2Endpoint 没有 Poller 组件,也就是 Selector。因为在异步 I/O 中 Selector 的工作是内核在做的

  接下来我详细介绍一下 Nio2Endpoint 各组件的设计。

Nio2Acceptor

  和 NioEndpint 一样,Nio2Endpoint 的基本思路是用 LimitLatch 组件来控制连接数,但是 Nio2Acceptor 的监听连接的过程不是在一个死循环里不断地调 accept 方法,而是通过回调函数来完成的。我们来看看它的连接监听方法:

serverSock.accept(null, this);

  其实就是调用了 accept 方法,注意它的第二个参数是 this,表明 Nio2Acceptor 自己就是处理连接的回调类,因此 Nio2Acceptor 实现了 CompletionHandler 接口。那么它是如何实现 CompletionHandler 接口的呢?

protected class Nio2Acceptor extends Acceptor<AsynchronousSocketChannel>
    implements CompletionHandler<AsynchronousSocketChannel, Void> {
    
@Override
public void completed(AsynchronousSocketChannel socket,
        Void attachment) {
        
    if (isRunning() && !isPaused()) {
        if (getMaxConnections() == -1) {
            //如果没有连接限制,继续接收新的连接
            serverSock.accept(null, this);
        } else {
            //如果有连接限制,就在线程池里跑run方法,run方法会检查连接数
            getExecutor().execute(this);
        }
        //处理请求
        if (!setSocketOptions(socket)) {
            closeSocket(socket);
        }
    } 
}

  可以看到 CompletionHandler 的两个模板参数分别是 AsynchronousServerSocketChannel 和 Void,我在前面说过第一个参数就是 accept 方法的返回值,第二个参数是附件类,由用户自己决定,这里为 Void。completed 方法的处理逻辑比较简单:

  • 如果没有连接限制,继续在本线程中调用 accept 方法接收新的连接。
  • 如果有连接限制,就在线程池里跑 run 方法去接收新的连接。那为什么要跑 run 方法呢,因为在 run 方法里会检查连接数,当连接达到最大数时,线程可能会被 LimitLatch 阻塞。为什么要放在线程池里跑呢?这是因为如果放在当前线程里执行,completed 方法可能被阻塞,会导致这个回调方法一直不返回。

  接着 completed 方法会调用 setSocketOptions 方法,在这个方法里,会创建 Nio2SocketWrapper 和 SocketProcessor,并交给线程池处理。

Nio2SocketWrapper

  Nio2SocketWrapper 的主要作用是封装 Channel,并提供接口给 Http11Processor 读写数据。讲到这里你是不是有个疑问:Http11Processor 是不能阻塞等待数据的,按照异步 I/O 的套路,Http11Processor 在调用 Nio2SocketWrapper 的 read 方法时需要注册回调类,read 调用会立即返回,问题是立即返回后 Http11Processor 还没有读到数据,怎么办呢?这个请求的处理不就失败了吗?

  为了解决这个问题,Http11Processor 是通过 2 次 read 调用来完成数据读取操作的:

  • 第一次 read 调用:连接刚刚建立好后,Acceptor 创建 SocketProcessor 任务类交给线程池去处理,Http11Processor 在处理请求的过程中,会调用 Nio2SocketWrapper 的 read 方法发出第一次读请求,同时注册了回调类 readCompletionHandler,因为数据没读到,Http11Processor 把当前的 Nio2SocketWrapper 标记为数据不完整。接着 SocketProcessor 线程被回收,Http11Processor 并没有阻塞等待数据。这里请注意,Http11Processor 维护了一个 Nio2SocketWrapper 列表,也就是维护了连接的状态。

  • 第二次 read 调用:当数据到达后,内核已经把数据拷贝到 Http11Processor 指定的 Buffer 里,同时回调类 readCompletionHandler 被调用,在这个回调处理方法里会重新创建一个新的 SocketProcessor 任务来继续处理这个连接,而这个新的 SocketProcessor 任务类持有原来那个 Nio2SocketWrapper,这一次 Http11Processor 可以通过 Nio2SocketWrapper 读取数据了,因为数据已经到了应用层的 Buffer。

  实际上,简单的来理解就是在这期间,连接被保留着,数据没就绪处理的线程资源先释放了。收到异步数据就绪通知后,根据原有的连接重建处理线程,继续处理,阻塞期间线程可复用。

  这个回调类 readCompletionHandler 的源码如下,最关键的一点是,Nio2SocketWrapper 是作为附件类来传递的,这样在回调函数里能拿到所有的上下文。

this.readCompletionHandler = new CompletionHandler<Integer, SocketWrapperBase<Nio2Channel>>() {
    public void completed(Integer nBytes, SocketWrapperBase<Nio2Channel> attachment) {
        ...
        //通过附件类SocketWrapper拿到所有的上下文
        Nio2SocketWrapper.this.getEndpoint().processSocket(attachment, SocketEvent.OPEN_READ, false);
    }

    public void failed(Throwable exc, SocketWrapperBase<Nio2Channel> attachment) {
        ...
    }
}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值