为什么现代系统需要一个新的编程模型

akka版本2.6.9
版权声明:本文为博主原创文章,未经博主允许不得转载。

 actor模型是Carl Hewitt在几十年前提出的,作为在高性能网络中并行处理的一种方法(当时还没有这种环境)。今天,硬件和基础设施的能力已经赶上并超过了Hewitt的愿景。因此,构建具有复杂需求的分布式系统会遇到一些挑战,这些挑战无法用传统的面向对象编程(OOP)模型完全解决,但可以从actor模型获的较大的启发。
 今天,actor模型不仅被认为是一种高效的解决方案,而且已经在世界上一些最复杂的应用程序的生产环境中得到了证明。为了突出actor模型所解决的问题,本主要讨论了以下传统编程假设与现代多线程、多cpu架构现实之间的不匹配:

  1. 封装的挑战
  2. 现代计算机结构*享内存的错觉
  3. 调用堆栈的假象

一. 封装的挑战
 OOP的核心支柱是封装。封装说明对象的内部数据不能直接从外部访问;只能通过调用一组经过装饰的方法来修改它。对象负责公开保护其封装数据的不变特性的安全操作。
 例如,对有序二叉树实现的操作必须不允许违反树排序不变性。调用者希望排序是完整的,并且在查询树中特定的数据片段时,他们需要能够依赖这个约束。
 在分析OOP运行时行为时,我们有时会画一个消息序列图来显示方法调用的交互。例如:
为什么现代系统需要一个新的编程模型
 不幸的是,上面的图表没有准确地表示实例在执行期间的生命线。实际上,线程执行所有这些调用,不变量的实施发生在调用方法的同一个线程上。用执行线程更新图表,它看起来是这样的:
为什么现代系统需要一个新的编程模型
 当您试图对多线程所发生的事情进行建模时,这种澄清的重要性就变得很清楚了。显然,我们画得很整齐的图表变得不够用了。我们可以试着举例说明多线程访问同一个实例:
为什么现代系统需要一个新的编程模型
 在执行的某一段中,两个线程进入同一个方法。不幸的是,对象的封装模型不能保证该部分中发生的任何事情。这两个调用的指令可以以任意的方式交错,如果两个线程之间没有某种类型的协调,就无法保持不变量的完整性。现在,想象一下这个问题由于许多线程的存在而变得复杂。

 解决这个问题的常见方法是添加一个锁在这些方法。虽然这确保了在任何给定的时间最多有一个线程进入方法,但这是一个非常昂贵的策略:

  • 锁严重限制并发性,在现代CPU体系结构上,锁的开销非常大,需要从操作系统中进行大量工作,以挂起线程并在稍后恢复它。
  • 调用者线程当前被阻塞,因此它不能做任何其他有意义的工作。即使在桌面应用程序中,这也是不可接受的,我们希望应用程序面向用户的部分(其UI)能够响应,即使在运行较长的后台作业时也是如此。在后端,阻塞完全是浪费。有人可能认为这可以通过启动新线程来补偿,但线程也是一个代价高昂的抽象。
  • 锁引入了一个新的威胁:死锁。

 锁的使用虽然解决了部分问题,但这始终不是一个双赢的局面:

  • 没有足够的锁,状态就会被破坏。
  • 如果有很多锁,性能会受到影响,很容易导致死锁。

 此外,锁只在本地工作得很好。当涉及到跨多台机器进行协调时,唯一的替代方案是分布式锁。不幸的是,分布式锁的效率比本地锁低几个量级,并且通常会对向外扩展施加硬性限制。分布式锁协议需要在网络上跨多台机器进行多次通信往返,因此延迟非常高。
 在面向对象语言中,我们通常很少考虑线程或线性执行路径。我们经常想象一个系统作为网络应对方法调用的对象实例,修改他们的内部状态,然后通过方法调用相互交流推动整个应用程序状态:
为什么现代系统需要一个新的编程模型
 然而,在一个多线程分布式环境中,实际发生的是,线程“穿越”这个网络对象实例的方法调用。因此,线程是真正驱动执行:
为什么现代系统需要一个新的编程模型
总而言之:

  • 对象只能保证封装(不变量的保护)在面对单线程访问时,多线程执行几乎总是导致损坏内部状态。在同一个代码段中有两个竞争线程可能会违反每个不变式。
  • 虽然锁似乎是支持多线程封装的自然补救方法,但实际上,在任何实际规模的应用程序中,它们都是低效的,而且很容易导致死锁。
  • 锁在本地工作,试图让它们分布式存在,但提供有限的扩展潜力。

