为什么现代系统需要新的编程模型(翻译)

演员模型几十年前由Carl Hewitt提出,作为在高性能网络中处理并行处理的一种方式 – 当时还没有这种环境。如今,硬件和基础设施能力已经赶上并超越了休伊特的愿景。因此,构建具有苛刻要求的分布式系统的组织遇到了传统的面向对象编程(OOP)模型无法完全解决的挑战,但是可以从actor模型中受益。

如今,演员模型不仅被认为是一种高效的解决方案 – 它已经在世界上一些要求最苛刻的应用中得到了验证。为了突出演员模型所解决的问题,本主题讨论了传统编程假设与现代多线程,多CPU架构的现实之间的以下不匹配:

封装的挑战

OOP的核心支柱是封装。封装规定对象的内部数据不能直接从外部访问; 它只能通过调用一组策划方法来修改。该对象负责公开保护其封装数据的不变性的安全操作。

例如,对有序二叉树实现的操作不得允许违反树排序不变量。调用者希望排序是完整的,当查询树中的某个数据时,他们需要能够依赖这个约束。

当我们分析OOP运行时行为时,我们有时会绘制一个消息序列图,显示方法调用的交互。例如:

序列图

不幸的是,上图不能准确地表示执行期间实例的生命线。实际上,线程执行所有这些调用,并且不变量的强制执行发生在调用该方法的同一线程上。使用执行线程更新图表,它看起来像这样:

带螺纹的序列图

当您尝试模拟多线程发生的情况时,此澄清的重要性将变得清晰。突然间,我们整齐地绘制的图表变得不充分。我们可以尝试说明访问同一​​实例的多个线程:

序列图与线程交互

有一个执行部分,其中两个线程进入相同的方法。不幸的是,对象的封装模型并不能保证该部分中发生的事情。两个调用的指令可以以任意方式交错,这消除了在两个线程之间没有某种类型的协调的情况下保持不变量完整的任何希望。现在,想象一下这个问题因许多线程的存在而加剧。

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

  • 严重限制了并发性,它们在现代CPU架构上非常昂贵,需要从操作系统中重载以暂停线程并在以后恢复它。
  • 调用者线程现在被阻止,因此它无法执行任何其他有意义的工作。即使在桌面应用程序中,这也是不可接受的,我们希望即使在长时间后台作业运行时,也要使面向用户的应用程序部分(其UI)保持响应。在后端,阻塞是彻头彻尾的浪费。有人可能认为这可以通过启动新线程来补偿,但线程也是一种代价高昂的抽象。
  • 锁定会带来新的威胁:死锁。

这些现实导致了一个不赢的局面:

  • 没有足够的锁,状态就会被破坏。
  • 由于存在许多锁定,性能会受到影响并且很容易导致死锁。

此外,锁只能在本地很好地工作。在协调跨多台机器时,唯一的选择是分布式锁。不幸的是,分布式锁定的效率比本地锁定效率高几个级别,并且通常会对扩展性施加硬性限制。分布式锁定协议需要在多台计算机上通过网络进行多次通信往返,因此延迟可以通过。

在面向对象语言中,我们很少考虑线程或线性执行路径。我们经常将系统设想为对象实例的网络,它们对方法调用作出反应,修改其内部状态,然后通过方法调用相互通信,从而驱动整个应用程序状态:

交互对象网络

但是,在多线程分布式环境中,实际发生的是线程通过以下方法调用“遍历”此对象实例网络。因此,线程真正推动了执行:

线程遍历的交互式对象网络

总结

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

现代计算机体系结构中共享内存的错觉

80′-90的编程模型概念化写入变量意味着直接写入存储器位置(这有点混淆了局部变量可能仅存在于寄存器中的水)。在现代架构上 – 如果我们稍微简化一下 – CPU正在写入缓存行而不是直接写入内存。这些高速缓存中的大多数都是CPU内核的本地高速缓存,也就是说,一个内核的写入对另一个内核是不可见的。为了使本地更改对另一个核心可见,从而对另一个线程可见,需要将缓存行传送到另一个核心的缓存。

在JVM上,我们必须使用volatile标记或

1
Atomic

包装器明确表示要跨线程共享的内存位置。否则,我们只能在锁定的部分访问它们。为什么我们不将所有变量标记为volatile?因为跨核心运送缓存行是一项非常昂贵的操作!这样做会隐式地阻止所涉及的核心进行额外的工作,并导致高速缓存一致性协议(协议CPU用于在主存储器和其他CPU之间传输高速缓存线)的瓶颈。结果是减速的幅度。

即使对于了解这种情况的开发人员来说,确定哪些内存位置应该标记为易失性,或者使用哪种原子结构是一种黑暗的艺术。

总结

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

调用堆栈的错觉

今天,我们经常将调用堆栈视为理所当然。但是,它们是在一个并发编程并不重要的时代发明的,因为多CPU系统并不常见。调用堆栈不会跨线程,因此不会为异步调用链建模。

当线程打算将任务委托给“后台”时,会出现问题。实际上,这实际上意味着委托给另一个线程。这不能是一个简单的方法/函数调用,因为调用是严格本地的线程。通常发生的是,“调用者”将一个对象放入由工作线程(“被调用者”)共享的内存位置,而该工作线程又在某个事件循环中拾取它。这允许“调用者”线程继续前进并执行其他任务。

第一个问题是,如何通知“呼叫者”完成任务?但是当任务因异常而失败时会出现更严重的问题。异常传播到哪里?它将传播到工作线程的异常处理程序,完全忽略了实际的“调用者”是谁:

异常不能在不同的线程之间传播

这是一个严重的问题。工作线程如何处理这种情况?它可能无法解决问题,因为它通常无视任务失败的目的。需要以某种方式通知“调用者”线程,但是没有调用堆栈来解除异常。失败通知只能通过旁道进行,例如将错误代码放在“调用者”线程否则要求结果就绪的情况下。如果没有这个通知,“调用者”永远不会收到失败的通知,任务就会丢失!这与网络系统如何工作,消息/请求在没有任何通知的情况下丢失/失败的情况类似。

当事情出现问题并且由线程支持的工作人员遇到错误并最终陷入无法恢复的情况时,这种糟糕的情况会变得更糟。例如,由bug引起的内部异常会冒泡到线程的根目录并使线程关闭。这立即提出了一个问题,谁应该重新启动线程托管的服务的正常操作,以及如何将其恢复到已知良好的状态?乍一看,这似乎是可以管理的,但我们突然面临一个新的意外现象:线程当前正在处理的实际任务不再位于从中获取任务的共享内存位置(通常是队列) )。实际上,由于异常到达顶部,展开所有调用堆栈,任务状态完全丢失!我们已经丢失了一条消息,即使这是本地通信而没有涉及网络(消息丢失的预期)。

综上所述:

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

接下来,让我们看看演员模型的使用如何克服这些挑战。

Add a Comment

电子邮件地址不会被公开。 必填项已用*标注