Tomcat 是如何实现同步阻塞式IO模型的

1,294 阅读10分钟

大家好,我是小黑。之前两篇水文分享了同步阻塞式IO模型,今天我们缓一缓,具体看看 Tomcat,是如何实现同步阻塞式IO模型的。同步阻塞式IO模型-多线程版本同步阻塞式IO模型-单线程版本

基础知识

Tomcat 把模板方法、组合模式、责任链模式发挥到了极致,确实更好维护,但是对我们学习源码的人来说是一个不小的障碍。代码一层套一层的,很难弄清楚整个链路。我们先补充点基础知识,再往下面冲。

整个Tomcat可以分为两个主要模块,一个是连接器(Connector),一个是容器(Container)。其中,连接器负责监听客户端请求、返回响应数据,容器负责具体的请求处理。

image.png

具体来讲,连接器负责处理网络请求。Tomcat 采用组件分离的思想设计了多个主要的组件。

  • 连接器,管理连接
  • ProtocolHandler 协议处理器,用于支持不同的协议和IO模型
  • EndPoint 负责监听 socket 链接,并发起网络请求的处理过程
  • Processor 则负责解析协议
  • Adapter 作为连接器和容器之间的适配器,连接器会通过 Adatper 调用容器

image.png

而容器是负责具体请求处理的组件。Tomca 中有四种类型的容器:Engine,Host,Context,Wrapper。他们都实现了 Container 接口。他们之间有父子关系(通过组合实现的,而非继承),父容器通过一种叫做管道的机制(通过责任链模式实现的)调用子容器。

  • Engine:表示整个 Catalina servlet 引擎
  • Host:表示一个虚拟主机,一个 Host 可以包含多个 Context 容器
  • Context:表示一个web应用,一个 Context 可以包含多个 Wrapper
  • Wrapper:表示一个 Servlet,会通过加载器加载 Servlet,并调用Servlet中相应的 service() 方法

image.png 想更深入了解的可以看网上的这篇文章:Tomcat 架构原理解析到架构设计借鉴

老版本Tomcat是如何实现同步阻塞式IO模型的

老版本的 Tomcat 连接器和文章开头介绍的不太一样,更加简化。只有一个 HttpConnector,一个 HttpProcessor。先来看看简单的老版本 Tomcat 是如何实现同步阻塞式IO模型的。

HttpConnector

我们先看看HttpConnector,HttpConnector是网络连接的入口,Tomcat启动时就会启动。

  • HttpConnector.run()

HttpConnector是建立连接的入口,本身也是一个实现了 Runnable 接口的类,核心逻辑在 run() 方法中。

run() 方法整体分为三个大步骤:

  1. accept() 方法接受客户端连接
  2. 从对象池中取出一个processor对象,创建的时候会新起一个线程来执行。processor对象会负责处理客户端请求。
  3. 调用processor对象的 assign() 方法,开始准备处理客户端连接。assign() 方法很特别,是一套很巧妙的线程同步机制实现的。下面讲 HttpProcessor 的时候会细讲。
	public void run() {
        // 一直循环,直到服务器停止
        while (!stopped) {
            Socket socket = null;
            try {
                socket = serverSocket.accept(); // 接受客户端连接
            } catch (AccessControlException ace) {
                log("socket accept security exception", ace);
                continue;
            } catch (IOException e) {
                // 各种异常处理
            }

            // processor 本身是一个实现了 Runnable 的接口的类,用来处理连接中的数据。
            // HttpConnector 用 Stack 维护了一个 processor 对象池,createProcessor()
            // 方法就是从对象池中取出一个可用的 processor,用于处理客户端连接。
            HttpProcessor processor = createProcessor();
            
            // 调用processor对象的 assign() 方法,开始准备处理客户端连接
            processor.assign(socket);
        }
    }

HttpProcessor

我们再来看看 HttpProcessor。

HttpProcessor 本身是一个实现了 Runnable 接口的类,在接收到客户端连接时,就会创建一个 HttpProcessor,用于处理请求。 在创建 HttpProcessor 的时候新起一个线程来执行他的 HttpProcessor.run() 方法。处理逻辑封装在 HttpProcessor.process() 方法中。

  • HttpProcessor.run()

初始状态下,HttpProcessor 没有分配到 socket,所以会一直阻塞在_** await()**_ 方法上。

    public void run() {

        while (!stopped) { // 一直循环,直到服务器停止

            // 一直阻塞,直到有一个socket被分配(assign)给了processor
            Socket socket = await();
            if (socket == null)
                continue;

            try {
                // 处理来自这个套接字的请求,分为四步:1.读取数据;
                // 2.解析HTTP请求;3.调用底层容器;4.返回响应
                process(socket);
            } catch (Throwable t) {
                log("process.invoke", t);
            }

            connector.recycle(this); // 把 processor 归还到对象池中

        }
    }
  • HttpProcessor.await()

available 是 HttpProcessor 对象中的一个实例,初始值是 false。

