反应式编程是当前非常热门的话题,构建反应型系统的程序库正如雨后春笋般出现在不同平台和语言中。类似 Reactive Manifesto (查看 InfoQ 对 Francesco Cesarini 和 Viktor Klang 的采访)这样的倡议正在推进这一想法,另外 Reactive Steam (查看 InfoQ 上的 Reactive Streams with Akka Streams )还努力为不同反应式编程库提供互操作性。
但是反应式编程到底是什么样的?它们间各自又是如何实现的?InfoQ 将三位来自反应式编程的先驱者聚集到虚拟论坛,认识并详细了解各个项目。
备注:我们很可能忽略了你最喜欢的处理数据流的编程库或其它反应式用例。你可以给我们留言告诉我们其它你想让我们覆盖的编程库(以及一位我们可以联系的优秀的论坛参与者),我们将在虚拟论坛的后续文章中继续讨论。
参与者:
Viktor Klang是 Typesafe 的一名首席架构师, Akka 项目前领导者。
Timothy Baldridge是 Cogniteck 一名 Clojure 开发人员, Core.Async 贡献者。
Jafar Husain是 Netflix 一名技术主管,同时也是 RxJava 的贡献者。
我们要求他们回答了以下问题:
- 请简单介绍一下你们的编程库或框架。
- 你们是如何实现反应性 / 并发?其构建于什么基元、概念及语言结构上?开发人员可以控制代码的执行方式及处理背压吗?
- 它们都是如何与平台中的其他功能集成的,比如:I/O 库、集合(Collections)、可用算法等?
- 与语言中的其他方式相比,为什么你们的解决方案更好?其主要功能是否防止了错误,降低了困难,以及能使用新的编程风格?
- 你们的编程库是哪类问题的最好解决方案,什么是运用它们的初始动机?还有在什么时候你们会采用其它的方法?
- 我们是否需要重新思考编程的方式?也就是说,这些解决方案是否有某些限制?是否可以只用纯函数?还是说需要将所有东西封装起来,亦或必须具有延续性?
- 你们的编程语言或平台是否带来了好处,还是说它会使事情更加复杂化或使彻底性变得不可能?在实施上,有哪些特定挑战你们愿意与我们分享?
InfoQ:请简单介绍下你们的编程库或框架。
Viktor Klang:Akka 是基于 JVM(主要针对 Java 和 Scala,但也绑定到 JRuby、Clojure 及其它更多语言)的反应式应用的程序库和运行环境。Akka 采用Actors(查看 Actor Mode )作为以下方面的主要结构:并发、分布、快速恢复能力、从小设备到大型服务器、单节点到数千节点集群的不同规模的扩展。
Timothy Baldridge:Core.Async 编程库允许在 Clojure(JVM 上)和 ClojureScript(JavaScript 虚拟机上)中使用 CSP 风格编程( Communicating Sequential Processes )。顾名思义,该范式提倡了将代码组织为逻辑线程通信于各信道上。理解 CSP 很好的方法是将其看成制造流水线,其中处理执行的逻辑线程为工人,工人则用队列所提供的输送带来沟通。
Jafar Husain:Rx 提出了一个问题:事件和类似数组或集合的组合间有什么不同?现在大部分开发者处理事件和组合的方式非常不同。Rx 为事件和组合带来了统一的编程模式。该编程库通过使用类似匹配、过滤或减少等方式将它们进行转换,从而允许我们在处理事件时采取与组合相同的方式。与其构建状态机来响应事件顺序,我们不如用这些方法从单一事件来创建复杂事件。
InfoQ:你们是如何实现反应性 / 并发?其构建于什么基元、概念及语言结构上?开发人员可以控制代码执行方式及处理背压吗?
Timothy Baldridge:我们将代码组织成逻辑线程。这些线程与 OS 线程可能 1:1 匹配也可能不是,这取决于开发人员。逻辑线程间的通信是通过等待队列,即信道完成。这些信道提供了背压,并支持同一信道中的多个读写操作。由于该模型的简易性,我们根据需要可以很容易地将并行拓展到更高层次:只要往同一信道分配更多的逻辑线程用于读写。
Jafar Husain: RX 引进了新的组合类型:Observable。Observable 类似于 UI 元素中的事件,但 Observable 是类似可以用于传递的列表或集合的第一类对象。
与事件一样,我们可以订阅回调(callback)到 Observable,这样每当异步数据到达时,我们都会收到提醒。然而 Observable 添加了完成(completion)这一概念到事件流。这一简单添加允许开发人员构建复杂的,基于事件的系统,该系统甚至无需退订回调就能完成。
一般情况下,当构建类似用户界面这样长期使用的应用时,我们必须小心地退出事件订阅,以防止内存泄漏。即使它们以后不会再被触发,事件也能控制处理程序(Handler)(比如:网页 DOM 加载时会触发“document.onload”)。与此相反,Observable 流传送完数据后,处理程序会自动退出订阅。
我们可以通过使用 Scheduler 控制操作应何时发生。Scheduler 控制 Observable 何时及何地通知我们数据已到达。Scheduler 可以保证订阅或提醒在不同线程中发生,或一定时间后在同一线程中。
举个例子,我们可以安排两个 Observable 执行于线程池中,将它们并行起来。在类似 JS 这样的单线程环境中,Scheduler 可以用于观察事件循环中关键时刻的事件流。
关于背压,我们总是可以从 Observable 中退订以阻止数据流,然后再重新订阅。我们正在探索更加复杂的方式,你将在 Rx 的后续版本中看到它们。
Viktor Klang:正如前面提到的,我们主要抽象就是 Akka Actors(“Actors”),Actor 模型的一个 JVM 实现。Actor 之间在逻辑上相互隔离,这意味着多个 Actor 可以并行运行,它们之间存在叫做 ActorSystem – Actor 的逻辑层次结构。
Actor 的核心是一个行为,该行为适用于其传入的信息,然后我们通过发送信息与 Actor 沟通,Actor 会依次处理这些信息。在处理信息时,该信息可以决定创建新 Actor;发送其所知信息到 Actor;修改处理下一个信息的行为;或者这些行为的任意组合。不论是 Actor 的创建还是发送信息到 Actor 都是异步进行的。这说明 Actor 本身是事件驱动的。
一个 Actor 包含以下内容:
- 创建它的“父类”Actor
- 一个行为:应用于下一个信息的处理
- 一个邮箱:当其忙于处理信息时,可用于存储入站信息
- 一个调度器:用于协调 Actor 的执行
- 还有其所创建的零到 N 个“子类”Actor
一个 Actor 是一个轻量级构造,典型的大小在 450 bytes,意味着它可以在大型硬件系统中同时运行数百个万个实例。
执行
Actor 的执行由 Akka 的调度程序(Dispatcher)完成,它们通常由 ExecutorService 支持。所有这些,开发人员都可以通过编程方式或外部配置实现。事实上,大部分东西都可以由开发人员配置和定制,比如邮箱实现、ExecutorService 和调度程序等。
背压
背压通过 Acking/Naking 入站信息来实施,这意味着无等待,异步背压。
快速恢复
快速恢复由所谓的监督(Supervision)完成,其中从 Actor 内部抛出的异常将升级到其父类中,同时失败 Actor 的执行将被暂停,直到其父类决定好如何处理该故障。可能会出现以下几种结果:
- 恢复(忽略故障,恢复失败 Actor 信息处理)
- 重新启动(丢弃失败 Actor 的旧实例,然后新建一个,与此同时保持邮箱的完好性)
- 停止(终止失败的 Actor)
- 升级(重新抛出故障,升级问题到“祖父”类)
这种监督层次(Supervisor Hierarchy)意味着 Actor 可以创建新的 Actor 用于执行潜在的危险操作,然后使用监督处理任何故障,从而避免风险。这通常被称为“错误内核模式(The Error Kernel Pattern)”。
有趣的是由于信息驱动的特征结合了故障探测器(Failure Detectors),当监督层次分散在多个节点时,它也能运行。
常见模式是多个 Actor 组使用了不同调度器和 ExecutorService 用于隔离它们之间的执行。因此如果有一组 Actor 执行被破坏,其它的则不会受影响。而这通常被称为“防水层(Bulkheading)”
InfoQ:它们都是如何与平台中的其他功能集成,比如:I/O 库、集合(Collection)和可用算法等?
Jafar Husain:Observable 执行了大部分集合变换函数,这些函数有 Java8 中的流类型,C#中的枚举类型以及 Javascript 中的数组类型。如果你知道如何使用类似匹配,过滤及降低等函数用于转换集合,很容易就可以利用 Rx 构建复杂异步系统。
Rx 提供了帮助方法(helper method)用于转换任何异步接口为 Observable。这使得 Rx 能够简单地逐步集成到其它现有系统中。当然你也可以从小地方入手,比如在系统中某一事件子集中使用组合,然后逐步扩散。
Viktor Klang:Akka 带有基于 Java 的 NIO 功能的 IO 库,该库将 IO 暴露为简单且熟悉的消息传递:发送和接收数据块。
对于集合,你可以在 Actor 中任意使用,但当发送带有集合的消息时,我们强烈建议发送不可变的集合。
除此之外,你可以根据需要使用任何 JVM 库。
Timothy Baldridge:Java 和 JavaScript 虚拟机上的大部分平台库通过回调支持异步。在最底层,Core.Async 信道使用了相同模型。因此,很容易就能从宿主虚拟机获取回调将事务放入到信道中,或者将回调连接到信道去调用一些虚拟机方法。
InfoQ:与同语言中的其他方式相比,为什么你们的解决方案更好?其主要功能是否防止了错误,降低了困难,及允许了新的编程风格?
Viktor Klang:Actor 是个通用结构,它结合了并发、分布和故障管理,允许从上到下及从内到外的拓展。对于更详细的用例,类似 Futures , Agents 等其它工具则可能更加适用。
Timothy Baldridge:通常情况下,当应用程序到达到一定规模,开发人员会创建分布式队列将项目分割成小块且更易于管理的部分。该模型同时也为拓展带来了更多选项,开发人员可以根据需要调整队列中读者和写者数量,从而提高性能。
值得一问的是,为什么不在一开始就以这种方式创建我们的系统?为什么不以高度解耦系统开始,将应用程序设计为逻辑线程集沟通于进程队列上。然后,当我们需要拓展应用程序时,我们可以将这些进程队列替换为分布式队列。
以这种方式构建的系统往往更易于调试,因为每个组件在通过信道连接到其他组件前,都可以单独进行测试。
Jafar Husain:Rx 学起来非常简单。它充分利用了开发人员的现有知识:如何通过使用类似匹配、过滤和降低等转换函数构建集合。事实上,最难的部分莫过于放弃这一想法:事件在某种层度上与我们每天正在用的集合不同。
Rx 还帮助开发人员避免常见的类似内存泄漏这样的陷阱。当构建传统基于事件的系统时,开发人员通常依赖于状态机来决定什么时候从事件中退订。Rx 允许我们构建以声明形式指定其结束条件的事件流。一旦事件流结束,它会清除所有未退订订阅。
InfoQ:你们的编程库是哪类问题的最好解决方案,什么是运用它们的初始动机?还有在什么时候你们会采用其它的方法?
Timothy Baldridge:当应用程序需要与其它系统进行异步交互时,Core.Async 可以很容易就成功应用。这些系统可以是外部队列服务、数据库、甚至是 HTML DOM。尽管如此,这一模式还是引入了小量的额外成本。因此如果你的唯一目标是并行的话,这个库不见得是个聪明的选择。比如:我就不会使用 core.async 构建 3D 光追踪器。所以如果你面临的问题是同步的,且大量并行,应该考虑别的更好的选择。
Jafar Husain:Rx 的想法来自 Erik Meijer 和 Brian Beckman 对迭代器模式(Iterator pattern)和观察者模式(Observer pattern)之间基本通信的观察。这两个模式都允许数据生成器逐步发送数据到数据消费者,一次一个。不同点在于,迭代器模式中由消费者控制从生成器获取数据,而在观察者模式中则由生成器控制将数据推送给消费者。
Erik 和 Brian 在观察者模式中注意到一个奇怪的漏洞。在迭代器模式中,当数据序列结束时,生成器发送到消费者的结束信号具有良好的定义,而在观察者模式中则完全没有这些语义。比如:DOM 事件就没有定义好的方法用于发送信号给消费者,通知它们数据已完全达到。
Erik 和 Brian 意识到这两个无处不在的设计模式可以统一起来,只要往观察者模式中添加“完成”这一概念。其结果就是个新的类型:Observable。这两个定义是对偶的,这意味着,任何可以用于构造 Iterable 的操作器都可以用于定义 Observable。而这更意味着查询事件流很可能和查询数据库一样。
Viktor Klang:Akka 由 Jonas Bonér 创建,目的在于将 Erlang 中好的概念移入到 JVM:尤其是拥有独立的通过信息沟通的进程,和通过 Supervision 处理故障。
Akka 背后的驱动原理之一是所有的基本操作的位置都是透明的,因此如果想结束 Actor 信息发送或监督它,其位置在哪里并不重要。因此如果你想将应用程序分布于多个机器,不论从更高的处理能力方面,还是快速恢复能力,Akka 都是完美的工具。
InfoQ:我们是否需要重新思考编程方式?也就是说,这些解决方案是否有某些限制?是否可以只用纯函数?还是说需要将所有东西封装起来,亦或必须具有延续性?
Timothy Baldridge:值得庆幸的是,一元或持续传递不是必须的。Core.Async 中的轻量级线程支持代码重写宏。该库用户只要将他们的代码封装在“go”块中,然后把其它的交给宏就可以了;重写代码到连接信道的回调中。这是该编程库最强大的功能之一,它允许开发人员编写出来的代码更像他们每天编写的命令式代码。
唯一需要多花点心思的地方是涉及 IO 的内容。该库将逻辑线程按其是否被 OS 线程支持分为两类。这两类线程适用于不同任务。专用线程通常用于 IO,而轻量级线程则推荐用于 CPU 较密集的任务。
Viktor Klang:我想说在一开始,你会一从开始就考虑沟通,这一流动于 Actor 之间的通信协议来改变编程方式。
如果想快速且有趣地完成这一过渡,我们可以将其看成一个与人交互的过程。因为我们总是彼此发送信息,可以是邮件、语言、即时通信或其它方式。而不是直接刺激别人的神经(与共享内存并行比起来)。
另外一个有趣的变化是我们可以开始考虑如何将架构分解成单独部分,然后独立运行。
第二个变化是对故障管理的考虑,对具有风险的操作的授权保障了应用程序的关键部分,避免了连锁故障。
由于 Actor 与其它 Actor 能够并行,因此不在 Actor 间共享可变状态变得很重要: Actor 应只通过消息进行交互。
但是 Actor 内部运行的代码,其行为是正常的,类似“call-me-some-methods”这样的代码。
Jafar Husain:如果你已经知道如何使用功能组成转换集合,你就不需要改变你的编程方式。值得指出的是:Observable 其实是持续 monad 的细微改写版本,其副作用将延迟到你退订后。然而,实践中,开发人员无需了解 monad 就能使用 RX。在 Netflix,我们培训开发人员使用该技术时,甚至不提及“Monad”一词。
InfoQ:你们的编程语言或平台是否带来了好处,还是说它会使事情更加复杂化或使彻底性变得不可能?在实施上,有哪些特定挑战你们愿意与我们分享?
Viktor Klang: Akka Actor 使得所创建的应用程序,不论其规模大小,都能根据需要简易地扩展或收缩。比如,我们近期在 Google Compute Engine 上完成的试验就在单一集群上运行了 2500 个 Akka 节点。
Scala,作为 Akka 的实现语言,对不可变、高效的数据结构有很好的支持。也使得用“案例类”创建不可变信息类型变得更加简单。
而说到挑战,我想到了两点:创建大多数并发协调的无锁版本;对 Akka 集群进行研究和实施,而这本身就是一漫长而有趣的过程。
Jafar Husain: 如果你的编程语言中带有闭包,显然以函数式风格编程会更简单。这可能是 Rx 导入到 Java 中最大的障碍了。Ben Christensen 负责 Netflix 贡献的流行开源 Rx 的 Java 接口,已经为类似 Scala、Clojure 和 Groovy 这样的宿主语言创建了适配器,使得 RX 能更容易地在 JVM 上使用。随着 Java 8 的发布,在 Java 中使用 Rx 将变得更加简单,因为它第一次引入了闭包到该语言中。
Timothy Baldridge:Clojure 语言所支持的较先进的宏系统允许开发人员根据需要扩展语言。这允许我们在编写整个 core.async 库时,根本不用接触 Clojure 编译器。这点的附加好处是导入编程库到 ClojureScript 只需几个小时,而非几天或几周。
这在我看来,显示了 Clojure 和 core.async 的真正力量。能在 JVM 上为 Clojure 编写异步代码,然后将其在浏览器中“刚刚好运行”于 ClojureScript 上,这已经非常强大。
关于专家
Jafar Husain作为软件开发人员已经工作了 18 年。他曾为通用电气、微软和 Netflix 等公司开发过软件。目前他是 Javascript 标准委员会 TC39 的成员。他擅长通过使用函数型反应式编程构建 Web 服务器和客户端。他还是“Falkor”协议的架构师,该协议是所有 Netflix 设备的动力来源。作为具有高度评价的演讲者,Jafar 曾在 QCon 和 YOW! 上就反应式编程做了相应演讲!还在 Channel 9 做了相关主题的访谈。他还攥写了互动式培训软件帮助开发人员学习功能型反应式编程。
Timothy Baldridge(@timbaldridge)是 Cognitect Inc. 的一名开发人员。他来自美国科罗拉多州丹佛市的山岭地区。Timothy 是个掌握 Clojure、C#、Python 和 Erlang 等多种编程语言的开发人员。最近,他深入参与到 Clojure 的 Core.Async 库的开发中,其中他参与设计和实现,并维护着该状态机的被称作“go”的代码重写宏。
Viktor Klang(@viktorklang)目前是 Typesafe 首席架构师,也是 Akka 项目前任技术主管。他在 JVM 上具有很深的背景,目前他对并发、异步、分布式和弹性编程特别感兴趣。
评论