Actor模型与传统模型

官方链接

小结:

本文从传统面向对象的封装,内存共享以及并发错误处理等几个方面对比了传统并发模型的缺陷。

  • 对象封装在并发的情况下很难保证原子性,加锁又会导致性能问题,还会导致死锁
  • 多核CPU在cache-lines上的资源同步很容易造成性能问题(使用volatile或者atomic)。
  • 传统的线程模型会因为错误处理导致消息丢失,难以恢复。

简述

    通过对比actor模型和传统模型,更好的理解actor模型在高并发的分布式场景中的优势.

   Actor模型在十多年前被Carl Hewitt提出用来在高性能网络环境中处理并发问题--在当时的环境没有价值.如今硬件和基础设施能力赶上了甚至超过了Carl Hewitt的预见.因此当组织要构建苛刻需求(demanding requirements)的分布式系统遇到了到了挑战,我们不能够完全通过传统的面向对象模型解决这些问题,但是使用actor模型却能够从中受益.

      如今,actor模型不仅被认识为是一个高效的解决方案-他也在很多世界上需求苛刻的应用中被证明.

为了突出显示actor模型解决的问题,下面将讨论现代多线程多CPU与传统编程的观点不匹配的地方.

封装的挑战

     面向对象的一个核心概念就是 封装(encapsulation) .封装规定内部数据不能够直接的被外部访问.只能够通过暴漏的方法访问.对象需要负责暴露安全的操作来保证他的数据的不变性质.

     举个例子在一个有序二叉树实现上的操作,不能够允许违反树排序不变的. 调用方期望树是完好的, 并且在查询树的某一数据段时,必须依赖这个约束.

     当我们分析面向对象的实时行为,我们可以通过时序图来展示方法调用.

不幸的是,上面的图不能够确切的代表实例在执行时的生命线.实际中,一个线程执行这些调用,当不变量的执行发生在调用的同一个线程.更新上面的图

 

当我们试着模拟多线程中发生的事情,使用这种方式会变得清晰.突然我们整齐的图变得零乱.我们可以试着说明多线程是如何访问同一个实例:

上图表明两个线程访问同一个方法.不幸的是,封装对象的模型不能够保证在这个情况下发生什么. 两个方法调用可以以任意方式进入,所以在没有特定的协助下我们不能保证不变量不变了.

     通常解决这个问题的方法是添加一个锁.这可以保证同一时刻只有一个线程进入这个方法,但是代价非常昂贵:

  • 锁严格限制了并发,在当前CPU架构下会非常的昂贵,需要一个操作系统的繁重的操作(heavy-lifting)来挂起一个线程并且一会恢复.
  • 调用线程会被阻塞,所以它不能做其他有意义的工作. 即使在桌面程序这也是不可以被接受的,我们希望即使后台做了一个很长时间的工作 UI也是能够相应的.在后端,阻塞也是非常浪费资源的,即使另起一个线程来弥补,但是线程就是一个昂贵的抽象
  • 锁也会带来死锁的问题.

这些问题会导致一个必败的情况:

  • 没有足够的锁,状态获取将被破坏.
  • 使用了太多的锁,性能不行还容易造成死锁.

额外的,锁只能在本地工作,当我们需要多台机器协作时,唯一的解法是分布式锁.分布式锁性能比本地锁差了很多而且对扩展限制比较严格. 分布式锁协议需要多台机器多次得网络往返通信,所以延迟会飞涨(latency goes through the roof)

在面向对象的语言我们很少考虑线程或者线性执行路径.我们通常把一个系统想象成一个对象实例的网络,对方法调用做出反应,修改他们内部状态,然后互相通信驱动着整个应用状态向前.

下图是对象通过方法相互调用

然而在多线程的分布式环境中,实际发生的是线程通过方法调用"穿过了"对象实例的网络.因此,线程才是真正

的驱动执行的东西

下图三个颜色 三个线程中方法互相调用.

 

 总结:

  • 面对单线程的访问,对象可以只通过封装来保证不变量的保护,多线程执行通常回破坏内部状态.每个不变量在同一个代码片段可能被两个互相竞争的线程侵犯.
  • 锁是对于多线程中 似乎是天然的改进来支持封装.实际中它是低效的容易引起死锁的.
  • 锁在本地工作,如果想让他们做成分布式,会使扩展性受到限制.