分配了socket,available才会被置为true(通过 HttpProcessor.assign() 方法实现的)。所以没分配的话,await() 会阻塞在 wait() 方法上。

    private synchronized Socket await() {

        // available初始是false,所以会进入这个循环代码块并阻塞住
        while (!available) {
            try {
                wait();
            } catch (InterruptedException e) {
            }
        }

        Socket socket = this.socket;
        available = false;
        notifyAll(); // 唤醒processor线程
        
        return (socket);
    }
  • HttpProcessor.assign()

在服务器与客户端建立连接,并接收到socket的时候,HttpConnector会调用 HttpProcessor.assign() 方法,将socket分配给processor,并唤醒processor线程进行处理。

 synchronized void assign(Socket socket) {

        // available初始是false,所以assign的时候,不会进入这个循环代码块,不会被阻塞住
        while (available) {
            try {
                wait();
            } catch (InterruptedException e) {
            }
        }

        this.socket = socket; // 分配socket
        available = true;
        notifyAll(); // 唤醒processor线程
    }

总结一下,黄色部分是 HttpConnector 的主要流程,绿色部门是 HttpProcessor 的主要流程。

HttpConnector 接收客户端连接,从对象池里面取出 HttpProcessor 对象,并把 socket 分配给 HttpProcessor,然后就 accept() 下一个连接,不阻塞在处理线程上,是典型的多线程版本的同步阻塞式IO模型。

image.png

老版本Tomcat实现的同步阻塞式IO模型有哪些缺点

来挑挑刺,我们看看老版本Tomcat实现的同步阻塞式IO模型有哪些缺点。老版本 Tomcat 是基于 Stack 实现的对象池,来实现多线程同步阻塞式IO模型,主要的缺点就是这个线程池实现方式过于简陋了

  • 老版本的线程池对于空闲线程不会自动销毁

processor 被创建后,就不会被销毁了,这批线程的存在本身就会占用系统资源。比如某一个时间点请求数飙升,导致新建了一大批 processor 线程,这批线程就会一直存在直到服务器被关闭。这对系统资源是一种浪费。

  • 老版本的线程池不支持缓冲队列

线程池内的线程如果都是忙碌状态并且线程池线程数量达到上限,就会忽略接下来的请求。

我们来细看下 createProcessor() 方法。他负责从 processor 对象池中,取出一个可用的 processor,如果没有可用的 processor,就调用 newProcessor() 构造一个。

    private HttpProcessor createProcessor() {

        synchronized (processors) {
            if (processors.size() > 0) { // 对象池空闲,直接从对象池里面取
                return ((HttpProcessor) processors.pop());
            }
            // 对象池没有空闲的processor了,要看看是否能新建一个 HttpProcessor 对象
            if ((maxProcessors > 0) && (curProcessors < maxProcessors)) { // 指定了 maxProcessors,并且当前 processor数小于指定的 maxProcessors,新建一个Processor
                return (newProcessor());
            } else {
                if (maxProcessors < 0) { // 未指定 maxProcessors,无脑新建一个Processor
                    return (newProcessor());
                } else { // 指定了 maxProcessors,直接返回null,效果是忽略这个请求
                    return (null);
                }
            }
        }
    }

留个小 Tips:这里的 Processor 对象池是使用 Stack 实现的。为什么用 Stack 而不用更常见的 ArrayList?或者 Vector 可以吗? 另外,Tomcat 4为什么不用 Java 原生的线程池,而用这么简陋的方式实现? 欢迎评论区一起沟通~

新版本Tomcat是如何实现同步阻塞式IO模型的

连接器总览

新版本Tomcat有了特别大的改版,涉及到的组件如下:

  • Connector:连接器
  • ProtocolHandler:协议处理器,Tomcat支持Ajp、HTTP协议,在加上各种IO模型的底层实现,Tomcat提供了各种实现:AjpAprProtocol,AjpNioProtocol,AjpProtocol,Http11AprProtocol,Http11NioProtocol,Http11Protocol。其中Http11Protocol是我们这次讨论的,HTTP同步阻塞协议处理器。
  • EndPoint:IO模型的具体实现,Tomcat提供了:AprEndpoint、JIoEndpoint、NioEndpoint。其中JIoEndpoint是我们这次讨论的同步阻塞 Endpoint。
  • Acceptor:acceptor 线程,自身是一个实现了 Runnable 接口的类,内部会调用 serverSocket.accept() 方法
  • 各种Processor:主要就是用来处理网络请求的,比如解析HTTP协议、http请求升级成websocket等等,都是Processor来处理的。
  • Adapter:作为连接器和容器之间的适配器,连接器会通过 Adatper 调用容器

各组件的协作就就是文章开头贴出来的图,这里再贴一下:

image.png

连接器的启动

稍等片刻,我们先看看连接器的启动流程,看看启动流程涉及到哪些组件。

image.png

  • 连接器启动的入口是 Connector.startInternal() 方法,方法内部会依次调用 ProtocolHandler 组件的 start() 方法,JIoEndpoint的 start() 方法。
  • JIoEndpoint的 start() 方法内部主要干两件事,

