Vertx高并发理论原理以及对比SpringBoot

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
简介: Vertx 是一个基于 Netty 的响应式工具包,不同于传统框架如 Spring,它的侵入性较小,甚至可在 Spring Boot 中使用。响应式编程(Reactive Programming)基于事件模式,通过事件流触发任务执行,其核心在于事件流 Stream。相比多线程异步,响应式编程能以更少线程完成更多任务,减少内存消耗与上下文切换开销,提高 CPU 利用率。Vertx 适用于高并发系统,如 IM 系统、高性能中间件及需要较少服务器支持大规模 WEB 应用的场景。随着 JDK 21 引入协程,未来 Tomcat 也将优化支持更高并发,降低响应式框架的必要性。

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)等。下面是线程切换的主要步骤:

  1. 保存当前线程的上下文:操作系统首先保存当前线程的上下文,将当前线程的寄存器、PC 和 SP 等信息保存到内存中,以便稍后恢复。
  2. 选择下一个线程:操作系统从就绪队列中选择下一个要运行的线程。这个选择可以基于调度算法,如先来先服务(FCFS)或轮转调度(Round Robin)。
  3. 加载下一个线程的上下文:操作系统从内存中加载下一个线程的上下文,包括寄存器的值、PC 和 SP 等。
  4. 切换到下一个线程:操作系统将 CPU 控制权切换到下一个线程,使其继续执行。
  5. 恢复上下文:如果之前保存了当前线程的上下文,操作系统会将其恢复,以便在下次切换回该线程时能够继续执行。

为了节约内存和避免频繁上下文切换带来的系统损耗,就出现了线程池,同时线程池也都具备回收线程的功能来,比如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会导致当前线程阻塞太久

假设一个请求场景

用户信息微服务,用户订单微服务,请求操作:

  1. PC端携带用户Id请求用户服务,用户服务接受并从网卡中读取数据(网络IO)
  2. 解析读取请求信息(CPU解析)
  3. 根据解析出的用户Id从用户服务数据库中查出用户A的信息(数据库IO和网络IO)
  4. 用户服务携带用户数据向订单服务请求用户一年消费数据(网络IO)
  5. 根据用户信息和用户A消费数据计算出用户消费指标信息同时生成报表(CPU计算)
  6. 将生成报表发送到oss(网络IO)
  7. 将用户指标信息更新数据库(网络IO和数据库IO)
  8. 同时返回给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 也可以开发出高性能、高吞吐量的应用程序。


转载来源:https://juejin.cn/post/7408774470074531881

相关文章
|
1月前
|
XML Java 开发者
Spring Boot开箱即用可插拔实现过程演练与原理剖析
【11月更文挑战第20天】Spring Boot是一个基于Spring框架的项目,其设计目的是简化Spring应用的初始搭建以及开发过程。Spring Boot通过提供约定优于配置的理念,减少了大量的XML配置和手动设置,使得开发者能够更专注于业务逻辑的实现。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,为开发者提供一个全面的理解。
34 0
|
4天前
|
NoSQL Java Redis
Spring Boot 自动配置机制:从原理到自定义
Spring Boot 的自动配置机制通过 `spring.factories` 文件和 `@EnableAutoConfiguration` 注解,根据类路径中的依赖和条件注解自动配置所需的 Bean,大大简化了开发过程。本文深入探讨了自动配置的原理、条件化配置、自定义自动配置以及实际应用案例,帮助开发者更好地理解和利用这一强大特性。
42 14
|
1月前
|
Java Spring
SpringBoot自动装配的原理
在Spring Boot项目中,启动引导类通常使用`@SpringBootApplication`注解。该注解集成了`@SpringBootConfiguration`、`@ComponentScan`和`@EnableAutoConfiguration`三个注解,分别用于标记配置类、开启组件扫描和启用自动配置。
56 17
|
26天前
|
Java 容器
springboot自动配置原理
启动类@SpringbootApplication注解下,有三个关键注解 (1)@springbootConfiguration:表示启动类是一个自动配置类 (2)@CompontScan:扫描启动类所在包外的组件到容器中 (3)@EnableConfigutarion:最关键的一个注解,他拥有两个子注解,其中@AutoConfigurationpackageu会将启动类所在包下的所有组件到容器中,@Import会导入一个自动配置文件选择器,他会去加载META_INF目录下的spring.factories文件,这个文件中存放很大自动配置类的全类名,这些类会根据元注解的装配条件生效,生效
|
5月前
|
Java 应用服务中间件 开发者
Java面试题:解释Spring Boot的优势及其自动配置原理
Java面试题:解释Spring Boot的优势及其自动配置原理
129 0
|
2月前
|
Java Spring 容器
springboot @RequiredArgsConstructor @Lazy解决循环依赖的原理
【10月更文挑战第15天】在Spring Boot应用中,循环依赖是一个常见问题,当两个或多个Bean相互依赖时,会导致Spring容器陷入死循环。本文通过比较@RequiredArgsConstructor和@Lazy注解,探讨它们解决循环依赖的原理和优缺点。@RequiredArgsConstructor通过构造函数注入依赖,使代码更简洁;@Lazy则通过延迟Bean的初始化,打破创建顺序依赖。两者各有优势,需根据具体场景选择合适的方法。
104 4
|
3月前
|
Java 开发者 数据格式
【Java笔记+踩坑】SpringBoot基础4——原理篇
bean的8种加载方式,自动配置原理、自定义starter开发、SpringBoot程序启动流程解析
【Java笔记+踩坑】SpringBoot基础4——原理篇
|
2月前
|
Java Linux 应用服务中间件
【编程进阶知识】高并发场景下Bio与Nio的比较及原理示意图
本文介绍了在Linux系统上使用Tomcat部署Java应用程序时,BIO(阻塞I/O)和NIO(非阻塞I/O)在网络编程中的实现和性能差异。BIO采用传统的线程模型,每个连接请求都会创建一个新线程进行处理,导致在高并发场景下存在严重的性能瓶颈,如阻塞等待和线程创建开销大等问题。而NIO则通过事件驱动机制,利用事件注册、事件轮询器和事件通知,实现了更高效的连接管理和数据传输,避免了阻塞和多级数据复制,显著提升了系统的并发处理能力。
70 0
|
5月前
|
SQL Java 数据库连接
springboot~mybatis-pagehelper原理与使用
【7月更文挑战第15天】MyBatis-PageHelper是用于MyBatis的分页插件,基于MyBatis的拦截器机制实现。它通过在SQL执行前动态修改SQL语句添加LIMIT子句以支持分页。使用时需在`pom.xml`添加依赖并配置方言等参数。示例代码: PageHelper.startPage(2, 10); List<User> users = userMapper.getAllUsers(); PageInfo<User> pageInfo = new PageInfo<>(users); 这使得分页查询变得简单且能获取总记录数等信息。
130 2
|
5月前
|
Java 开发者 Spring
深入理解Spring Boot中的自动配置原理
深入理解Spring Boot中的自动配置原理
下一篇
DataWorks