从命令式编程到反应式编程(Reactive Programming)

2,568 阅读6分钟

我第一次接触反应式编程(当然你也可以叫做响应式编程,下面统称为 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)的优势有三个方面:

  1. 协程占用的占用的资源更少,只需要几 KB,而线程一般都在几兆。

  2. 协程的由 runtime 在用户态进行上下文切换,切换耗时比线程要小30倍左右。

  3. 通过 go 关键字可以实现命令式的异步编程。

从上面来看协程完全碾压线程,但是事实真的如此吗?起码在现阶段的goland中我认为不是的,主要有以下几点原因:

  1. 在海量并发的场景下,协程确实比线程占用的资源要少,但是 runtime 对协程的调度能力是有限的。所以。如果不加思考滥用 go 关键字随意创建协程,很有可能导致延迟增加和内存溢出。

  2. 由于问题1,所以很多非官方的库中都实现了协程池(worker pool),然而这似乎并不符合 go 作者的初衷,我想这也是为什么 go官方没有支持协程池的原因。因为,当使用了协程池之后,通过协程创建异步任务的便捷和优雅将大大降低,就和 java 的线程池在编排异步任务的时候一样让你恶心。

  3. 从现代 web 服务器上来说内存资源已经不再是非常昂贵资源了,现在服务器动辄上百G的内存。线程和协程占用的资源相对来说就不是很多了,更不要提使用线程/协程池化技术之后了,10G 的资源和 2G 的资源显然没有很大区别。那么协程的优势又在哪里那?

  4. 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)。

那么反应式编程主要有什么特点和优势那?我总结了以下几点:

  1. 在降低资源占用的情况下(主要是线程)提高 CPU 的利用率。
  2. 编排异步任务,JVM的 Future 和 CompleteFuture 对异步任务的编排能力非常差,(例如任务失败后重试、异常处理、多个异步任务协作等)。而 Flux 以通过操作符轻易的对异步任务进行编排。
  3. 拥抱延迟执行,在没有订阅的情况下什么都不会发生,Flux 只是一个占位符不会有任何副作用,只有在订阅的时候才会执行。
  4. 可以轻易执行并发,而不是直接使用线程池(从命令式并发到声明式并发)。
  5. 声明式的异常处理和重试机制。而不是使用try-catch。
  6. 流控制和回压能力。

当然反应式编程也有一定的缺点:

  1. 学习路线陡峭,和常用的思维方式不同。
  2. 异常堆栈难以进行跟踪。
  3. 相比于命令式编程更容易导致内存泄漏。

2. 反应式编程的实际案例

在很长一段时间里,我都没有找到反应式编程的实际落地场景。直到最近我看了《RxJava 反应式编程》中 ”将反应式编程应用于已有应用程序“一节。

我最近做了一个监控巡检的需求,就使用了反应式编程,具体需求如下:

  1. 从数据库获取上一周要发送人的列表。(包含两个字段,并且需要去重,所以使用了 megrge() 和 distinct() 操作符)。
  2. 对每个发送人的监控大屏进行截图并上传 AWS S3(利用 flatMap() 和 subscribeOn() 实现并发截图和上传 )。
  3. AWS S3 从下载截图,发送邮件。(利用 flatMap() 和 subscribeOn() 实现并发下载和发送 )

当然在整个过程中,使用 buffer() 和 blockLast() 作为异步和同步之间的桥梁也是非常重要的。

3. 最后

对于想要了解和学习反应式编程的同学,非常推荐阅读一下《RxJava 反应式编程》。我也总结一些反应式编程的教程,大家可以参考。