二. 现代计算机结构*享内存的错觉
 80 -90年代的编程模型认为,写入变量意味着直接写入内存位置(这在某种程度上混淆了局部变量可能只存在于寄存器中的这一事实)。在现代的架构中——如果我们稍微简化一下的话——cpu会直接写入缓存线,而不是直接写入内存。大多数缓存都是在CPU核心的本地进行的,也就是说,一个核的写入对另一个核是不可见的。为了使本地更改对另一个核心可见,从而对另一个线程可见,需要将缓存线发送到另一个核心的缓存。
 在JVM上,我们必须通过使用易失性标记或原子包装器显式地表示要跨线程共享的内存位置。否则,我们只能在一个锁着的部分访问它们。为什么我们不把所有变量都标记为volatile呢?因为跨核心传送缓存线是一项非常昂贵的操作!这样做会隐式地使所涉及的核心停止执行额外的工作,并导致缓存一致性协议(cpu用于在主存和其他cpu之间传输缓存线的协议)上出现瓶颈。其结果是经济放缓的程度。
 即使开发人员知道这种情况,找出哪些内存位置应该标记为volatile,或使用哪些原子结构是一种黑暗的艺术。

总而言之:

  • 不再存在真正的共享内存,CPU核心之间显式地传递数据块(缓存线),就像网络上的计算机那样。cpu间通信和网络通信的共同点比许多人意识到的要多。现在,通过cpu或网络计算机传递消息是一种常态。
  • 与通过标记为shared或使用原子数据结构的变量隐藏消息传递方面不同,更有纪律和原则的方法是将状态保持为并发实体的本地状态,并通过消息显式地在并发实体之间传播数据或事件。

三. 用堆栈的假象
 今天,我们经常认为调用堆栈是理所当然的。但是,它们是在并发编程不那么重要的时代发明的,因为多cpu系统还不常见。调用堆栈不跨线程,因此不建模异步调用链。
 当线程打算将任务委托给“后台”时,问题就出现了。实际上,这实际上意味着委托给另一个线程。这不能是一个简单的方法/函数调用,因为调用严格地是线程本地的。通常发生的情况是,“调用者”将一个对象放入一个由工作线程(“被调用者”)共享的内存位置,而工作线程依次在某个事件循环中获取该对象。这允许“调用者”线程继续执行其他任务。
 第一个问题是,如何通知“调用者”任务已经完成?但是,当任务因异常而失败时,会出现更严重的问题。异常传播到哪里?它将传播到工作线程的异常处理程序,完全忽略实际的“调用者”是谁:
为什么现代系统需要一个新的编程模型
 这是一个严重的问题。工作线程如何处理这种情况?它可能无法解决这个问题,因为它通常忽略了失败任务的目的。需要以某种方式通知“调用者”线程,但是没有调用堆栈需要异常展开。失败通知只能通过侧通道来完成,例如,在“调用者”线程准备好之后,将错误代码放在它希望得到结果的地方。如果没有此通知,则永远不会通知“调用者”失败,任务将丢失!这与网络系统的工作方式惊人地相似,在网络系统中,消息/请求可能在没有任何通知的情况下丢失/失败。
 当事情真正出错,由线程支持的工作程序遇到bug并以不可恢复的情况结束时,这种糟糕的情况会变得更糟。例如,由错误引起的内部异常会气泡到线程的根,并导致线程关闭。这立即提出了问题,谁应该重新启动线程承载的服务的正常操作,以及如何将其恢复到已知的良好状态?乍一看,这似乎是可管理的,但我们突然面临一个新的、意想不到的现象:线程当前正在处理的实际任务不再位于从其中获取任务的共享内存位置(通常是一个队列)。实际上,由于异常到达顶部,展开所有调用堆栈,任务状态完全丢失!我们已经丢失了一条消息,尽管这是与网络无关的本地通信(消息丢失是意料之中的)。

总而言之:

  • 要在当前系统上实现任何有意义的并发性和性能,线程必须在不阻塞的情况下在彼此之间有效地委托任务。对于这种任务委托并发(在网络/分布式计算中更是如此),基于调用堆栈的错误处理就会失效,需要引入新的显式错误信号机制。失败成为域模型的一部分。
  • 具有工作委托的并发系统需要处理服务故障,并有原则性的方法从故障中恢复。这些服务的客户端需要知道,在重新启动期间,任务/消息可能会丢失。即使没有发生丢失,响应也可能会因为之前排队的任务(一个长队列)、垃圾收集引起的延迟等原因而任意延迟。面对这些问题,并发系统应该像网络/分布式系统一样,以超时的形式来处理响应的最后期限。