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

7次阅读

共计 3474 个字符,预计需要花费 9 分钟才能阅读完成。

摘要:现在高要求的分布式系统的建造者遇到了不能齐全由传统的面向对象编程 (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 系列】之 为什么古代零碎须要一个新的编程模型?》,原文作者:荔子。

点击关注,第一工夫理解华为云陈腐技术~

正文完
 0