Tomcat 如何实现非阻塞 I/O —— NioEndpoint 组件

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

  UNIX 系统下的 I/O 模型有五种:同步阻塞模式、同步非阻塞模式、I/O 多路复用模型、信号驱动 I/O 模型和异步 I/O 模型。在了解这些模型都有什么区别时,应该问自己一个终极问题:什么是 I/O?为什么需要这些 I/O 模型?

  所谓 I/O 就是计算机内存鱼外部设备之间拷贝数据的过程,外部设备指的则是除了 CPU 和内存以外的设备,比如硬盘。 我们知道 CPU 访问内存的速度是远远高于外部设备的,因此 CPU 一般是把外部设备加载到内存中,然后再进行处理的。

  你现在想象一个场景,当你的程序通过 CPU 向外部设备发出一个读指令的时候,数据从外部拷贝到内存中往往需要一定的时间,这个时候 CPU 没事情做了,你的程序是主动把 CPU 让给别人,还是让 CPU 不停的查:拿到数据了吗?拿到数据了吗…这就是 I/O 模型要解决的问题。

Java I/O 模型

  对于一个 I/O 通信过程,比如网络数据读取,会涉及到两个对象,一个是调用整个 I/O 操作的用户线程,另外一个就是操作系统内核。一个进程的地址空间分为用户空间和内核空间,用户线程不能直接访问内核空间。

  当用户线程池发起 I/O 操作之后,网络数据读取操作会经历两个步骤:

  • 用户线程等待内核将数据从网卡拷贝到内核空间
  • 内核将数据从内核空间拷贝到用户空间

  各种 I/O 模型的区别就是实现这两步的步骤不一样。

  1. 同步阻塞 I/O 模型: 用户发起 read 调用之后就阻塞,让出 CPU。内核等待网卡数据的到来,把数据从网卡拷贝到内核空间,接着把数据从内核空间拷贝到用户空间,之后再唤醒用户线程。

在这里插入图片描述

  1. 同步非阻塞 I/O 模型: 用户线程不断发起 read 调用,数据没到内核空间的时候,就一直返回失败,当数据到达内核空间时,这一次的 read 调用后,在等待数据从内核空间拷贝到用户空间这段时间里,线程还是阻塞的,等数据到了用户空间再把线程叫醒。

在这里插入图片描述

  1. I/O 多路复用模型: 用户线程的读取操作分为了两步,线程先发起 select 调用,目的是问内核数据准备好了吗,等内核数据准备好了,再发起 read 调用。在等待数据从内核空间到用户空间的时候,这段时间还是阻塞的,你可能会觉得这不是跟上面一样吗,为什么叫多路复用?这是因为 select 调用可以向内核查看多个数据通用(channel)的状态,所以叫这个。

在这里插入图片描述

  1. 异步 I/O 模型: 用户线程发起 read 调用之后就注册一个回调函数,之后方法就返回。等待内核将数据准备好之后,再调用指定的回调函数处理。整个过程,用户线程没有阻塞

在这里插入图片描述

NioEndpoint 组件

  Tomcat 的 NioEndPoint 组件实现了 I/O 多路复用模型,接下来我会介绍 NioEndPoint 的实现原理。

总体工作流程

  对于 Java 多路复用模型,无非两步:

  • 构建一个 selector,在它身上注册各种感兴趣的事件,然后调用 select 方法,等待感兴趣的事件发生
  • 感兴趣的事件发生,这时候注册新的线程从 Channel 中读取数据。

  Tomcat 的 NioEndPoint 组件虽然比较复杂,但基本就是上面这两部。我们先来看看它有哪些组件,它一共包含 LimitLatch、Acceptor、Poller、SocketProcessor 和 Executor 共 5 个组件,它们的工作过程如下图所示:

在这里插入图片描述

  LimitLatch 是连接控制器,它负责控制最大连接数,NIO 模式下的连接数最大是 10000,达到这个阈值之后,连接请求会被拒绝。

  Acceptor 跑在一个独立的线程里,它在一个死循环里内调用 accept 方法来接受新连接,一旦有新连接请求的到来,accpet 方法返回一个 Channel 对象,接着把 Channel 对象交给 Poller 去处理。

  Poller 本质上一个 selector,也跑在单独的线程里, Poller 在内部维护一个 channel 数组,它在一个死循环里不断检测 Channel 的数据就绪状态,一旦有 Channel 可读,就生成一个 SocketProcessor 任务对象扔给 Executor 去处理。

  Executor 就是线程池,负责运行 SocketProcessor 任务类,SocketProcessor 的 run 方法会调用 Http11Processor 来读取和解析请求数据。我们知道,Http11Processor 是应用层协议的封装,它会调用容器获得响应,再把响应通过 Channel 写出。

  接下来再看看各个组件的设计特点:

LimitLatch

  上面提到这是一个连接控制器,当连接数达到最大阻塞数量时,直到后续组件处理完一个连接后将连接数减 1。另外注意,达到最大连接数后操作系统底层还是会接受用户请求,但是用户层已经不再接受,它的核心代码如下:

public class LimitLatch {
    private class Sync extends AbstractQueuedSynchronizer {
     
        @Override
        protected int tryAcquireShared() {
            long newCount = count.incrementAndGet();
            if (newCount > limit) {
                count.decrementAndGet();
                return -1;
            } else {
                return 1;
            }
        }

        @Override
        protected boolean tryReleaseShared(int arg) {
            count.decrementAndGet();
            return true;
        }
    }

    private final Sync sync;
    private final AtomicLong count;
    private volatile long limit;
    
