共享内存模型

传统并发编程都是采用了共享内存模型,也就是一部分内存是多个线程同时可见的。而对于共享内存的修改,往往需要使用互斥锁机制来保证内存在并发修改场景下的正确性。而互斥锁机制实际上会造成多核并发计算在此处顺序执行,降低了对CPU核心的利用率。按照Amdahl定律,并发程度越高,互斥计算对多核心利用率的影响就越严重。

也许有人会说使用无锁算法,也就是CAS机制。CAS机制确实会有比互斥锁机制更高的效率。但是仍然会遇到一个问题,那就是伪共享

现代CPU在计算时,会将数据以缓存线(cache-line)的形式加载在缓存体系中,L1,L2等等。X86体系CPU的cache line大小为64字节。如果两颗CPU内核在计算时,加载到了同一段内存。而其中一个对内存数据做了修改,那么另一颗核心不得不重新加载这一段缓存线。也同样导致CPU计算时间被浪费。这个场景并不极端,Java中的ArrayBlockingQueue就是因为伪共享的问题,在高并发场景下,性能表现不佳。

在共享内存模型中,想要解决伪共享问题,就需要对内存增加冗余数据,保证同一段缓存线中,不会出现并发修改的场景。俗称padding。但是padding会造成内存的无效占用。而且,不同CPU体系的cache line长度并不一致。使用了padding策略,会降低代码的可移植性。在ARM服务器体系崭露头角的今天,提前放弃代码的可移植性是需要斟酌的。

Actor模型

并发编程并非只有一种模型。除了共享模型之外,还有消息传递模型的诸多变种。

消息传递模型的关键点在于不同功能单元之间通过传递消息来实现并发计算。Golang的CSP模型就是消息传递模型的一种。Actor模型也是一种消息传递模型。Actor和CSP的都通过消息通道来完成消息传递,不同之处在于Actor模型并不知晓其他Actor对象的消息通道的存在,而CSP模型中不同功能单元都知道彼此之间的消息通道。

Actor模型的思想是“万物皆Actor”。每一个功能单元都抽象为一个Actor。每一个Actor能够:

  1. 向其他Actor发送无限量消息
  2. 创建无限量子Actor
  3. 能够处理接收到的消息

在实现上,每一个Actor内都有一个消息通道称作“信箱”。Actor会将接受到的消息先存入信箱。内置的计算逻辑会不断的从信箱中读取消息,异步的处理消息。消息的传递被信箱解耦,避免了相互之间的阻塞。每一个Actor的计算逻辑仅仅顺序的读取邮箱里的消息,最大程度减少了不同Actor之间的共享内存。

Actor模型的实现

在工业领域,Actor模型的实现也有很多。其中实现最为成熟的当属Erlang。Erlang是爱立信开发的语言,最初用于通信设备的编程。作为一门函数式语言,Erlang也是支持热部署的动态语言。Erlang是语言级的支持Actor模型,每一个Process都是一个Actor。Process是一个仅数百字节大小的轻量级用户态线程。在Erlang的BEAM虚拟机实现中,Process能够被公平调度,而且GC也是以Process为单位的。也就是说,一部分Process因为GC处于STW阶段时,其他Process仍然处于活跃状态。因此Erlang极为适合拿来实现一些软实时系统。

在JVM上也有对应的Actor模型系统的实现,就是Akka。Akka是一个Scala编写的框架,同时支持Java和Scala两种语言。得益于高性能JVM实现,Akka在工业界也得到了很多应用。例如,Paypal使用8台双核VM实例运行Scala + Akka应用能够每天处理10亿级别的交易量

Rust上也有高性能的Actor模型的实现,Actix。基于Actix的Http Server框架Actix Web在跨语言Http服务框架性能对比网站Techempower.com中长期占据榜单前列。

Actor模型的使用

从定义来看,Actor模型是一种消息驱动的编程模式。也因此,实现代码与常见的也有很大的区别。

在Actor的世界中,每一个功能单元都实现为一个Actor。计算逻辑主要实现类似于,从信箱中读取消息,根据消息的不同类型,采取不同的处理逻辑,并向其他Actor单元发送消息。必要时,Actor甚至可以主动生成新的Actor单元,执行相应操作。

所以总体来讲,Actor模型极为适合实现流式消息处理系统。

有一种说法讲:Actor是另一种OO设计。每一个Actor就是一个对象,足够内聚的计算逻辑封装在其中。对象的继承可以用Actor的父子体系来对比。而多态,可以理解为Actor对不同类型消息的处理。Actor对象相互之间不会直接发生栈上调用,而是通过消息传递来异步调用。

因为内容的不同,消息大致可以分为三类:命令消息、事件消息、文档消息。三者各自存放的内容大致类似于:“执行某种操作”、“发生了某种事情”、“某些资源的内容”。

根据具体的要求,消息系统的架构中可以存在执行消息分发、拆分、聚合、包装等等操作的Actor对象。

Actor模型的健壮性

Actor的模型的强大之处在于Actor的父子关系。Erlang和Akka给予其一个更有工程意义的称法:监督(Supervision)。父Actor能够维护子Actor的生命周期:生成、启动、错误管理、重启、注销等等。由此,工程界演进出一个极为强大的思想:“放任崩溃”,也就是“Let it crash”。

“放任崩溃”并不是字面意思说的:随便报错,任之崩溃。

这一模式建立在Actor对象的轻量级上:在Erlang里是一个数百字节的Process,在Akka里仅仅是一个JVM对象。当一个Actor运行失败后,直接报错。由其父Actor根据错误类型决定其生命周期,重启或是重新生成。“错误内核”模式在此极具代表意义:有状态的Actor往往会将高风险操作委托给子Actor来执行,这样即使子Actor执行失败,仍然保证了父Actor处于可用状态。一旦遇到失败,父Actor可以选择其他策略执行操作。从而有效的保证了系统整体的健壮。

依托于此,爱立信的Erlang服务曾经达到了惊为天人的9个9可用性,平均每年宕机时间仅为数十毫秒。

没有Actor,放任崩溃怎么做

知乎的讨论里,有人举例C++程序中出现难以定位的内存泄露问题。于是在线上实际运行时,会监控应用的内存占用量。当达到一定阈值的时候,应用会被直接重启,避免将来出现内存分配失败。这也算是这一思想的应用,只是没有了Actor监控体系的存在,整体实现变得更为复杂。

“放任崩溃”也可以类比Kubernates的Pod生命周期管理,例如常见的失败重启。但这个建立在Pod重启耗时小的基础上。如果Pod里运行的是一个启动耗时30s+的SpringBoot服务,那这个“放任崩溃”的代价其实是很高的。因为这一段时间内服务的可用性是没有保证的。这也是Golang更适合做云原生服务的原因之一。GraalVM看起来很有希望,可以拭目以待。

参考资料

  1. 《响应式架构 - 消息模式Actor实现与Scala、Akka应用集成》
  2. 《反应式设计模式》