tomcat-连接器架构设计

发布于:2024-04-07 ⋅ 阅读:(165) ⋅ 点赞:(0)

一、NioEndpoint组件

Tomcat的NioEndPoint组件实现了I/O多路复用模型

在Tomcat中,EndPoint组件的主要工作就是处理I/O,而NioEndpoint利用Java NIO API实现了多路复用I/O 模型。其中关键的一点是,读写数据的线程自己不会阻塞在I/O等待上,而是把这个工作交给Selector。同时Tomcat在这个过程中运用到了很多Java并发编程技术,比如AQS、原子类、并发容器,线程池等,都值得 我们去细细品味。

接下来我会介绍NioEndpoint的实现原理。

1.NioEndpoint处理流程

我们知道,对于Java的多路复用器的使用,无非是两步:

1.创建一个Seletor,在它身上注册各种感兴趣的事件,然后调用select方法,等待感兴趣的事情发生。

2.感兴趣的事情发生了,比如可以读了,这时便创建一个新的线程从Channel中读数据。

Tomcat的NioEndpoint组件虽然实现比较复杂,但基本原理就是上面两步。

我们先来看看它有哪些组件,它 一共包含LimitLatch、Acceptor、Poller、SocketProcessor和Executor共5个组件,它们的工作过程如下图所 示。

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

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

3.Poller的本质是一个Selector,也跑在单独线程里。

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

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

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

1.LimitLatch计数器

LimitLatch用来控制连接个数,当连接数到达最大时阻塞线程,直到后续组件处理完一个连接后将连接数减 1。

请你注意到达最大连接数后操作系统底层还是会接收客户端连接,但用户层已经不再接收。LimitLatch 的核心代码如下:

从上面的代码我们看到,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的 releaseShared()方法,其实就是当一个连接请求处理完了,这时又可以接收一个新连接了,这样排在队列前面阻塞的线程将会被唤醒。

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

所以我自己认为LimitLatch借助了AQS骨架,维护了一个先进先出的阻塞队列唤醒机制

2.Acceptor接收器

Acceptor是Endpoint的一个内部类,实现了Runnable接口,因此可以跑在单独线程里。

它为什么是内部类 呢?

因为Acceptor是Endpoint的实现细节,外部组件无需知道。

一个端口号只能对应一个 ServerSocketChannel,因此这个ServerSocketChannel是在多个Acceptor线程之间共享的,它是Endpoint的 属性,由Endpoint完成初始化和端口绑定。

初始化过程如下

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

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

2.ServerSocketChannel被设置成阻塞模式,也就是说它是以阻塞的方式接收连接的。

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

3.Poller选择器

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

private final SynchronizedQueue 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。 

4.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的方法去读写数据。

5.Executor线程池

Executor是Tomcat定制版的线程池,它负责创建真正干活的工作线程,干什么活呢?

就是执行 SocketProcessor的run方法,也就是解析请求并通过容器来处理请求,最终会调用到我们的Servlet。

后面我 会用专门的篇幅介绍Tomcat怎么扩展和使用Java原生的线程池。

2.NioEndpoint线程模型

在弄清楚NioEndpoint的实现原理后,我们来考虑一个重要的问题,怎么把这个过程做到高并发呢?

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

我们注意到NioEndpoint要 完成三件事情:接收连接、检测I/O事件以及处理请求,那么最核心的就是把这三件事情分开,用不同规模 的线程去处理,比如用专门的线程组去跑Acceptor,并且Acceptor的个数可以配置;用专门的线程组去跑 Poller,Poller的个数也可以配置;最后具体任务的执行也由专门的线程池来处理,也可以配置线程池的大 小


网站公告

今日签到

点亮在社区的每一天
去签到