    //线程调用这个方法来获得接收新连接的许可,线程可能被阻塞
    public void countUpOrAwait() throws InterruptedException {
      sync.acquireSharedInterruptibly(1);
    }

    //调用这个方法来释放一个连接许可,那么前面阻塞的线程可能被唤醒
    public long countDown() {
      sync.releaseShared(0);
      long result = getCount();
      return result;
   }
}

  从上面的代码我们看到,LimitLatch 内步定义了内部类 Sync,而 Sync 扩展了 AQS,AQS 是 Java 并发包中的一个核心类,它在内部维护一个状态和一个线程队列,可以用来控制线程什么时候挂起,什么时候唤醒。我们可以扩展它来实现自己的同步器,实际上 Java 并发包里的锁和条件变量等等都是通过 AQS 来实现的,而这里的 LimitLatch 也不例外。

  理解上面的代码时有两个要点:

  1. 用户线程通过调用 LimitLatch 的 countUpOrAwait 方法来拿到锁,如果暂时无法获取,这个线程会被阻塞到 AQS 的队列中。那 AQS 怎么知道是阻塞还是不阻塞用户线程呢?其实这是由 AQS 的使用者来决定的,也就是内部类 Sync 来决定的,因为 Sync 类重写了 AQS 的 tryAcquireShared() 方法。它的实现逻辑是如果当前连接数 count 小于 limit,线程能获取锁,返回 1,否则返回 -1
  2. 如何用户线程被阻塞到了 AQS 的队列,那什么时候唤醒呢?同样是由 Sync 内部类决定,Sync 重写了 AQS 的 tryReleaseShared() 方法,其实就是当一个连接请求处理完了,这时又可以接收一个新连接了,这样前面阻塞的线程将会被唤醒

  其实你会发现 AQS 就是一个骨架抽象类,它帮我们搭了个架子,用来控制线程的阻塞和唤醒。具体什么时候阻塞、什么时候唤醒由你来决定。我们还注意到,当前线程数被定义成原子变量 AtomicLong,而 limit 变量用 volatile 关键字来修饰,这些并发编程的实际运用。

Acceptor

  Acceptor 实现了 Runnable 接口,因此可以跑在单独线程里。一个端口号只能对应一个 ServerSocketChannel,因此这个 ServerSocketChannel 是在多个 Acceptor 线程之间共享的,它是 Endpoint 的属性,由 Endpoint 完成初始化和端口绑定。初始化过程如下:

serverSock = ServerSocketChannel.open();
serverSock.socket().bind(addr,getAcceptCount());
serverSock.configureBlocking(true);

  从上面的初始化代码我们可以看到两个关键信息:

  1. bind 方法的第二个参数表示操作系统的等待队列长度,我在上面提到,当应用层面的连接数到达最大值时,操作系统可以继续接收连接,那么操作系统能继续接收的最大连接数就是这个队列长度,可以通过 acceptCount 参数配置,默认是 100。
  2. ServerSocketChannel 被设置成阻塞模式,也就是说它是以阻塞的方式接收连接的。

  ServerSocketChannel 通过 accept() 接受新的连接,accept() 方法返回获得 SocketChannel 对象,然后将 SocketChannel 对象封装在一个 PollerEvent 对象中,并将 PollerEvent 对象压入 Poller 的 Queue 里,这是个典型的“生产者 - 消费者”模式,Acceptor 与 Poller 线程之间通过 Queue 通信。

Poller

  本质上是一个 Selector,它内部维护了一个 Queue,定义如下:

private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();

  SynchronizedQueue 的方法比如 offer、poll、size 和 clear 方法,都使用了 synchronized 关键字进行修饰,用来保证同一时刻只有一个 Acceptor 线程对 Queue 进行读写。同时有多个 Poller 线程在运行,每个 Poller 线程都有自己的 Queue。每个 Poller 线程可能同时被多个 Acceptor 线程调用来注册 PollerEvent。同样 Poller 的个数可以通过 pollers 参数配置。

  Poller 不断的通过内部的 Selector 对象向内核查询 Channel 的状态,一旦可读就生成任务类 SocketProcessor 交给 Executor 去处理。Poller 的另一个重要任务是循环遍历检查自己所管理的 SocketChannel 是否已经超时,如果有超时就关闭这个 SocketChannel。

SocketProcessor

  我们知道,Poller 会创建 SocketProcessor 任务类交给线程池处理,而 SocketProcessor 实现了 Runnable 接口,用来定义 Executor 中线程所执行的任务,主要就是调用 Http11Processor 组件来处理请求。Http11Processor 读取 Channel 的数据来生成 ServletRequest 对象,这里请你注意:

  Http11Processor 并不是直接读取 Channel 的。这是因为 Tomcat 支持同步非阻塞 I/O 模型和异步 I/O 模型,在 Java API 中,相应的 Channel 类也是不一样的,比如有 AsynchronousSocketChannel 和 SocketChannel,为了对 Http11Processor 屏蔽这些差异,Tomcat 设计了一个包装类叫作 SocketWrapper,Http11Processor 只调用 SocketWrapper 的方法去读写数据。

Executor

  Tomcat 定制的线程池,它负责创建真正干活的工作线程,干什么活呢?就是执行 SocketProcessor 的 run 方法,也就是解析请求并通过容器来处理请求。

高并发思路

  高并发就是能快速地处理大量的请求,需要合理设计线程模型让 CPU 忙起来,尽量不要让线程阻塞,因为一阻塞,CPU 就闲下来了。另外就是有多少任务,就用相应规模的线程数去处理。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值