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

Akka中最重要的便是actor模型。

几十年前,Carl Hewitt提出了actor模型,以作为在高性能网络下并行处理的一种方式。但是在当时并没有这样的环境,如今硬件和基础设施能力已经赶上并超越了Carl Hewitt当时的预料。所以,那些想构建高性能分布式系统的组织遇到的使用面向对象编程(OOP)模型无法完全解决的挑战,现在可以使用actor模型解决。

如今,actor模型不仅被认为是一种高效的解决方案,而且也已经在世界上一些要求苛刻的应用中得到了验证。为了突出actor模型所能解决的问题,本主题主要讨论传统编程思想与现代多线程多CPU架构之间的不匹配:

  • 封装的挑战

  • 共享内存的错觉

  • 调用栈的错觉

封装的挑战

OOP的核心是封装。封装规定对象内的数据不能直接从外部访问,只能调用方法来进行修改。对象需要暴露出一些安全的操作,这些安全操作用来维护其内部数据的约束。

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

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

不幸的是,上图并不能准确地表示执行期间实例的生命周期。实际上,所有这些调用发生在同一线程上。

当您尝试模拟多线程情况时,上面的这种表达方式就变得更加清晰了。因为我们可以通过下图来表示两个线程访问同一个实例:

两个线程进入同一个对象相同的方法,但是对象的封装模型并不能很好的表达这其中发生的事情。两个线程可以以任意方式交错,想象一下,如果是多线程,这个问题会更加严重。

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

  • 锁严重限制了并发性,它们在现代CPU架构上非常昂贵,需要操作系统暂停线程并且在之后还要恢复它。

  • 调用者线程被阻塞后无法执行任何其他有意义的工作。即使在桌面应用程序中这也是不可接受的,我们希望即使在后台有耗时比较久的作业运行时,也要保持面向用户的应用程序部分能够响应用户的请求。在后端,阻塞是彻头彻尾的浪费。有人可能认为这可以通过启动新线程来补偿,但线程的代价也非常高昂。

  • 使用锁还可能导致死锁。

这些现实导致了一种尴尬的局面:

  • 如果没有足够的锁,对象的正常状态会被破坏

  • 如果使用很多锁,性能会受到影响并且很容易导致死锁。

此外,锁只能在本地很好地工作。在协调跨多台机器时,唯一的选择是分布式锁。不幸的是,分布式锁的效率比本地锁效率要差几个级别,并且在扩展时有更多的限制。分布式锁需要在多台计算机上通过网络进行多次通信往返,因此还存在延迟。

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

但是,在多线程分布式环境中,实际上是线程通过方法调用“遍历”此对象网络。因此,真正推动应用程序运行的是线程:

总结:

  • 对象封装只能保证单线程访问时的对象内部状态的安全,多线程执行时几乎总会导致内部状态的损坏。

  • 虽然锁似乎是支持多线程环境下的补救措施,但实际上它们效率低,并且容易导致死锁。

  • 锁更适合在本地工作。

共享内存的错觉

在80-90年代的编程概念模型中,本地变量是直接写入到内存中的(这和我们理解的本地变量是存在寄存器中是不一样的)。在现代架构上,CPU是写入到缓存行而不是直接写入内存的。这些高速缓存大多数都在CPU内核中,也就是说,一个内核的写入不会被另一个内核看到。为了使内核中的本地更改对另一个核心可见,需要将缓存行传送到另一个核心中。

在JVM中,我们必须使用volatile标记或使用Atomic包装类明确表示变量要跨线程进行内存共享。否则,我们只能先加锁然后访问它们。为什么我们不将所有变量都标记为volatile?因为跨核心同步缓存行是一项非常昂贵的操作!这样做会阻止内核去执行额外的工作,并导致缓存一致性协议出现瓶颈。

即使对于了解这种情况的开发人员来说,确定哪些变量应该被标记为volatile,或者使用哪种atomic结构也是一种艺术。

总结:

  • 没有真正的共享内存,CPU核心就像网络上的计算机一样需要将数据块(高速缓存行)同步给其他CPU核心。CPU间通信和网络通信是相似的。

  • 通过将变量标记为volatile或使用Atomic结构来使CPU核心之间的数据进行同步是可以被替代的,我们可以使用一种更有纪律和原则性的方式,将本地变量保存到并发实体内,然后通过消息显式地在并发实体之间传播数据或事件。

调用栈的错觉

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

当主线程打算将任务委托给“后台”时,这实际上就是将任务委托给另外一个工作线程,实际上就是主线程将一个任务对象放入工作线程中的一个共享队列里,工作线程负责从这个队列里获取任务来执行,这就允许主线程继续前进并执行其他任务。

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

这是一个严重的问题。主线程的调用栈上不能捕获这个异常,工作线程如何处理这种情况?需要以某种方式通知主线程,例如将异常放在主线程预先准备存放结果的地方,但如果主线程一直没有收到通知,任务也即丢失!

当工作线程在执行任务时出现BUG,导致工作线程关闭, 这时谁来重新启动 一个线程来处理这个任务,并且该任务如何恢复到正常的状态。这都是问题。

总结:

  • 为了在当前系统上实现有意义的高性能并发,线程必须以有效的方式在彼此之间委派任务并且不会发生阻塞。使用这种任务委托方式,基于调用栈的错误处理会中断,并且需要引入新的、明确的错误通知机制。失败处理成为并发系统中要考虑的一部分。

  • 并发系统需要处理服务故障并需要具有从中恢复的方法。此类服务的客户端需要知道任务/消息可能在重新启动期间丢失。即使没有发生丢失,由于先前排队的任务比较多,垃圾收集造成的延迟等,响应可能会被延迟。面对这些,并发系统应该以超时的形式处理响应,就像网络/分布式系统一样。

原文为akka官网链接: https://doc.akka.io/docs/akka/current/guide/actors-motivation.html

接下来的文章,让我们看看actor模型如何克服这些挑战。

如果觉得这篇文章能让你学到知识,能否帮忙转发,将知识分享出去。

如果想第一时间学习更多的精彩的内容,请关注微信公众号: 1点25

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章