为什么现代系统需要一个新的编程模型?
摘要:如今高要求的分布式系统的建造者遇到了不能完全由传统的面向对象编程(OOP)模型解决的挑战,但这可以从Actor模型中获益。
为什么现代系统需要一个新的编程模型?
Actor模型作为一种高性能网络中的并行处理方式由Carl Hewitt几十年前提出-高性能网络环境在当时还不可用。如今,硬件和基础设施的能力已经赶上并超越了Hewitt的愿景。因此,高要求的分布式系统的建造者遇到了不能完全由传统的面向对象编程(OOP)模型解决的挑战,但这可以从Actor模型中获益。
今天,Actor模型不仅被认为是高效的解决方案——这已经被世界上要求最高的应用所检验。为了突出Actor模型解决的问题,这个主题讨论以下传统编程的假设与现代多线程、多CPU体系架构之间的不匹配:
- 封装的挑战
- 现代计算机体系结构中共享内存的错觉
- 一个调用栈的错觉
封装的挑战
OOP的一个核心支柱是封装。封装表明一个对象的内部状态不能直接从外部访问;它只可以通过调用一组辅助的方法修改。对象负责暴露保护它所封装数据的不变性的安全操作。例如,在一个有序二叉树上的操作不允许违反树的有序性。调用者希望保持有序性,当查询树上一条特定的数据时,它们需要能够依赖这个约束。
当分析OOP运行时的行为时,我们有时候画出一个消息序列图展示方法调用的交互过程。例如:
不幸的是,上面的图表没能精确表示执行过程中对象的生命线。实际上,一个线程执行所有的调用,所有对象的不变体约束出现在同一个方法被调用的线程中。更新线程执行图,它看起来是这样:
当试图对多线程行为建模时,上面阐述的重要性变得明显了。突然,我们画出的简洁的图表变得不够充分了。我们可以尝试解释多线程访问同一对象:
有一个执行部分,两个线程调用同一个方法。不幸的是,对象的封装模型不能保证执行这部分时会发生什么。两个线程之间没有某种协调的话,两个调用指令将以不能保证不变体性质的任意方式相互交错。现在,想象一下这个由多个线程存在而变得复杂的问题。
解决这个问题的常见方法是给这些方法加一个锁。尽管这保证了在给定的时间内最多一个线程将执行该方法,但是这是一个代价高昂的策略:
- 锁严重限制了并发,锁在现代CPU体系结构中的代价很高,要求操作系统承担挂起线程并随后恢复它的重负。
- 调用者线程被阻塞,因此它不能做其他有意义的工作。在桌面应用中这是不能接受的,我们希望使应用程序的用户界面(UI)即使在一个很长的后台作业正在运行的时候也是可响应的。在后台,阻塞是完全浪费的。或许有人想到这可以通过开启一个新线程弥补,但线程也是一个代价高昂的抽象。
- 锁引入了一个新的威胁:死锁
这些事实导致一个无法取胜的局面:
- 没有足够的锁,状态会被破坏
- 有足够的锁,性能受损并很容易导致死锁
另外,锁只有在本地有用。当涉及跨机器协调时,唯一可选的是分布式锁。不幸的是,分布式锁比本地锁低效几个数量级,并且限制了伸缩性。分布式锁协议需要在网络中跨机器的多轮通信,因此延迟飞涨。
在面向对象语言中,我们通常很少考虑线路或线性执行路径。我们经常把系统想象成一个对象实例的网络,这些实例对象响应方法调用、修改自身内部状态、然后通过方法调用相互通信以驱动整个应用状态向前:
然而,在一个多线程的分布式环境中,实际发生的是线程沿着方法调用贯穿这个对象实例网络。因此,线程是真正的运行驱动者:
【总结】
- 对象只能在单线程访问时保证封装(不变体的保护),多线程执行几乎总会导致破坏对象内部状态。每个不变体可以被处于同一代码段相互竞争的两个线程违反。
- 虽然锁似乎是对维护多线程时的封装很自然的补救,实际上,在任何现实应用中锁很低效并很容易导致死锁。
- 锁在本地有用,但试图使锁成为分布式的,可以提供有限潜力的扩展。
现代计算机体系结构中共享内存的错觉
80-90年代的编程模型定义:写入一个变量意味着直接写到内存位置 (这在一定程上混淆了局部变量可能仅存在于寄存器)。在现代体系架构中,如果我们简化一下,CPUs会写到cache行而不是直接写入内存。大多数caches是CPU局部私有的,也就是,一个核写入变量不会被其他核看到。为了使局部改变对其他核可见,因此对于另一个线程,cache行需要被传送到其他核的cache。
在JVM中,我们必须通过使用volatile或Atomic显式地指示线程间共享的内存位置。否则,我们只能在锁定的部分访问这些内存。为什么我们不将所有变量标记为volatile?因为跨核传送cache行是一个代价非常高昂的操作!这样做会隐式地停止涉及做额外工作的核,并导致缓存一致性协议的瓶颈。(CPUs用于主存和其他CPUs之间传输cache行的协议)。结果便是降低数量级的运行速度。
即使对于了解这个情况的开发者,搞清楚哪个内存位置应该被标记为volatile或者使用哪一种原子结构是一门黑暗的艺术。
【总结】
- 没有真正的共享内存了,CPU核就像网络中的计算机一样,将数据块(cache行)显式地传送给彼此。CPU之间的通信和网络中计算机之间通信的相同之处比许多人意识到的要多。传送消息是如今跨CPUs或网络中计算机的标准。
- 相对于通过标记为共享或使用原子数据结构的变量来隐藏消息传递的层面,一个更规范和有原则的方法是保存状态到一个并发实体本地并通过消息显式地在并发实体间传送数据或事件。
一个调用栈的错觉
今天,我们常常将调用栈视为理所当然。但是,调用栈是在一个并发程序不那么重要的时代发明的,因为多CPU系统那时不常见。调用栈没有跨越线程,因而没有对异步调用链建模。
当一个线程意图委派一个任务给后台的时候会出现问题。实际上,这意味着委托给另一个线程。这不是一个简单的方法、函数调用,因为调用严格上属于线程内部。通常,调用者(caller)线程将一个对象放入与一个工作线程(callee)共享的内存位置,反过来,这个工作线程(callee)在某个循环事件中获取这个对象。这使得调用者(caller)线程可以向前运行和执行其他任务。
第一个问题是:调用者(caller)线程如何被通知任务完成了?但是当一个任务失败且带有异常的时候一个更严重问题出现了。异常应该传播到哪里?异常将被传播到工作者(worker)线程的异常处理器而完全忽略谁是真正的调用者(caller):
这是一个严重的问题。工作者(worker)线程如何处理这种情况?它可能无法解决这个问题,因为它通常不知道失败任务的目的。调用者(caller)线程需要以某种方式被通知,但是没有调用栈去返回一个异常。失败通知只能通过边信道完成,例如,将一个错误代码放在调用者(caller)线程原本期待结果准备好的地方。如果这个通知不到位,调用者(caller)线程不会被通知任务失败和丢失!这和网络系统的工作方式惊人地相似-网络系统中的消息和请求可以丢失或失败而没有任何通知。
在任务出错和一个工作者(worker)线程遇到一个bug并不可恢复的时候,这个糟糕的情况会变得更糟。例如,一个由bug引起的内部异常向上传递到工作者(worker)线程的根部并使该线程关闭。这立即产生一个疑问,谁应该重启由该线程持有的这一服务的正常操作,以及怎样将它恢复到一个已知的良好状态?乍一看,这似乎很容易,但是我们突然遇到一个新的、意外的现象:线程正在执行的实际任务已经不在任务被取走得共享内存位置了 (通常是一个队列)。事实上,由于异常到达顶部,展开所有的调用栈,任务状态完全丢失了!我们已经丢失了一条消息,尽管这是本地的通信,没有涉及到网络 (消息丢失是可期望的)。
【总结】
- 为了在当下系统实现有意义的并发和性能,线程必须以一种高效的、无阻塞的方式相互委派任务。有了这种任务委派并发方式(网络/分布式计算更是如此),基于栈调用的error处理失效了,新的、显式的error信号机制需要被引入。失败成为领域模型的一部分。
- 任务委派的并发系统需要处理服务故障并且有原则性的方法恢复它们。这种服务的客户端需要知道任务/消息会在重启中丢失。即使不丢失,一个响应或许会由于队列 (一个很长的队列) 中先前的任务而发生任意的延迟,由垃圾回收造成的延迟等等。在这些情况下,并发系统应该以超时的形式对待响应截止时间,就像网络/分布式系统一样。
本文翻译自https://doc.akka.io/docs/akka/current/guide/actors-motivation.html
本文分享自华为云社区《【Akka系列】之 为什么现代系统需要一个新的编程模型? 》,原文作者:荔子 。