在现代计算机架构共享内存的问题

         80-90年代的概念写入变量意味着直接写到内存中(局部变量也许之存在寄存器中).现代的架构.CPUs 将变量写在cache lines中. 大部分的缓存都是在CPU 核心中,多以会出现一个核心对其他的不可见.为了使局部改变对于其他核心可见,从而让别的线程可见,缓存行需要将改变传到别的核心缓存中.

         在JVM,我们不得不通过使用 volatile关键字或者 原子的修饰来明确标记存储是要跨线程共享的.因此,我们只能通过锁定区域来访问他们。因为calche lines 跨核心是非常昂贵的操作,所以我们不能够把所有的变量都标记为volatile. 如果这么做将隐式的暂停了相关参与核心做额外的工作,并且造成缓存一致性协议(MESI)的瓶颈(cpus 通常使用这个协议在主存和其他cpu之间传输cacle lines).最终导致效率数量级的下降.

总结:

  • cpu把数据块(cache lines)明确的传递给彼此 就如同计算机在网络上做的一样.Inter-CPU 通信和网络通信又许多共同点.传递消息已经变成了一种常态,无论跨Cpu还是网络上的计算机.
  • 与在标记变量是共享的或者使用原子数据结构不同,一种更有纪律和原则的方法可以将状态保持为并发实体的本地状态,然后通过消息在并发实体中传播数据或者事件。

调用堆栈的问题

      如今,我们经常把调用堆栈当作理所当然.但是,他们是在多CPU系统不流行的时代被发明的.调用堆栈不能跨线程所以不能模拟异步调用链.

     这个问题在一个线程打算委托一个任务给"backgroud"出现.实际中,这意味着委托给其他线程.这种方式不是一个简单得方法/函数调用,因为这种调用是严格的在线程本地.通常发生,"调用者"把一个对象放在一个和工作线程("callee")共享得内存位置, 而调用者又在某些事件循环中获取它.这允许"caller"线程继续或者去做别的任务.

     第一个问题是,怎么样通知"caller"任务已经完成了? 与之而来的一个更严重的问题出现,当一个任务以异常状态失败了.怎么把异常传播过去?它将传播给工作线程的异常处理器完全的忽略谁是实际的"caller": 

     这是个严肃的问题.工作线程怎么处理这个问题呢? 它似乎不能解决这个问题因为它通常不知道失败任务的目的.调用线程需要以某种方式被通知,但是没有调用堆栈去释放异常.失败的通知只能够被别的通道完成,比如把一个错误码放到调用者线程期望获取结果的位置。如果这个通知没有到位,调用者永远不能够获得失败的通知,这个任务就丢失了! 这个和网络系统工作的很相似,消息/请求可能丢失/失败 而没有任何通知.

        更坏的情况是当真的出现了错误并且一个工作线程遇到了bug或者一个不可恢复的情况。比如,一个内部一场造成了一个bug冒出来到线程的root 并且使线程关闭了. 这时谁应该重启这个线程承载的服务的正常运行,并且怎么重置成一个好的状态? 乍一看,貌似可以被管控,但我们忽然要面对一个新的,不期望的现象(phenomenon):当前线程正在工作的实际任务,已经不在共享内存的位置-任务获取的地方(通常是队列). 事实上,由于异常到达了最上级,释放了所有的堆栈,这个任务状态已经完全丢失了! 我们甚至没有网络的参与就在本地通信中丢失了消息.

总结:

  • 为了让当前系统实现并发和高性能,线程之间必须以一个高效的没有阻塞的方式相互委托任务.使用这种任务委托并发性(网络/分布式计算 更是如此), 基于堆栈调用的错误处理将会崩溃. 一个新的,明确的错误通知机制需要被引入.错误变成了领域模型的一部分.
  • 使用工作委托的并发系统需要处理服务失败并且有一个恢复他们的方式。这些服务的客户端需要察觉到任务/消息也许在重启的时候丢失. 就算这个丢失没有发生,一个响应 可能会因为在队列之前的排队的任务给延时,比如gc导致的延时.面对这些,并发系统应该以超时的形式处理响应的最后期限,就想网络/分布式系统.
  • 6
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FantasyBaby

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值