响应式编程初见——从一块蛋糕说起

Posted 现代魔术工房

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了响应式编程初见——从一块蛋糕说起相关的知识,希望对你有一定的参考价值。

一个故事

有这样一个神奇的村落,村里的每个人都爱吃蛋糕,并且都定制自己喜欢的蛋糕。然而,村里的物资不算丰富,只有一套专门用来加工蛋糕的设备(假设蛋糕只能用这个设备来加工),但可以找到许多会做蛋糕的工人。现在,有100个村民都想吃定制蛋糕,如何才能让所有人都尽快吃到自己想要的蛋糕呢?

最简单的做法,就是雇一个工人,让这100个村民按照顺序轮流让工人做蛋糕,一个村民做完之后换下一个。显然,这样做可以让加工设备达到最大利用率,但是随之而来的是公平性问题。虽然加工一个蛋糕的时间并不长,第一个村民可以立刻吃到蛋糕,但排在最后的倒霉村民要等到前面99个村民都吃完蛋糕之后,才能开始做自己的蛋糕,这段时间他的肚子早就饿扁了。不仅如此,一旦中间某个蛋糕的要求特别复杂,要花费三倍平均时间才能做完,那么所有后面的人都要等待更长的时间。因此,所有人都认为,这显然不是一个优秀的解决方案。

接下来有人提出,可以雇100个工人,每个人都只做一个蛋糕。这些工人按照顺序轮流使用设备,每当使用一段固定时间之后,就让给下一个人。这样一来,村民吃到蛋糕的时间,只和他的蛋糕需要的制作时间,以及工房的繁忙程度(对所有人来说都一样)有关,和自己在队伍中的位置无关,再也不需要碰运气了。

这个方案一提出来,大家都很满意,然而当人们真正开始动手的时候,抱怨声又响起来了:效率实在太低了!在这种方案下,时常出现工作刚做到一半就被计时器强制打断的尴尬情况,此时要收拾一堆“烂摊子”,做不了任何有意义的工作。不仅如此,蛋糕工房里还不得不准备100张椅子,用来让没做完蛋糕的工人休息,以便他们在需要时能尽快来到机器前。拥挤不堪的房间不仅令人不快,更降低了换人效率。要知道,在这之前只有一个工人,根本不需要占用任何额外空间。无奈之下,人们只得请村中最聪明的智者帮他们解决这个问题。

这位智者可不一般,他写过的代码比所有人吃过的蛋糕都多。只见他摸了摸寸草不生的头顶,很快给出了自己的解决方案。首先,他把蛋糕的制作过程分成很多个相对独立的步骤,例如和面、发酵、烤制、涂奶油等等,这些步骤不需要一口气完成,两个步骤之间可以轻松中断。随后他雇了两个人,一个看门人守在工房门口,一个工人一直待在机器前。这一次,村民们只需要把自己的需求告诉看门人,他会给蛋糕画一个“设计图”,把所有的制作步骤都写在上面,之后村民就可以回家等着了。随后,看门人会把这些设计图交给工人,他制作蛋糕的方式很简单:每次随机挑一个蛋糕,只做一个步骤,做完后再重新挑。当一个蛋糕完成时,工人就会让看门人打电话通知对应的村民来取蛋糕。

在智者精心设计的流水线的帮助下,加工过程非常高效。机器一直都在运作,没有换人期间的空闲,而蛋糕工房里也再也不需要挤进去100个工人了。同时,在随机化的生产模式下,所有人吃到蛋糕的时间还是和顺序无关。最后,每个人都开心地吃到了蛋糕。人们都问智者,这么巧妙的主意你是怎么想到的呢?

故事的背后

稍有开发经验的读者,一定能看出这个故事中的人和物在计算机中的对应概念:村民对应请求,工人和看门人对应线程,蛋糕对应要处理的数据,椅子对应内存,而机器对应CPU的物理核心。于是,整个故事就就可以抽象成“多线程模型在单核CPU下的性能优化”。当处理并发请求时,前面的三种解决方案,也分别对应着下面的三种模型:

  • 单线程轮询处理请求
  • 阻塞模型:每个请求开一个新线程,在各自线程内处理请求
  • 非阻塞模型(也叫响应式模型)

