1. 背景介绍
由于直接介绍可能理解不了相关概念,建议先大概了解Vert.X的一些对应知识以及其相对位置,参考另一篇文章:Modern Web Programming 学习总结与思考
2. Modern Web 发展历史及其演变
2.1 并发带来的问题
Servlet3.0标准出来之前,由于阻塞模型导致大部分web应用的并发量无法上来,3.0标准以及后续的3.1标准出来以后,web应用的并发量得以迅猛提升,但是并发量的提高同时显现了另一个问题:根据木桶原理,系统的瓶颈取决于性能最低的那个模块,这个模块逐渐变成了---- IO.
对于计算型web应用来说还好,系统的CPU可以得到充分的利用,而对于大多数互联网web应用来说,大量的数据库操作,NoSQL操作,跨系统调用等,都涉及到IO操作,而这些操作短至几ms,长至十几秒,涉及到对应线程的阻塞,时间片的竞争等等。而另外一点是:相比协程的概念来说(如goroutine等),Java的Thread开销是很大的,不仅一个Thread通常需要几MB的开销(为栈分配的内存),其也涉及到和内核线程(Kernel Thread)的交互。从而一定程度上浪费了CPU资源。
总的来说,对于Java的IO瓶颈型应用,其痛点主要为2点,第一线程开销太大,第二因为阻塞切换的CPU等操作开销太大。目前业内主流通过两种方式来解决:第一,轻量级线程,协程;第二通过async/await模型,既纯异步操作来解决阻塞+线程切换的问题。
2.2 轻量级线程解决方案 -- 如何在Java中实现类似Go中的goroutine
2.2.1 Thread与协程的区别与思考
Go在语言层面实现了轻量级的协程,其主要概念就是 (continuation + scheduler)(原谅我此处无法翻译成中文,因为目前还没有标准统一的中文翻译,大概的意思就是程序剩余要执行的部分及调度命令)。
而相对于Java的Thread来说,区别主要是:Java的Thread是对系统线程(Kernel Thread)的一种抽象,就算可以通过-Xss来设置线程大小,但是Java线程本质上是对操作系统的抽象,其start方法是调用系统线程来操作的:
而通常来说,其理论可生成最大线程数是由底层系统可以生成的最大线程数量决定的:在Linux中:/proc/sys/kernel/threads-max,可其默认值是32080。所以我们在JVM中,就算有充足的内存,运行起来其理论值也只是接近这个数值而已。其次,如果修改Linux的默认值,由于Thread本身的限制,创建单个Thread最小内存消耗也比较大,实际运行中几个G的内存通常也无法撑起海量线程(调小线程栈内存也容易Stack Over Flow)。
而Go等语言是在语言层面实现协程的,所以可以达到轻量级实现,在我们深入理解不用级别线程之前,关于用户线程(User Thread)和系统线程(Kernel Thread), 可以参考文章:用户线程 VS 系统线程
另一个额外可能要提出的点就是:通常我们知道线程是操作系统可以操作的最小单元,但是更近一步说,还有另外两点需要进一步理解:Java中的Thread是对系统/内核线程(Kernel Thread)的一种abstraction,而系统线程则是按顺序执行的计算机指令序列(A thread is a sequence of computer instructions executed sequentially.)-->这能帮我们进一步理解其系统瓶颈在哪儿:当我们在操作系统中完成一件事,不仅仅是计算(calculation),还涉及到IO网卡数据交互(IO wait),时间暂停(time wait),从内核总线同步数据(synchreonize/volatile)等等操作。
2.2.2 天然语法层面的实现 -- Kotlin
2018.10月,Kotlin 1.3 版本已经从语法层面正式支持Kotlin协程conroutine,其可以和Java相互转换的特性可以让我们在stable版本尝试到协程带来的好处,和Java的切换也很无缝,IntelliJ已经天然支持自动转换。不过介于目前大部分Java开发对于kotlin语言还是不够熟悉,可能从Java切换到Kotlin难度较大。
2.2.3 Java -- Project Quasar
Quasar 主要实现了协程级别的用户线程(User Thread),通常被称为纤程,其数据抽象模型和Thread基本一样。可以【通过javaagent在运行时修改字节码】 或者 通【过 AOT instrumentation在编译期实现代码的拦截】。其纤程单元fiber单个实例需要400BYTE-2KB左右的级别开销,其操作方式和线程的方式类似,如下创建100万个用户线程,最终消耗内存1G左右,单个Fiber消耗1KB左右,只要内存无限制,可以无限量起纤程:
我们可以通过类似Thread的方式在我们的项目中启动大量的纤程,从而增大并发,提高系统性能。但是Fiber模式也有2个主要的缺陷:1.对代码有一定的侵入性。2.本质上是多个fiber共享一个kernel thread,如果kernel thread被阻塞,那么所有在这个kernel thread上分配的fiber将被阻塞。这就意味着,任意一组fibers,不能有任何一个在运行中调用阻塞的API,否则会阻塞 kernel thread,导致这一组fibers都被阻塞。关于Fiber更详细的介绍,可以参考以下几篇:
Quasar: Efficient and Elegant Fibers, Channels and Actors
Java中的纤程库 - Quasar
继续了解Java的纤程库 - Quasar
2.2.4 Project Loom
Project Loom 是openJDK Lead的项目,目前正在开发中,有可能会被纳入openJDK11中,其主旨和Quasar基本一致,基本可以认为是Quasar的加强版本,并且借鉴了Quasar的设计思路。其中关键的是目前Quasar由于不是官方项目,其对代码的侵入性或者说AOT方式不是JCP提倡的“不在编译器做任何修改”原则,后续此特性加入JDK以后,Quasar上述提到的缺点1便不存在了。关于Loom项目,值得一提的是其官方介绍,一个长篇英文立意论述,很精彩,从很底层很深入的描述了现代web应用中面临的并发问题以及在操作系统层面的思考,讨论,以及解决之道,并且详细给出了实现代码。和Quasar一致,未来Loom会提供Fiber类型,和Thread同样继承与共同的父类Strand,文章需要半天以上时间的阅读+理解,链接如下:
Project Loom: Fibers and Continuations for the Java Virtual Machine
除此之外,一些有益于理解的链接如下:
Project Loom openJDK官方说明介绍
项目Lead -- Project Loom with Ron Pressler and Alan Bateman
Project Loom: Fibers and Continuations for the Java Virtual Machine with Ron Pressler
2.3 Async/await模型解决方案
2.3.1 async/await定义
首先可以规整下对应的定义,编程中主流的两种编程风格,以及它们对应的风格,不同的实践方式,对应的模式简称,分别是:
OOP --> 面向对象编程 --> Thread/Green Thread --> proactor pattern
FP --> 函数编程 --> Event Driven --> reactor pattern
在我的另一篇文章(Reactive Stack系列(一):响应式编程从入门到放弃)中提到了关于响应式宣言的一些介绍,其中在目前的Java为主的OOP编程中,FP概念的趋势有增强的趋势,如RxJava,Netty,Lambda,JDK9 Flow等较新的特性和框架都是FP,reactor pattern的。而reactor pattern 中最为熟知的一个特性应该算是:callback(回调)。
async/await模式常见于大前端概念中(网页,IOS/Andriod客户端等),因为常见的点击等响应式事件使得这个响应式(reactive)风格易于理解,而后端通常的命令式/逻辑式编程比较不适应这种编程风格(其实是思考方式)。async/await模式要求后端把所有的IO相关的操作都用callback方式来桥接起来,而reactor pattern天然适合写成异步编程模式。
2.3.2 Vert.X背景介绍
Netty是reactor pattern的一个典型实现,其是网络层的抽象框架(不是应用框架重复三遍)。 所以其天然基于事件驱动,弹性扩展等特性。
Vert.X最初起源于Node JS,所以不难理解为什么它是reactor pattern的实现。Vert.X是应用级别的框架,基本等同于SpringBoot系列+Spring Cloud系列全家桶而又不仅仅限于此。所有的模块都是异步实现的,凡是涉及到IO通讯的异步实现基本都是基于Netty之上的。(再次引用之前自己画的图)。
在Web框架中,SpringBoot2.0中引入的WebFlux模块和Vert.X中的Web模块基本原型概念到实现没有太大的差别,但是由于Vert.X天生没有servlet stack的包袱,基于Netty构建,所以web构建相对于WebFlux会简洁很多。
另外Spring中目前核心的Spring Core模块仍然没有对应的reactor pattern配套模块,只能靠JDK8中的Stream和Lambda来实现。而Vert.X由于天生是纯reactor pattern,所以提供了一套完成的Vert.X Reactive模块。
2.3.3 Vert.X 的问题
说是Vert.X的问题,其实也不是Vert.X的问题,而是Java程序员的问题:就是长期OOP写服务端,很难去很熟练 的转到FP模式写代码,也不仅仅如此,其实FP写代码的一个共识问题就是:Callback Hell,异步模式虽然不阻塞,但是由于我们在业务代码中通常逻辑会深入很深,如果对应的阻塞操作都改成异步操作,那么回调地狱问题会特别严重。随便举个例子如下,做一个数据库操作,失败了需要回滚事务:
这仅仅是一个数据库操作,如果再加上Redis,跨系统IO调用等,括号会飞上天。
2.3.4 利用Fiber来解决Vert.X中的Callback Hell问题
对于上述提到的Callback Hell问题,由于我们知道Fiber模型相对Thread来说开销很小,所以这里我们可以用Fiber来改写成熟悉的proactor pattern。据官方数据,用Fiber改写性能损失大约在3%-5%(为了换取可读性,应该可以接受?)
以下给出一段实例代码,为拼团业务中用户开团下订单的过程。
这段不算长的代码中,涉及到比较复杂的下单逻辑,流程依次涉及到:
--->启动服务器
------>接受客户端请求
--------->Redis抢锁防重
------------>Redis校验库存并扣除
--------------->去下游收单系统下单
------------------>数据库订单表插入数据
--------------------->数据库团信息表插入数据
------------------------>返回客户端请求
我们可以看到,基本逻辑很清爽,没有任何的callback hell现象,核心在于awaitResult方法,其用lambda方式,在其中调用中产生了Fiber对象,通过run()方法来获取结果,这样就很优雅的解决了callback hell对我们造成的杀伤力。
另外在这段代码中,我们也可以把所有通过Netty接受的请求从Thread线程切换到纤程模式,可以以更加轻量的方式来承接更多的请求:
3 总结
上面的所有讨论,总的来说在围绕着木桶原理不断地对瓶颈部分做底层优化:对CPU做文章,减少CPU的空闲时间,等待时间与切换开销;用async/await,本质对IO操作分组,分线程组等;用Fiber来解决线程过重等问题。实际项目中我们可能需要更加灵活的根据实际情况来选择合适的框架,比如计算密集型任务reactor pattern解决不了问题,更应该选择简单合适易上手的框架。