【SpringBoot + Tomcat】【二】请求到达后端服务进程后的处理过程-连接的处理细节
1 前言
上节的后半部分,由于忙项目的事情去了,收尾的有点潦草,我们这节再继续。
上节我们的思路是先简单回顾了下,SpringBoot 启动和创建 Tomcat 的时机,然后我们还主要看了下 连接器 Connector 的创建已经启动过程。连接器本身很重要,因为它就像一个港口或者一个枢纽,连接着客户端和服务端,接收客户端的请求,然后进行信息打包交给服务端进行处理,服务端处理完,枢纽将数据写回客户端。
中间涉及到的一些关键类或者接口:Endpoint、Connector、Acceptor等,我们这节再加强一下认识。
2 剖析
我们从哪开始呢?我们思路的角度是什么呢?我这里是从线程的角度去理解的哈,就从 NioEndpoint 的 start() 方法回顾起,因为它的启动就标志着三类线程的启动,也标志着后端服务的大门正式开启了(可以处理请求了):
从这里我们可以看到三类线程池,大致看到一个请求的处理:
首先是 acceptor 线程,这个线程主要监听端口。当有请求过来的时候,完成 tcp 三次握手,将 accept 过来的 socket 注册OP_REGISTER 事件,并将该事件提交到 Poller 线程的事件队列 PollerEventQueue中 。
然后是 poller 线程,每一个 poller 实例都有一个 NIO selector 实例,同时也有一个事件队列SynchronizedQueue<PollerEvent>,轮询队列 SynchronizedQueue<PollerEvent>,该队列的事件由 acceptor 线程(监听到新连接时)或者 tomcat io 线程(处理完请求之后保持长连接,添加读事件)放入,然后根据不同的事件对原始socket注册相应的读写事件。每一个poller thread 来说,会调用 java NIO 对象 selector,发起系统调用,来监测原始 scoket 是否有读写事件发生,如果有则将原始 scoket 的封装对象交由 tomcat io 线程处理。
最后是我们的 Tomcat 线程池,接收 poller thread 传入的 scoket 封装对象后,依次调用 SocketProcessor/ConnectionHanlder(global instance)/Http11Processor/CoyoteAdapter,最后交由 servlet container 完成servlet API 的调用。
我们接下来,看几个小细节。
2.1 创建连接池 createExecutor
先来看下连接池的创建:
public void createExecutor() { internalExecutor = true; // 线程池的队列 TaskQueue taskqueue = new TaskQueue(); // 线程工厂 主要是标记线程名称的 TaskThreadFactory tf = new TaskThreadFactory(getName() + "-exec-", daemon, getThreadPriority()); // 线程池参数 minSpare默认是10 核心线程数 maxThreads 最大线程数默认是 200 executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), 60, TimeUnit.SECONDS,taskqueue, tf); taskqueue.setParent( (ThreadPoolExecutor) executor); }
注意这里的 ThreadPoolExecutor 它不是我们 juc包里的那个,这里是 Tomcat 的自己命名了一个,并且是继承了 juc 里边的那个:
// org.apache.tomcat.util.threads.ThreadPoolExecutor public class ThreadPoolExecutor extends java.util.concurrent.ThreadPoolExecutor {...}
那么问题来了,区别是什么呢?我们看看:
(1)第一个区别是创建的时候,Tomcat 有个预创建的操作:
/** * Starts all core threads, causing them to idly wait for work. This * overrides the default policy of starting core threads only when * new tasks are executed. * * @return the number of threads started */ public int prestartAllCoreThreads() { int n = 0; while (addWorker(null, true)) ++n; return n; }
可以看到它会预先把核心线程数的线程都先创建出来,这个大家应该理解吧,提前创建出来,这样有任务了的话,就可以快速执行了嘛。
(2)加了一个计数器的 submittedCount
会实时统计已经提交到线程池中,但还没有执行结束的任务。也就是说 submittedCount
等于线程池队列中的任务数加上线程池工作线程正在执行的任务。
提交任务的时候 ++:
public void execute(Runnable command, long timeout, TimeUnit unit) { submittedCount.incrementAndGet(); ... }
任务执行完的 --:
@Override protected void afterExecute(Runnable r, Throwable t) { ... if (!(t instanceof StopPooledThreadException)) { submittedCount.decrementAndGet(); } }
(3)原生线程池拒绝策略后的重试
public void execute(Runnable command, long timeout, TimeUnit unit) { submittedCount.incrementAndGet(); try { super.execute(command); // 捕获原生拒绝策略抛出的异常 } catch (RejectedExecutionException rx) { if (super.getQueue() instanceof TaskQueue) { final TaskQueue queue = (TaskQueue)super.getQueue(); try { // 再次放入队列,尽可能的执行 if (!queue.force(command, timeout, unit)) { // 若还是失败 就抛异常了 submittedCount.decrementAndGet(); throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull")); } } catch (InterruptedException x) { submittedCount.decrementAndGet(); throw new RejectedExecutionException(x); } } else { submittedCount.decrementAndGet(); throw rx; } } }
(4)第四个很重要的区别可能跟线程池本身没多大关系,而是跟队列有关系,就是这个 TaskQueue,它本身是继承 LinkedBlokingQueue.。
线程池提交任务的执行过程,不知道大家还记得不,我搜了一个图,大家看一下,就是当核心线程数满了后,会往队列里放 offer(不阻塞):
public void execute(Runnable command) { if (command == null) throw new NullPointerException(); int c = ctl.get(); if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 核心线程数满了,走到这里 先执行 offer if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); if (! isRunning(recheck) && remove(command)) reject(command); else if (workerCountOf(recheck) == 0) addWorker(null, false); } else if (!addWorker(command, false)) reject(command); }
而我们这里的队列 TaskQueue,我们看一下它的 offer 方法:
@Override public boolean offer(Runnable o) { //we can't do any checks 这个不为空 因为放的就是线程池对象 if (parent==null) return super.offer(o); //we are maxed out on threads, simply queue the object 当前线程数是否到达最大线程数 如果到了,那就只能放队列了 if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o); //we have idle threads, just add it to the queue 提交的任务数小于线程数 说明有空闲的线程 可以放进队列 就能直接跑任务了 if (parent.getSubmittedCount()<=(parent.getPoolSize())) return super.offer(o); //if we have less threads than maximum force creation of a new thread 当前线程数还没到最大线程数 直接返回 false if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false; //if we reached here, we need to add it to the queue return super.offer(o); }
核心就在第三、四步,提交的任务数小于线程数直接放队列就能立马得到执行,到第四步,如果还没到最大线程数,它直接返回 false 再结合上边的线程池任务执行,他会走到最后的 addWorker 也就是创建新线程去执行了。所以可以看到它的执行方式跟传统的线程池的差异就是:tomcat 优先创建线程去执行任务了,而传统的会优先放队列哈。至于为什么要这么做?可能 tomcat 就是想快速的优先处理请求,将任务下发下去吧,有点类似 io 密集型的,线程数量多点,但是大多可能都在等待中,不会导致 cpu一直飙。
好了,这是线程池的一些细节。
2.2 请求计数器 initializeConnectionLatch
我们再看一个请求计数器,acceptor 线程用于接收客户端请求,那我总不能接上百万 上千万的吧,那我服务端岂不是要累死,是不是?
private int maxConnections = 8*1024;
protected LimitLatch initializeConnectionLatch() { if (maxConnections==-1) return null; if (connectionLimitLatch==null) { connectionLimitLatch = new LimitLatch(getMaxConnections()); } return connectionLimitLatch; }
默认的连接数最大为 8*1024,内部其实一个 Sync(共享锁方式实现) 、count 计数器、limit 最大限制
private final Sync sync; private final AtomicLong count; private volatile long limit;
再来看看执行:
所以说 tomcat 任务的线程池的并发量还要取决这个连接的配置哈。
3 小结
本节就看到这里哈,下节我们继续。