一是调用 bind() 方法:构造 serverSocket 对象,指定服务端端口; 二是调用 startInternal() 方法:启动 acceptor 线程,acceptor 线程内部会调用 ServerSocket.accept() 方法

  • AbstractEndpoint.start() 源码
    public final void start() throws Exception {
        if (bindState == BindState.UNBOUND) {
            bind(); // 调用JIoEndpoint的 bind() 方法,绑定服务端端口
            bindState = BindState.BOUND_ON_START;
        }
        startInternal(); // 调用JIoEndpoint的 startInternal()方法,启动 acceptor 线程
    }
  • JIoEndpoint.startInternal() 源码
    @Override
    public void startInternal() throws Exception {

        if (!running) {
            running = true;
            paused = false;

            // Create worker collection
            if (getExecutor() == null) {
                createExecutor();
            }

            initializeConnectionLatch();

            startAcceptorThreads(); // 启动acceptor线程

            // Start async timeout thread
            Thread timeoutThread = new Thread(new AsyncTimeout(),
                    getName() + "-AsyncTimeout");
            timeoutThread.setPriority(threadPriority);
            timeoutThread.setDaemon(true);
            timeoutThread.start();
        }
    }

    protected final void startAcceptorThreads() {
        int count = getAcceptorThreadCount();
        acceptors = new Acceptor[count];

        for (int i = 0; i < count; i++) {
            acceptors[i] = createAcceptor(); // 创建 acceptor 对象
            String threadName = getName() + "-Acceptor-" + i;
            acceptors[i].setThreadName(threadName);
            Thread t = new Thread(acceptors[i], threadName);
            t.setPriority(getAcceptorThreadPriority());
            t.setDaemon(getDaemon());
            t.start(); // 启动 acceptor 线程
        }
    }

连接器启动的本质,是启动Acceptor线程,接下来我们看看Acceptor是怎么干活的吧。

处理请求的流程

Acceptor线程启动后,主要就是Acceptor干活了。

他的流程就是 _**accept() **_返回后,构造一个SocketProcess 对象,扔到线程池中让线程池执行,执行过程中会涉及到多种 Processor。最终,Http11Processor 会解析协议,通过 CoyoteAdapter 调用容器。 image.png 具体的代码流程如下图: image.png

  • Acceptor 线程的 accept() 方法返回后,会调用 processSocket() 方法。processSocket() 方法会构造一个 SocketProcessor 对象,并扔到线程池中,然后返回
  protected boolean processSocket(Socket socket) {
       // 构造一个 Socket 包装类
       SocketWrapper<Socket> wrapper = new SocketWrapper<Socket>(socket);
       wrapper.setKeepAliveLeft(getMaxKeepAliveRequests());
       wrapper.setSecure(isSSLEnabled());
	   // 构造一个 SocketProcessor () 对象,扔到线程池中然后返回
       getExecutor().execute(new SocketProcessor(wrapper));
       return true;
    }
  • 再然后就是一层调一层,第六步调用AbstractHttp11Processor的 process() 方法,该方法内部会解析http请求,并调用 CoyoteAdapter 的 service() 方法,
// CoyoteAdapter的service()方法内部,调用底层容器的代码
connector.getService().getContainer()
    .getPipeline().getFirst().invoke(request, response);

我们可以从Tomcat的实现中学习到什么

  • 各种设计模式应用:享元模式、模板模式、组合模式、工厂模式等等

Tomcat 的设计感觉把设计模式用到了极致,通过Tomcat源码的学习,我们加深对设计模式的理解。

  • Tomcat 对线程池的优化

考虑到篇幅,这块先留个坑,以后新开一篇专门谈谈。

这里只简单谈谈 Tomcat 为什么不用 JDK 自带的线程池:SingleThreadExecutor、FixedThreadPool、CachedThreadPool。

首先Tomcat是web服务器,天生就要应对多线程的场景,所以 SingleThreadExecutor 排除。

其次,FixedThreadPool用的固定线程数,阻塞队列用的是 LinkedBlockingQueue,如果请求量激增,线程处理不过来,任务会堆积到 LinkedBlockingQueue,造成 OOM。同样 CachedThreadPool 也有这个问题,如果请求量激增,会不断的去开新的线程,造成机器负载飙高甚至宕机。

最后,JDK 原生线程池是在阻塞队列满了才会去新开线程,直到达到最大线程数。这种策略比较适合处理 CPU 密集型任务,因为线程已经不够用了,无法开新的任务,还要等到阻塞队列满了才会新开线程,这不耽误事吗。而 Tomcat 连接器是典型的 IO 密集型任务,这并不利于 Tomcat 的性能,Tomcat 也针对这块做了改造。

综上所述,所以 Tomcat 没有使用 JDK 自带的线程池。

小Tips:本人几年前校招面试时就被问了这个问题:JDK 原生线程池的线程数达到核心线程数后,后续的任务为什么是先放到阻塞队列里面,而不是继续新开线程直到达到最大线程数?现在知道该怎么回答了吧 ~

Ref

  • 极客时间:《深入拆解Tomcat&Jetty》
  • 孙卫琴:《Java网络编程精解》
  • Budi Kurniawan、Paul Deck:《深入剖析Tomcat》
  • 葛一鸣:《实战Java高并发程序设计》