智者之所以能想到这个主意,当然是受到了“响应式模型”的启发。那么它到底是什么呢?为了解释这个问题,首先有必要理解线程的性质。线程,也就是故事中做蛋糕的工人,只是一坨数据的集合体,不能自己执行自己的代码。在计算机中,构成线程的数据,包括调用栈和程序计数器(PC),也就是所谓的“执行上下文”,这个上下文完整定义了它的指令序列(蛋糕的配方)和当前状态(蛋糕做到了哪一步)。这坨数据想要做出有意义的行为(做蛋糕),必须首先把CPU资源(加工机器)分配给它,由它的指令序列控制CPU来执行操作。离开了CPU,线程不过是一组冷冰冰的数据,正如工人离开了机器就做不出蛋糕。

当大量的请求同时到来时,只使用和CPU核心数量相等的线程进行轮询处理肯定是不够的,正如故事所述,这样虽然效率最高,却会带来巨大的响应延迟,也存在慢速请求拖垮整个系统的风险。稍微好一些的做法就是为每个请求分配一个线程,此时线程数量必然会超过CPU的核心数,只能由操作系统的调度机制(例如按照时间划分)来分配CPU资源,使得所有请求都能尽可能公平地分配到处理时间。然而,这样做虽然得到了更短的最长响应时间(最后一个人吃到蛋糕的时间),却显著降低了系统整体的吞吐量(加工机器的工作效率)——切换线程也是很大的开销,在此期间系统无法进行任何有意义的工作。当请求的数量增长到极限,频繁的线程切换会将系统的吞吐量拖到极低的水平,最长响应时间甚至不如轮询——时间都花在换人上了,怎么干活呢?除此之外,大量的线程会占用大量的内存空间,进一步降低了系统性能。

铺垫了这么多,总算该说说响应式模型和它的优势了。在这种模式下,接受请求的线程(看门人)只需要在一个对象中构造出处理请求所需的流程(蛋糕的设计图),也就是函数调用链,但不去自己执行,而是把这个对象交给专门的执行线程(工人)来执行,之后就可以去接受下一个请求了。请求的发起者无需阻塞在请求之上来处理它,而是在请求处理完成后得到异步的通知,因此响应式模型是一种非阻塞模型。由于构造这个流程所需的工作量很小,接受线程处理一个请求的速度很快,这样甚至可以只用一个线程通过轮询来接受全部的请求,没有任何切换开销。执行线程方面,一个CPU只对应一个线程,不使用操作系统的任务切换机制,而是在线程内部实现切换操作。这样一来,执行线程可以根据具体的业务需求(制作蛋糕的步骤)自行控制切换的时间和场合,将开销尽可能压到最低,甚至能有选择性地只处理一部分请求,将系统整体的吞吐量控制在合理的范围内,整个过程就如同流水线一般自然顺畅。不仅如此,随着CPU的核心数量增加,少数执行线程比起大量线程而言可以更好地平衡各自的负载,得到最短的响应时间。

响应式编程经常配合函数式编程使用,它们两个可谓是“天生绝配”。在函数式编程中,对一个请求进行处理并返回结果的完整流程,可以表示为一个如下形式的函数调用链:

response = f1(f2(...fn(request)))

这样一看,步骤的划分就十分明显了:每次嵌套调用就是一个步骤。因此,按照这个方式划分步骤,由不同执行线程处理这些步骤,就可以得到不错的性能。Spring WebFlux及其底层的Project Reactor框架就是依照这个原理来工作的。

以上是关于响应式编程初见——从一块蛋糕说起的主要内容,如果未能解决你的问题,请参考以下文章

理解响应式编程

响应式编程的实践

VSCode自定义代码片段—— 数组的响应式方法

VSCode自定义代码片段10—— 数组的响应式方法

作为前端,你需要知道 RxJS(响应式编程-流)

视频从Cycle.js谈函数式与响应式编程