尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

手写Tomcat线程池改造:从每请求一线程到高并发可控

手写Tomcat线程池改造:从每请求一线程到高并发可控 手写Tomcat绕不开的坎就是线程池的引入。我自己写简易Servlet容器的时候最开始根本没想这么深一个ServerSocket循环accept()之后直接new Thread处理逻辑上特别通顺压测到二三十并发也感觉还不错。直到有一次联调环境里并发冲到几百线程数不像话地往上涨CPU跟着报警我才认真去研究线程池到底该怎么引入。这篇文章不聊花哨的架构只讲两件事一手写Tomcat为什么要引入线程池不引入会怎样二线程池真正需要理解的核心知识点尤其是一些官方文档里容易模糊的细节。适合正在撸手写Web服务器、或者对Tomcat线程池原理好奇的同学。1. 从BIO到线程池手写Tomcat最初是怎么被压垮的1.1 最初一版一句话的每请求一线程几乎所有手写Tomcat的起步版本都长一个样main方法里起一个ServerSocket循环等待连接拿到Socket之后直接开个新线程去处理。代码大概是这样public class SimpleTomcat { private ServerSocket serverSocket; private volatile boolean running true; public void start(int port) throws IOException { serverSocket new ServerSocket(port); while (running) { Socket socket serverSocket.accept(); new Thread(() - handleRequest(socket)).start(); } } }这个版本的可读性很好符合直觉每个来访的连接都有一个专属线程互相不干扰。问题在于每个连接一个线程这句话在很多场景下会变成每个连接一个重量级对象。线程本身的开销不是零。默认栈大小通常1MB左右就算现代操作系统的虚拟内存不值钱创建和销毁线程时涉及的系统调用、线程上下文切换、缓存失效都会实实在在地吃掉CPU。更麻烦的是一旦并发连接数变多线程数就会线性增长然后整个进程的资源都被线程管理本身占据真正做业务的CPU时间反而变少了。这个阶段你会观察到的典型现象是用JMeter或wrk压到三四百并发时响应时间开始剧烈抖动FGC频次增加甚至出现java.lang.OutOfMemoryError: unable to create new native thread。线程数失控是手写容器最常见的死法。1.2 并发一上来问题就藏不住了为什么线程池能解决每请求一线程的问题表面上看是资源复用深一点看是三个能力限制并发执行的任务数不是所有请求来了就立刻跑而是由线程池决定当前最大同时处理多少请求把多余请求放到队列里排队。复用线程对象线程执行完一个任务后不销毁继续执行队列里下一个任务省掉反复创建/销毁的开销。提供可观测的管理手段线程池可以查询当前线程数、活动线程数、队列积压量、已完成任务数。这一条在手写Tomcat里特别重要因为一旦容器出了问题至少能知道是线程不够还是队列堵死。这里要澄清一个容易误解的点线程池并不是让单个请求处理得更快。恰恰相反如果在池线程之外还有富余的CPU每请求一线程的极限吞吐可能还更高。线程池真正的价值是让系统在大量并发下保持稳定可控避免线程数量膨胀拖垮整个进程。对于手写Tomcat这种需要长期运行的服务器程序稳定性优先级远高于极限吞吐。2. 引入线程池之前先把Java线程池的工作机制想明白2.1 从Executors速成到ThreadPoolExecutor真面目很多初学者第一次接触线程池都用Executors工具类ExecutorService executor Executors.newFixedThreadPool(200);这里有两个隐患在手写Tomcat里都不合适。newFixedThreadPool底层用的是LinkedBlockingQueue队列容量是Integer.MAX_VALUE默认无界。一旦业务处理跟不上所有请求都会排队队列里的Runnable积压起来最终候内存溢出。对于一个Web容器来说这意味着拒绝服务而不是优雅限流。newCachedThreadPool更极端核心线程数为0最大线程数为Integer.MAX_VALUE任务一进来就尝试创建新线程空闲线程60秒回收。这相当于把每请求一线程重新请回来了只是加了回收机制并发爆发时依然可能创建几十万个线程。手写Tomcat里有真实业务压力必须自己面对ThreadPoolExecutor的完整参数这也是理解线程池的必经之路。2.2 线程池的那四个关键阶段核心线程、队列、扩容、拒绝ThreadPoolExecutor.execute()的流程是标准而且固定的我建议直接把它背下来如果当前工作线程数小于corePoolSize创建新核心线程执行任务。如果当前线程数已经大于等于corePoolSize尝试把任务放入workQueue队列。如果队列已经满了再尝试创建新线程直到线程数达到maximumPoolSize。如果线程数已经等于maximumPoolSize且队列也满了执行拒绝策略。这个默认顺序对大部分业务系统是合理的先用空闲的核心线程忙不过来就排队排队太多再扩线程。但放到Tomcat的场景里就会有点别扭后面我会专门讲Tomcat是怎么改的。参数之间的关系可以整理成一张表方便对照参数含义手写Tomcat里的角色corePoolSize长期保留的工作线程数量相当于最小空闲线程数maximumPoolSize允许的最大工作线程数相当于最大线程数workQueue放等待任务的队列请求排队缓冲区keepAliveTime非核心线程空闲存活时间空闲回收策略threadFactory创建线程的工厂设置线程名、Daemon标记handler队列满、线程满时的处理方式拒绝策略不要小看这四个阶段的顺序。同样是请求来了没线程处理是选择排队、创建新线程还是直接拒绝直接影响Web服务器的行为特征。后面手写Tomcat改进时核心就是调整这个顺序。3. 手写Tomcat改进第一步把请求交给线程池3.1 一个能直接跑的最小改造引入线程池的第一步不是把代码改成花哨的架构而是用一个Acceptor线程只负责接收连接处理逻辑全部交给线程池。这个结构是Tomcat连接器的雏形。public class ThreadPoolTomcat { private final ServerSocket serverSocket; private final ThreadPoolExecutor executor; private final AtomicBoolean running new AtomicBoolean(true); public ThreadPoolTomcat(int port, int core, int max, int queueSize) throws IOException { this.serverSocket new ServerSocket(port); ThreadFactory threadFactory new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(); Override public Thread newThread(Runnable r) { Thread t new Thread(r, server-thread- seq.getAndIncrement()); t.setDaemon(false); return t; } }; this.executor new ThreadPoolExecutor( core, max, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(queueSize), threadFactory, new ThreadPoolExecutor.CallerRunsPolicy() ); } public void start() { Thread acceptor new Thread(this::acceptLoop, acceptor-thread); acceptor.start(); } private void acceptLoop() { while (running.get()) { try { Socket socket serverSocket.accept(); executor.execute(() - processRequest(socket)); } catch (IOException e) { if (running.get()) { e.printStackTrace(); } } } } private void processRequest(Socket socket) { try (Socket s socket) { // 读取请求行、解析头、调用Servlet、写响应 } catch (Exception e) { e.printStackTrace(); } finally { // 确保连接关闭 } } public void shutdown() throws IOException { running.set(false); serverSocket.close(); executor.shutdown(); } }这个版本已经有三个关键点Acceptor线程数固定为1。accept()是阻塞的一个线程足够接收连接真正的业务由线程池并发处理。线程池使用有界队列。ArrayBlockingQueue可以让并发请求在队列满之后被限流而不是无限堆积。线程工厂命名。每个线程都有可读的名字出问题时用jstack一眼就能看出哪些线程是容器自己的。3.2 改造中的几个坑线程池怎么初始化、怎么传Socket第一个坑线程池必须是容器级别的单例。如果processRequest里每次请求new ThreadPoolExecutor那等于另类的每请求一线程线程池的意义完全丢失。线程池应该在start()之前创建好并伴随容器整个生命周期。第二个坑Socket不能直接交给业务线程后就没人管了。processRequest里必须用try-with-resources或finally关闭连接否则时间一长Socket句柄会泄漏表现就是Too many open files。第三个坑线程池里的任务可能会抛异常。如果使用executor.execute()异常会被线程池吞到任务执行的框架内部不处理的话日志里什么都没有。建议在Runnable.run()里整体包一层try/catch至少打出一条错误日志。这一点比线程池参数本身更容易被忽略但生产环境里往往先暴露的正是这样的问题。第四个坑拒绝策略的选择有讲究。AbortPolicy默认抛出异常Acceptor线程收到异常后如果没处理请求就直接丢失DiscardPolicy和DiscardOldestPolicy更隐蔽会静默丢弃。CallerRunsPolicy会让提交任务的线程——也就是Acceptor——自己去执行任务好处是自然限流坏处是Acceptor被占用后新的连接无法及时accept。手写Tomcat的初期我更推荐CallerRunsPolicy它至少不会丢请求代价只是接收连接的速度变慢。按需取舍。4. Tomcat线程池和Java内置线程池的不一样4.1 Tomcat为什么不用Executors如果你翻过Tomcat源码会发现它没有直接用Executors而是自己在org.apache.tomcat.util.threads包下实现了一个ThreadPoolExecutor配套还有一个TaskQueue。这背后的核心区别在于Java内置线程池的默认行为是优先排队队列满了才扩线程而Tomcat希望的是优先扩线程达到上限之后才排队。为什么会有这个差异因为Web请求通常都是短任务每个请求都需要尽快被处理如果任务进到队列里等待请求的响应时间就会多出一段不可控的排队延迟。Tomcat的做法是只要还没达到maximumPoolSize来了新请求就尽量创建新线程去处理等到线程数顶到上限才让请求进入队列排队。这样并发较低的时候不会发生排队并发较高的时候用队列做缓冲。这个思路和JVM默认策略看起来只是顺序反了实际影响非常明显。手写Tomcat时如果直接使用默认ThreadPoolExecutor你会观察到在并发爬升阶段大量请求先堵塞在队列里白白浪费时间。4.2 TaskQueue重写offer的秘密线程优先排队兜底Tomcat的TaskQueue继承自LinkedBlockingQueue它重写了offer()方法简化后的逻辑类似这样public class TaskQueue extends LinkedBlockingQueueRunnable { private ThreadPoolExecutor parent null; public void setParent(ThreadPoolExecutor executor) { this.parent executor; } Override public boolean offer(Runnable runnable) { if (parent null) { return super.offer(runnable); } // 当前线程池还没有到最大值时不入队而是返回false让线程池创建新线程 if (parent.getPoolSize() parent.getMaximumPoolSize()) { return false; } // 已经达到最大线程数才真正把任务放进队列 return super.offer(runnable); } }理解这个方法的重点在ThreadPoolExecutor的execute逻辑当核心线程满了之后它会尝试workQueue.offer()如果offer返回false就继续尝试创建非核心线程直到线程数达到maximumPoolSize。所以TaskQueue.offer(false)是在主动告诉线程池我不排队你先给我开新线程。在我们的手写Tomcat里完全可以复刻这个思路自己继承LinkedBlockingQueue在offer里判断当前线程池大小模仿Tomcat的线程优先策略。这样既不需要引入Tomcat依赖又能获得同样的行为。需要提醒的是这个无界队列配合线程优先的方案并不意味着系统可以接收无限任务。Tomcat在连接器层面还有acceptCount、maxConnections等参数来限制TCP层面的连接队列防止无界堆积。手写版本如果把队列设成无界就必须自己承担积压风险。我的建议是模仿Tomcat线程优先的思路可以但队列最好还是设一个明确的容量上限。4.3 Tomcat配置参数与线程池概念的对应关系Tomcat的conf/server.xml里Connector标签上有一堆线程池相关的配置项。很多人只认识maxThreads不知道其他参数到底映射到线程池的哪个概念。我整理了一个对照表Tomcat配置对应线程池概念作用minSpareThreadscorePoolSize保证至少有多少个空闲线程等待工作maxThreadsmaximumPoolSize最大工作线程数量maxIdleTimekeepAliveTime空闲线程超过该时间就会被回收acceptCount操作系统accept队列与线程池无关控制请求连接等待队列长度这里的minSpareThreads是Tomcat默认10maxThreads默认200。也就是说Tomcat线程池的corePoolSize是10maximumPoolSize是200空闲回收时间由maxIdleTime控制。理解了这张表你在调优Tomcat时就会更清楚自己是在动线程池的哪个环节。手写版本对应地可以这么做初始化线程池时corePoolSize设为你的最小空闲线程数maximumPoolSize设为最大线程数keepAliveTime设为空闲回收时间这个映射关系直接移植到代码里。5. 手写Tomcat引入线程池后必做的三件事5.1 监控线程池状态别等OOM才反应线程池引入之后最危险的情况不是线程池不够用而是线程池在运行一段时候后陷入假死队列越积越长活动线程长期打满但代码没有任何报错。这种问题只能靠监控提前发现。手写Tomcat阶段我不会上一整套监控系统只需要在启动时额外起一个定时任务定期打印线程池关键指标executor.execute(() - { while (running.get()) { int poolSize executor.getPoolSize(); int activeCount executor.getActiveCount(); int queueSize executor.getQueue().size(); long completedCount executor.getCompletedTaskCount(); // 简单日志输出 System.out.printf( [tomcat-pool] pool%d active%d queue%d completed%d%n, poolSize, activeCount, queueSize, completedCount); Thread.sleep(10000); } });重点关注三个指标activeCount是否长期接近maximumPoolSizequeueSize是否持续增长completedCount是否长时间不增加。如果三者同时出现基本可以确定业务处理堵住了。给线程命名这件事也是从这一步体现出价值的。用jstack查看线程栈时如果所有线程都叫pool-1-thread-1很难判断是Tomcat的线程还是业务线程。我习惯在ThreadFactory里加上业务前缀比如http-bio-这样排查线程池相关问题时能直接过滤。5.2 优雅停机shutdown和shutdownNow怎么选手写Tomcat如果只关注启动和运行很容易忽略停机。但kill -9杀进程会造成正在处理的请求突然中断这在真实环境里是不接受的。正确的关闭顺序应该是把running标志置为false让Acceptor循环退出停止接收新连接。关闭ServerSocket让阻塞在accept()上的线程立即返回。调用线程池的shutdown()不再接收新任务但允许已提交任务执行完。调用awaitTermination(timeout, unit)等待一段合理时间如果任务还没执行完再调用shutdownNow()强制中断。代码大致是public void shutdown() throws IOException { running.set(false); serverSocket.close(); executor.shutdown(); if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); } }一个容易踩的坑是shutdown()不是立即停止线程池而是不再接收新任务。如果你还继续往里提交任务会收到RejectedExecutionException。所以停机逻辑里running标志的检查必须在提交任务之前否则Acceptor刚跳出循环别的线程又往池里塞任务关不干净。另外shutdownNow()会中断正在执行的任务但线程池里的线程收到中断后如果代码没有响应中断靠Thread.interrupt()是停不下来的。所以业务任务里需要适当检查线程中断标志或者依赖Socket.setSoTimeout来打破长时间阻塞的读取操作。5.3 线程池大小该设多少从实测数据聊起关于线程池大小最常见的讨论是CPU核心数1还是CPU核心数*2。这个经验公式在纯计算场景下有一定参考价值但Web容器基本是IO密集型线程大部分时间阻塞在Socket读写、数据库查询、远程调用上真正的CPU计算占比反而不高。Tomcat默认把maxThreads设成200是有道理的大多数Web请求的瓶颈在IO等待200个线程可以让同一时刻有200个请求并发推进而不会把CPU直接打满。手写Tomcat起步阶段我会直接把corePoolSize设成10maximumPoolSize设成200队列容量设为1000左右先跑起来再根据压测调整。具体调参建议这样操作用wrk或JMeter压到目标并发观察前面说的监控指标。如果activeCount打到200但queueSize还在涨说明线程数不够可以逐步加到300、400直到请求延迟稳定。如果线程没到最大但CPU已经接近100%说明业务代码本身太重加线程只会加剧上下文切换正确做法是优化业务或增加机器。这里没有万能公式只有压测数据最可靠。至于队列大小的经验值我建议不要设置太小。太小会让突发的短请求直接触发拒绝策略太大又会导致拥堵时延迟过高。一个相对安全的起步值是maxThreads * 5压测后根据实际延迟要求再上下调整。注意这只是一个基于常见实践的参考值不是标准答案。对我自己来说从每请求一线程改成线程池之后最直观的变化是同样用300并发压测原来线程数会冲到300以上且CPU飙到90%改为固定线程池后线程数稳定在80左右CPU降到50%上下响应时间的尾部延迟也短了很多。之后我再也没有回到过来一个请求就new一个线程的写法。线程池不是银弹它只是让手写容器从能跑变成可控的第一步但这一步值得反复咀嚼。
返回列表