Vertx定义
Vertx是一个基于Netty响应式工具包,官方没有定义为框架,因为他并不像Spring侵入性那么强,甚至你可以在SpringBoot中使用他。
那什么是响应式
响应式编程,即 Reactive Programming。它是一种基于事件模式的模型。在异步编程模式中,我们描述了两种获得上一个任务执行结果的方式,一个就是主动轮询,我们把它称为 Proactive 方式;另一个就是被动接收反馈,我们称为 Reactive 方式。简单来说,在 Reactive 方式中,上一个任务执行结果的反馈就是一个事件,这个事件的到来会触发下一个任务的执行
所以响应式编程一定是异步的,但异步不一定是响应式。
响应式的核心是事件流Stream(一般包含三种类型的事件:某种类型值,错误,已完成的事件信号)。
熟悉Netty的应该知道,在netty中有两个EventLoop,一个BossEventLoop和一个WorkerEventLoop,分别负责负责监听Socket事件和处理对应的请求
响应式编程解决了什么问题?相比多线程异步的优点?
CPU运行线程代码时如果遇到IO,会将线程挂起,然后运行其他线程,这里会有一次上下文切换,会消耗一些CPU性能。这里要搞清楚是CPU不会被IO阻塞,线程是会被IO阻塞的。也就是说Threa1在执行任务A时遇到了IO块比如查数据库,他会一直阻塞直到这个操作完成,这里用任务会合适点,因为在线程池中需要执行的功能都被包装成任务了。但是golang和java21中的协程可以实现线程不被IO阻塞,但这个也是语言层面做了优化,本身Linux c线程就不支持。
其实响应式编程和异步都是为了解决一个问题:提升CPU的利用率,但是响应式编程相比多线程可以实现更少的线程完成更多的任务,在内存和上下文切换方面开销更小。
先探究一下多线程为啥能提升CPU利用率?
因为磁盘IO,网络IO的速度和CPU处理速度相差巨大。而线程处理任务遇到IO后会导致线程一直等待处于WAITING状态,无法处理其他任务。这样就造成了一个现象:CPU没活干,但有大量任务待处理。我们以单核CPU为例子,假设一个API场景为:
客户端请求服务A的接口/a/test,接口实现逻辑为:
A服务收到请求,解析出请求参数aId和bId,然后携带bId请求微服务B获取bId对应详情数据,A收到B的返回后A服务再去自己数据库查询aId数据详情。最后将aId详情和bId详情数据一并返回给客户端。
可以看到一次请求CPU的使用时间片占用较短(实际情况大部分接口占用CPU的时间片比图示更短,下图是理想状态实际难以跑满)。当CPU处理到线程IO时会挂起当前线程然后处理其他线程,当线程比较多就能处理更多任务,使CPU时刻都有任务处理,从而提高了CPU利用率。
那么我们是否可以将线程设置很大,这样就能最大限度提高CPU利用率?
不行。两个原因
1. 线程创建有成本代价
首先创建也会耗费CPU资源,同时每个线程也会占用一定内存。 jdk1.4默认的单个线程是占用256k的内存,jdk1.5+默认的单个线程是占用1M的内存,可以通过-Xss参数设定,一般默认就好
2. 线程上下文切换会有增加CPU负担
拿我们上面的单核10线程举例,CPU处理完一个线程的计算任务后等待IO需要切换到另外一个线程。 线程切换就涉及到保存当前线程的上下文(Context)和加载另一个线程的上下文。上下文包括寄存器的值、程序计数器(PC)和栈指针(SP)等。下面是线程切换的主要步骤:
- 保存当前线程的上下文:操作系统首先保存当前线程的上下文,将当前线程的寄存器、PC 和 SP 等信息保存到内存中,以便稍后恢复。
- 选择下一个线程:操作系统从就绪队列中选择下一个要运行的线程。这个选择可以基于调度算法,如先来先服务(FCFS)或轮转调度(Round Robin)。
- 加载下一个线程的上下文:操作系统从内存中加载下一个线程的上下文,包括寄存器的值、PC 和 SP 等。
- 切换到下一个线程:操作系统将 CPU 控制权切换到下一个线程,使其继续执行。
- 恢复上下文:如果之前保存了当前线程的上下文,操作系统会将其恢复,以便在下次切换回该线程时能够继续执行。
为了节约内存和避免频繁上下文切换带来的系统损耗,就出现了线程池,同时线程池也都具备回收线程的功能来,比如Tomcat就有线程池大小相关配置,而且默认200左右(比较小)
server.tomcat.accept-count:等待队列长度,当可分配的线程数全部用完之后,后续的请求将进入等待队列等待,等待队列满后则拒绝处理,默认100。
server.tomcat.max-connections:最大可被连接数,默认10000
server.tomcat.max-threads:最大工作线程数,默认200
server.tomcat.min-spare-threads:最小工作线程数,初始化分配线程数,默认10
同时这也导致了Tomcat并发度较低,不适合高并发项目,我们可以算一下,在200线程的前提下假如每个请求耗时在20ms左右Tomcat能并发处理的请求在10000左右实际还不到。那么我们可以提高工作线程数量,确实可以,但这样线程上下文切换更加频繁CPU损耗更大,CPU很多时间片都用在了上下文切换而不是处理任务,而且占用内存更多,比如要支持100,0000人在线的IM项目用Tomcat显然不合适。
那么怎样去优化?
优化的原则只有一个,就是用更少的线程去处理更多的任务。而不是更多的线程处理更多的任务,可以看到即使用了多个线程CPU利用率提高了但是每个线程的利用率还是很少!那么有人问了,线程少了可以承担那么多任务吗? 答案是肯定的,node.js就是单线程但是可以处理百万并发(但node.js受限于单线程,如果每个请求都大量耗费CPU会导致性能急剧下降)
那么如何用更少的线程处理更多的任务?
- 让线程不用等待IO,当遇到IO时线程直接去处理其他任务(jdk21之前还做不到,golang以及jdk21中的协程可以实现)
- 将大块的IO分散成小块IO(响应式编程和Vertx就是这种思路!!!)
为什么要将大块IO分散成小块IO? 因为大块IO会导致当前线程阻塞太久
假设一个请求场景
用户信息微服务,用户订单微服务,请求操作:
- PC端携带用户Id请求用户服务,用户服务接受并从网卡中读取数据(网络IO)
- 解析读取请求信息(CPU解析)
- 根据解析出的用户Id从用户服务数据库中查出用户A的信息(数据库IO和网络IO)
- 用户服务携带用户数据向订单服务请求用户一年消费数据(网络IO)
- 根据用户信息和用户A消费数据计算出用户消费指标信息同时生成报表(CPU计算)
- 将生成报表发送到oss(网络IO)
- 将用户指标信息更新数据库(网络IO和数据库IO)
- 同时返回给PC端(网络IO)
因为线程数量是有限的,而且远远小于处理请求数量。我们也知道处理一个接口时CPU占用时间相对IO是很少的,图里我也有体现。但因为线程需要等待IO导致其他需要处理的请求被迫等待,特别是当请求量大的时候越发明显。
如图所示黄色线条中间区域属于被迫等待,明明CPU都没事做了,但是由于5个线程阻塞在IO上导致他无法处理request6和request7导致他们阻塞时间延长了不少,特别当请求更多的时候这个情况愈发严重。传统的Tomcat线程在处理请求时就会面临这种问题,所以导致Tomcat从根本上难以胜任高并发。
要是request1-5中能快速抽出一个线程将request6-7的CPU操作运行完就好了,但是线程又不能再遇到IO时自动挂起处理其他任务,所以这个时候可行的方法是将一个请求的连续的IO拆成单个IO操作(粒度越小越好)封装成任务丢给线程池执行。
优化方法1:拆分大块IO,如图黑色线条
大块IO拆成小块,利于线程快速处理非阻塞任务,原来需要等待数个IO阻塞时间现在只需要等待1个IO阻塞时间了。而且没有了上下文切换,因为每个计算或者IO代码块被包装成了线程任务
优化方法2:使用专门CPU密集型线程池处理非阻塞代码块,如图紫色线条
但是因为IO阻塞时间相对CPU计算时间要大不少,所以还是导致需要CPU的任务块还是需要等待不少时间,那么第二个优化点就是使用专门的非阻塞任务线程池处理CPU计算任务。
其实这里又引出一个问题,阻塞也要耗费线程,CPU计算很快可能导致大量IO任务一起到来,所以这里要根据IO系数调整IO线程池数量。
从整体来看,一个系统如果达到最大并发状态,应该是每个CPU任务块能得到及时响应,每个IO任务块也能得到及时响应。方法1已经做到了后者,但前者没做到,但方法2兼顾了所有。CPU任务块能及时被响应,IO任务块也占满了IO线程池。
小结
回过头来看看Vertx以及Java中的响应式编程框架就是采用了上述优化方案。响应式编程是对IO任务块和CPU计算任务块进行了优秀的编排可以更加高效的利用CPU。但其实大多数Web系统从总体上来看瓶颈上是在数据库上,一般的业务没有必要上Vertx,因为JDK21之前写起来属实恶心,那么相比Tomcat,Vertx适合哪些业务场景:
- 大量连接并发的系统,如IM系统,消息通知中心且基于网络IO的。因为天然基于Netty可以保持大量连接,IO主要是网络IO网卡好点就行了
- 高性能中间件系统
- 想用更少的机器支持更大体量的WEB系统
- 同时Vertx启动非常快,没有SpringBoot臃肿,一些业务相对简单的项目可以试试
那么JDK21中也支持协程了,等到Tomcat优化好了使用响应式框架工具的必要性就更小了,毕竟响应式编程写出来的代码可读性太低了,有的本来代码写的比较乱改成Vertx更加不可看了,特别是业务代码。
在 Spring Boot 3.2 中,启用了虚拟线程后,Tomcat 默认使用的虚拟线程执行器不在需要池化。 也就是说,在 Spring Boot 3.2 以后的版本里,我们不在需要设置
server.tomcat.threads.max
以及server.tomcat.threads.min-spare
两个属性以控制 Tomcat 线程池的大小了,因为它压根没有使用平台线程池。对于 Tomcat 来说,引入虚拟线程,不必在为线程池的维护而费心,还能减轻编程的复杂度。
虚拟线程由 JVM 平台负责进行调度,它是廉价且轻量级的,Tomcat 可以使用 “每个请求一个线程” 模型,而不必担心实际需要多少个线程。
就算请求任务在虚拟线程中调用阻塞 I/O 操作,导致运行时虚拟线程被挂起阻塞,但是只要挂起结束后该虚拟线程就可以恢复
使用了虚拟线程后,程序员使用普通的阻塞 API,也可以让程序对硬件的利用达到近乎完美水平,以此提供高水平的并发性,从而实现高吞吐量。
可以说,虚拟线程的引入,以后程序员就算是使用 Java 中阻塞 API 也可以开发出高性能、高吞吐量的应用程序。