我第一次接触反应式编程(当然你也可以叫做响应式编程,下面统称为 Reactor 编程)是在2020年初,当时我们使用的网关是 Zuul , 后来因为 Zuul2 使用了 Reactor 架构重构,我看了Netflix 为什么要使用 Reactor 架构的一篇文章 Zuul2:The Netflix Journey to Asynchronous, Non-Blocking Systems。而后由于工作原因我又陆续的接触了 Reactor Netty,Rsocket,Hystrix,Resilience4j 等使用 Reactor编写的项目。不过对于 Reactor 一直停留在理论阶段,一直没有机会将它真正落地到项目中,其中很大的原因是因为 Reactor 的学习路线非常陡峭,理解 Reactor 的思想和能为我们带来什么样的收益是一件非常困难的事情。所以国内的开发人员使用 Reactor 编程的比较少,也没有什么经典的落地案例。
1. 谈一谈反应式编程和协程
1.1. 协程
如果谈到 Reactor 编程,那么一定离不开和Go语言的协程(goroutine)进行对比。大部分人的观点是 Reactor 学习路线陡峭,将代码改造为 Reactor 带来的收益太小(性能上),并且维护起来也比较困难,如果为了提高CPU 利用率编写异步代码,直接用协程(goroutine)多好。我们只需要一个关键字 go 就可以通过命令式编程的方式快速的写出异步的代码,如下:
go func() {
// do something
}()
起初,我也是这么认为的。但是随着对 Reactor 编程的深入理解和对协程(下文的协程专指goland的goroutine)的深入使用,我发现 协程(goroutine)和 Reactor 编程并不是等价的,两者之间是有本质性的区别的。
首先协程和线程/进程一样都是为了描述计算机的并发性,引入他们的目的都是为了使多个程序并发执行,从而改善资源利用率及提高系统的吞吐量。协程相比于线程(JVM 的 Thread)的优势有三个方面:
-
协程占用的占用的资源更少,只需要几 KB,而线程一般都在几兆。
-
协程的由 runtime 在用户态进行上下文切换,切换耗时比线程要小30倍左右。
-
通过 go 关键字可以实现命令式的异步编程。
从上面来看协程完全碾压线程,但是事实真的如此吗?起码在现阶段的goland中我认为不是的,主要有以下几点原因:
-
在海量并发的场景下,协程确实比线程占用的资源要少,但是 runtime 对协程的调度能力是有限的。所以。如果不加思考滥用 go 关键字随意创建协程,很有可能导致延迟增加和内存溢出。
-
由于问题1,所以很多非官方的库中都实现了协程池(worker pool),然而这似乎并不符合 go 作者的初衷,我想这也是为什么 go官方没有支持协程池的原因。因为,当使用了协程池之后,通过协程创建异步任务的便捷和优雅将大大降低,就和 java 的线程池在编排异步任务的时候一样让你恶心。
-
从现代 web 服务器上来说内存资源已经不再是非常昂贵资源了,现在服务器动辄上百G的内存。线程和协程占用的资源相对来说就不是很多了,更不要提使用线程/协程池化技术之后了,10G 的资源和 2G 的资源显然没有很大区别。那么协程的优势又在哪里那?
-
goroutine 不支持抢占式调度(1.14之后已经支持)和协程的优先级调度。
另外值得一提的是 goroutine 在异步任务的编排上要比 JVM 优雅一点,但是我们必须熟练使用 chan 和 select。下面这个示例使用chan 和 select 实现了一个简单的重试,它似乎有点类似于 JVM 中的 Future:
func AsyncPredict() {
signl := make(chan string, 1)
res := ""
go func() {
// 执行计算
res = Exec()
signl <- "done"
}()
// select 依然会阻塞
select {
case <-signl:
case <-time.After(time.Duration(10) * time.Second):
res = Exec()
}
go func(param string) {
// 执行异步的编排
}(res)
}
func Exec() string {
// 计算逻辑
time.Sleep(time.Duration(2) * time.Second)
return "success"
}
1.2. 反应式编程
反应式编程是一种编程范式,它和具体的语言无关(RxJava,RxJs,RxGo,Vert.x等都是反应式库),只要符合反应式流规范即可。
反应式编程用于构建反应式系统。反应式系统具备以下四个特征:即时响应性(responsive)、回弹性(resilient)、弹性(elastic)和消息驱动(message driven)。
那么反应式编程主要有什么特点和优势那?我总结了以下几点:
- 在降低资源占用的情况下(主要是线程)提高 CPU 的利用率。
- 编排异步任务,JVM的 Future 和 CompleteFuture 对异步任务的编排能力非常差,(例如任务失败后重试、异常处理、多个异步任务协作等)。而 Flux 以通过操作符轻易的对异步任务进行编排。
- 拥抱延迟执行,在没有订阅的情况下什么都不会发生,Flux 只是一个占位符不会有任何副作用,只有在订阅的时候才会执行。
- 可以轻易执行并发,而不是直接使用线程池(从命令式并发到声明式并发)。
- 声明式的异常处理和重试机制。而不是使用try-catch。
- 流控制和回压能力。
当然反应式编程也有一定的缺点:
- 学习路线陡峭,和常用的思维方式不同。
- 异常堆栈难以进行跟踪。
- 相比于命令式编程更容易导致内存泄漏。
2. 反应式编程的实际案例
在很长一段时间里,我都没有找到反应式编程的实际落地场景。直到最近我看了《RxJava 反应式编程》中 ”将反应式编程应用于已有应用程序“一节。
我最近做了一个监控巡检的需求,就使用了反应式编程,具体需求如下:
- 从数据库获取上一周要发送人的列表。(包含两个字段,并且需要去重,所以使用了 megrge() 和 distinct() 操作符)。
- 对每个发送人的监控大屏进行截图并上传 AWS S3(利用 flatMap() 和 subscribeOn() 实现并发截图和上传 )。
- AWS S3 从下载截图,发送邮件。(利用 flatMap() 和 subscribeOn() 实现并发下载和发送 )
当然在整个过程中,使用 buffer() 和 blockLast() 作为异步和同步之间的桥梁也是非常重要的。
3. 最后
对于想要了解和学习反应式编程的同学,非常推荐阅读一下《RxJava 反应式编程》。我也总结一些反应式编程的教程,大家可以参考。