Thanks Joe

  北京时间2019年4月20日,Erlang之父Joe Armstrong去世,享年68岁,致敬真正的思想者(还有AlanKay、Carl),在诺依曼架构硬件狂飙突进几十年、抵达了物理极限的终点之后,终于让软件架构跟上了脚步,思想一直远远走在现实之前。Thanks, Joe | Akka

Joe Armstrong (27 December 1950 in Bournemouth, England – 20 April 2019) 作为一个独立的生命进程,虽然不与我们共享他的状态,却把他对计算机世界的终极思考通过信息带给了我们

  传统OOP(区别经典OO,表示c++、Java代表的跑偏OO)对于现代大型软件系统的复杂性已经不堪重负,GearPump钟祥谈到一个现象:一位hive committor说没有一个人敢说了解hive所有代码,大规模分布式系统相比传统系统复杂度又上了一个台阶,事实上所有的单机并发、多机并行系统都需要一个简化模型,那就是回归经典OO的初心。

  actor模型起初的目标是在一个高性能网络当中处理并行计算问题,分布式系统是典型,而单机系统其实也一样,现代单机由于多核化多CPU化,一台机器已经近似于一个“高性能网络”,这反而有利于actor模型从分布式多机降维到多核单机。

  读史可以明智、说人话就是活久见,当一件事物存在的时间足够长,你就可能有幸看到一个奇观,当初的权威跌下神坛,其历史局限性一览无余,最初的OO无罪,但是c++和Java放弃了其精髓演变而来的传统OO已经到了这个时候了,所以也许我们应该放弃OOP并将其称为“面向消息的编程(Message Oriented Programming,MOP)”而不是“面向对象的编程(OOP)”?

  让我们看看传统OO编程模型的一些假设,这些假设在现代多核多CPU架构下已然变成闭门造车,首当其冲的是OO头牌:封装、其次是共享内存幻觉和邪恶调用栈。

一、封装面临的挑战:在任性的线程面前,封装成了皇帝的新衣

  传统编程模型认为写一个变量就是直接写了相应的内存地址,但是在现代多核计算机架构,简单点说CPU写的是cache lines而不是直接写内存,这些缓存大部分是L1一级缓存,CPU的一个核写的内容另外的核看不见,为了把变化广播给别的核(这样才能同样广播到别的线程),需要(做额外动作)把cache line的变化同步到其他核的cache.  也就是说CPU核之间类似于节点机之间,它们都需要通过某种网络通讯才能同步数据。

  JVM运行环境下,要做到上述的线程间共享,需要显式地给变量(memory locations)打上volatile 标记 或者使用Atomic原子包装数据结构,可以的话你还能用用Unsafe CAS、CopyOnWrite数据结构,最不济也得用蹩脚的锁,否则,一般变量值的变化不保证能实时同步给其它线程可见。你可能会问为什么不标记所有变量都是volatile的呢?因为在CPU的多个核之间同步cache lines是很昂贵的操作!会拖慢CPU内核速度并且导致cache coherence protocol (the protocol CPUs use to transfer cache lines between main memory and other CPUs) 瓶颈,结果就是明显的拖慢运行。volatile也只能解决可见性问题,不能保证原子性,这使它近乎鸡肋。

开发时:你看源码时自以为的方法调用路径,但你看到的,往往不是真实


运行时:一条线程执行你的代码时走的方法调用
两条线程


多条线程、多个对象、多种方法,欢迎来到真实世界


这才是OO理想国,Actor的三维世界。“纸上谈兵”的OO以为,源码白纸黑字写了的方法调用就是调用路径;可惜真正调用方法的并不是方法,而是线程;你的方法只是木偶,线程才是那根提线,图中无法直观表达线程,每一个蓝点即代表一个Actor对象、也代表一个专属于它的线程。

  如日中天上有图灵奖背书下有spring鼎鼎大名的OOP彻底跑偏了,OO退化成了源码这种文本的组织方式,类似文章要有目录、书要分章节,到了编译之后的运行时世界里O只是一些方法和数据的包,源码只是文本,事在人/核为,法律文书要是管用,还要警察黑社会干嘛?程序并不是写在二维纸面上的文本,而是运行在三维时空中的物理过程,在真实世界,距离、时间都是程序状态的决定要素,只要是二维的东西一定无法准确表达系统运行时,是的,UML和spring都是跑偏典型。BTW说到spring来看看spring是怎么跑偏的,一个应用系统,用了spring来做和不用它来做,在运行时几乎毫无差别,功能和性能都没差,无非是基于VM的一些花拳绣腿比如代理对象,所以,spring不是架构,它只是个好用的程序组装和配置工具,然后就没有然后了,在这个方向上它没有未来,具体说就是ioc虽然是spring的根,但在这个方向上能做的事很有限,收效也有限,其实spring的发展是经历了瓶颈的,在ioc之后只有收购了AOP才能继续做一些东西,再之后是基于maven才推出spring boot,最近2年则是搭车反应式、突然转向reactor,几乎是全面拥抱反应式,也是为了摆脱这种技术死胡同。

  比较来说actor是Akka的根,actor生命力如此强大,我们可以看到Akka在这个根上稳步推进,子项目层出不穷开枝散叶,稳定而持续不断的推出,在短短十年从诞生直到主导发布反应式宣言、成立linux反应式子基金会。对比来看spring只管理编译阶段的静态class而不是运行时阶段的动态instance,Akka则直面运行时、去做最有价值的事:管理运行时instance,更重要的是:统一并发和并行

  传统OO基本无视运行时无视线程,自圆其说叠屋架床繁琐无味,各种Builder各种Wrapper各种Service,能提高运行效率吗?完成了什么新功能吗?统统木有,传统OO对经典OO原教旨可谓是买椟还珠。传统OO要求对象把对状态的访问封装为“安全的”getter/setter(.Net中叫访问器)方法(The object is responsible for exposing safe operations that protect the invariant nature of its encapsulated data.),这些访问器方法相当莫名其妙,首先它们不了解调用方、也不知道调用时的业务语义,不止初学者、即使是老司机面对getter/setter方法时、感觉我应该写点什么的时候都会有共同的感觉:一脸懵逼,就像集体面对异常捕获时一样的懵逼:catch { 这里面特么的应该写点什么呢?},我们把这些问题统称为懵逼问题。

  传统OO在实践当中跑偏了、脱离了经典OO原教旨,参考:Alan Kay在2011年关于编程和扩展的 一席发人深省的讲话中再次提到了这个问题,指出软件工程已陷入停滞,成为了迷失的学科,无法跟上硬件和其他科学技术领域发展的步伐。巨大的代码库已经类似于一个垃圾场。Alan提到:“从非数据角度去看待对象的人数量很少,包括我自己、Carl Hewitt, Dave Reed 和其他一些人, 基本上这拨人都曾经是ARPA社区的,不同程度地参加过ARPAnet到Internet的设计,在这个设计中计算的基本单元就是一整台计算机。但是人们 可以看到一个观念可以僵化到什么程度:从七十到八十年代都有很多人试图用“远程过程调用”应付,而不是从对象和消息的角度去考虑问题。世界的辉煌就如此擦肩而过了Carl HewittActor Model的发明者,Dave Reed参与了TCP/IP 的早期开发和 UDP的设计”。

Hewitt-Carl actor之父,总有一些人真正推动着思想的进步(这些人不包括网红作家、TW首席大忽悠Martin Fowler.)

  实际上最初的OO当中封装所针对的就是线程,这是起点,要做到这种纯正的封装,势必要做到异步消息交互和对象解耦,这才是Encapsulation这个词的真正内涵,没有这个内涵的封装纯属耍流氓。

  即使是对知晓上述情况的开发者,决定哪些memory locations应该用volatile标记或atomic结构,仍然也算是黑科技(框架或库的使用问题你可以google,但是并发问题都有各自的场景,加之往往很难再现,google不到标准答案,意指这些技能经验难以积累共享)

  用锁做线程互斥将会严重约束并发度,在现代的CPU架构下锁的代价很高昂(requiring heavy-lifting from the operating system to suspend the thread and restore it later.) ,锁还会引狼入室,带来臭名昭著的死锁问题,死锁问题本质就是锁滥用的表现,现象就是系统假死—>无响应,它死了吗?进程还在、没死吗?死了吗?—>懵逼,总之,锁是最low的一逼没有之一,它带来的新问题不比它解决的旧问题少,实际上为了替代丑陋锁的努力一直有,比如CAS,当你的系统开始采用锁的时候,就已经失败了。事实上从当前的Java8开始javaer的大部分开发已经可以不用锁了。

  当前大数据云计算,都得益于分布式,分布式场景下比如分布式事务,锁从小怪兽升级成哥斯拉:distributed locks分布式锁,分布式锁的恶心度比小怪兽高好几个数量级,它不仅更加严重的约束并发、它还严重约束横向扩展能力,分布式锁的协议一般需要数次远程的、同步的、可能跨多台节点机的往返通讯,延迟感人。


二、共享内存幻觉:你确定i++ == i++吗?

1、从来就没有什么真正共享的内存:每个CPU核都有自己的Cache,线程在一个核中运行修改的变量数据chunks of data (cache lines) 不做同步广播动作只存在于这一个核的Cache、其他核看不见(其他线程看不见)。CPU间(多个核之间)的通讯和网络通讯的共同点比大部分人所了解的要更多,消息传递是常态Passing messages is the norm now be it across CPUs or networked computers. 在摩尔定律失效的多核并发年代,消息传递符合人的心智模型、多核CPU架构运行模型,也符合机器网络通讯运行模型。分布式是大势所趋,而只要是分布式系统,你还能不谈论消息吗?

2、把变量标记为共享(volatile)或使用atomic数据结构只是隐藏了消息传递而已(在多核之间或多个CPU之间传递消息)。有节操更专业的姿势应该是让事情回归简单、本地的归本地、该传递的就传递,把本地状态封装在实体当中,需要传递时就在并发实体之间明确地以消息形式传递,消息由你定义,消息涉及业务语义,接收者知道该做什么,actor的世界是和谐的:

actor社会才是OO理想国:面向对象系统是由对象和他们之间的(异步)消息构成——Alan Kay

三、邪恶调用栈

  如果说假道学的封装早就引起了我们的怀疑、对遮遮掩掩的共享内存我们也早有警觉,那么,那边那位不声不响一脸无辜的调用栈老先生,算是最邪恶的。

  今天我们认为call stack方法调用堆栈是理所应当天经地义的,因为我们使用它太久太久了,调用栈是上一个时代的产物,那个时代并发编程还不是主流,因为多CPU架构的系统不通用、不常见。Call stacks调用栈不跨线程、无法应用到现代异步调用链,每条线程有自己的方法堆栈,这些方法层层嵌套入栈出栈,其中一个方法阻塞上游所有方法阻塞;其中一个异常如果上游代码没有捕获或正确处理该异常则整个线程会被干掉。以上游方法作为上层异常处理FallBack的方式不佳,因为我们不能说因为A方法调用了B方法所以AB之间是上下级关系,A方法就有了更多知识背景能够做到恢复B方法的异常,方法调用关系不是这种上下级关系,但是C++和Java的异常体系是基于此设计的,这导致语义混淆、职责模糊、码农懵逼。

  Call stacks是造成运行时紧耦合的病毒,它沿着方法调用堆栈传播,经过的对象全部被传染,这些对象的病症表现为“僵尸化”:无法全局并发、整条方法调用栈串起来的对象只能同步;异常情况牵一发动全身且难以处理、大部分无法恢复;事实上通过线程方法调用栈传染的所有对象变成了一根绳上的蚂蚱,在线程方面(同步)和异常处理方面互相羁绊——这就是耦合。方法成了对象封装的免疫漏洞,线程携带着僵尸病毒Call stacks沿着这个漏洞从一个对象传播到另一个对象,致病原因:

    Call stacks do not cross threads and hence, do not model asynchronous call chains.

  当一条线程要把一项任务代理给后台(另一条线程,这是并发系统最普遍的委托并发模型)去运行,问题暴露的更加明显了,这不是一个简单的方法/函数调用(因为常规的方法调用都限于本地:本线程内部),此时一个“caller” Main线程把一个命令对象(没错这里是命令模式的用处,其实就是个可以跨线程交付的VO任务)通过一个内存位置queue(非阻塞队列)共享给另一条“callee” Worker线程。在传统OO语言Java和C++中,caller扔下这个Task就拍屁股走人了,随后,callee线程会按照某种poll机制 (picks it up in some event loop) 把Task对象接收下来。

  第一个问题:Main线程怎么知道任务干完了?这还不算最严重的问题,更严重的是如果任务执行失败了呢?任务抛了个异常,但是是在Worker线程中抛的,Worker线程该拿这个异常肿么办?这个异常最多只会抛到Worker线程环境的exception handler异常处理器,它找不回当初的Main线程,也根本不知道这个任务是干嘛滴,因为它只是个干活的——懵逼:

当我们懵逼时我们应该想些什么?做事情懵逼了,不是你的问题也不是他的问题,是体制问题;编码懵逼了,不是程序的问题不是代码的问题,是我们编的这个程有问题。懵逼问题,终究是大环境问题。

  正确做法应该是通知到Main线程,但是,这里已经没有通往Main线程的调用栈call stack了!只能用一些土办法,比如把错误码信号量之类传递给Main线程,但是如果信号迟迟不来,Main线程也毫无办法,而且,任务也会丢了、丢了?丢了!forever!这和网络通讯时的消息发 lost/fail 丢失/失败情况是何其相似:你得不到任何通知,没人会给你一个说法。

  理论上,异常时应该重启Worker线程的任务,但是该任务怎么才能恢复到正确的状态呢?我们直觉上总会觉得这是可以做到的,程序可以中断、业务应该可以恢复,但细思极恐:Worker线程所执行的那一个任务is no longer in the shared memory location where tasks are taken from (usually a queue).  实际上,如果异常抛出了callee线程之外,除了会直接导致callee线程shut down.  还会造成: unwinding all of the call stack, the task state is fully lost ! We have lost a message even though this is local communication with no networking involved (where message losses are to be expected).

  传统Java中的错误码信号量可以用Future,旧版本只有同步等待结果方法,很鸡肋,新版本有个即难看又难用的CompletableFuture,依然很丧sun,Scala Future支持了回调对这种情况改善很多。现代高并发系统当中必然大量发生线程间代理/委托任务,而且这些代理必然是无阻塞的(否则那不叫高并发),代理的中间环节就是异步的任务队列,线程间所有协作都应该设置response  deadlines 即timeouts,和网络或分布式系统一样,Scala在这一部分强化很多,远远走在了Java前面,所以如果说C++、Java是面向对象OO语言,那么Scala、Go可以叫做面向并发OC语言,在此基础上,还差一个线程调度层我们就可以开撸高并发系统了,Go在语言级别提供,Scala则由Akka库提供。

  对象和线程是两个维度的事情,OO没有理清它们,二者纠缠不清,Gof设计模式大部分都怪怪的,最怪的一点就是,基本绝口不提线程,而线程才是代码运行起来的载体你不关心它你特么关心啥呢?如上所述的命令模式,为何封装一个Command?如果仅仅是封装那么和一个普通VO还有何区别?实际上封装出来的Command应该用于异步地去传递执行(这个模式应该叫Job任务模式),所以Actor消息也可以叫做Command.

  很多程序员想出来很多办法应对单机大并发:java NIO、使用select/poll甚至多进程,这些看似无关的解决方案都是为了突破系统对线程使用的限制、更好的利用线程,用官话说线程是一种昂贵资源;用人话说:线程上下文切换耗时1微妙以上(1500ns)而且20年来一向如此—引用《反应式设计模式》;用图说话:

redis在保证线程无锁之后可以放心使用一条线程消除上下文切换代价

  这些方案都属于IO多路复用,虽然叫做IO多路复用,但其实复用的是线程,线程才是可控的(受益于JDK线程池支持),Java不是一无是处,它构建了稳固的Executor基础设施特别是fork-join,在此之上actor为线程复用定义了清晰的模型。

总结:

  整个系统都应基于完全异步的消息处理结构构建,而不只是传统的在局部应用多线程去执行一批相似的任务,只有这样才能充分利用多核将所有核都保持在差不多的利用率上实现高度并发的系统。我们知道线程从一个方法运行到另一个方法也被称作“控制权转移”,线程控制权分两部分:一是操作系统完全掌控的线程调度控制权(时间片和线程上下文切换);二是我们的代码所拥有的线程行为控制权,我们的代码虽然不能调度线程,但是我们的代码表述可以决定线程的行为如阻塞、让出、异常等,这里的控制权是指行为控制权。

  线程本质上是操作系统虚拟出来的更多CPU内核,对于一台8核CPU一个时刻事实最大并发度只有8,OS提供线程给应用开发的用意是:实际程序当中CPU可能大部分时间都处于空转状态,因为占用CPU的相当一部分线程都只是在忙于IO阻塞等待、锁挂起....CPU也可能忙着线程上下文切换,也就是说传统程序CPU利用率是非常低下的、大部分线程都在瞎忙活而已,OS虚拟出来线程提供给应用以后就可能去进一步实现优先调度有活干的线程、不调度空转线程、打开充分利用CPU的可能性,但实现这种可能OS却无能为力、因为线程跑的是你的任务代码、而OS不了解你的任务代码它也不能去武断处理,把线程提供给应用只是打开了这种可能性,这件事只能主要由应用自行来管理,它和应用的作业类型关联很大,没有一个标准建议说你的线程数在多少最合适,了解任务代码且有能力加以细粒度管理的只有应用自己。唯一一个例外是IO,OS和框架可以做到的是根据IO对应用代码自动调用。

  可以说这个思路欠缺了最后一英里:由应用来判断哪些代码可能空转、哪些核心业务代码不能被阻塞、然后把它们管理起来,包括Akka在内的诸多新技术都在致力于填补这最后一英里,最成体系的就是Akka,比如Akka能做的是只有当一个actor的邮箱有消息到达、该actor会被打上标记、才会被调度下一轮线程执行(与非阻塞I/O多路复用何其相似,而且Akka更自然,IO多路复用当中只有轮询socket有数据到达才会调度线程处理它),还可以指定一个actor处理几条消息之后就须要调度下来让出线程等等实用的细粒度线程管理;你能做的是:你了解你的任务代码、你知道哪些代码可能阻塞、你完全可以做到在关键主逻辑/业务实体当中是无锁无IO无阻塞的、把可能阻塞的代码隔离出去——舱壁模式......由此可见因为线程有一部分控制权在我们手上,而这部分权力要想用好取决于我们写的任务代码,Akka RT不仅仅是一个actor托管容器,它还是一个线程调度层。可以想到如果是IO很重的应用,其线程池可以配的很大、而且最好用舱壁BulkHeading模式做线程隔离,IO越轻锁越少的其线程数可以越少,比如redis算是做到了极致的典型,因为它清楚自己是纯内存高速缓存系统、无IO,少量并发也可以利用写时复制、原子变量、CAS这些手段杜绝并发冲突,为了进一步降低线程上下文切换成本redis干脆使用单线程而且成功了;另一个重要理由是避免使用锁(单线程自然无需锁,与一个actor内的运行类似),锁本身就是一种滥用+误用,scala下大力气完善的Future也可以说是一种避免使用锁的工具,因为它本质上是两个线程之间传输共享数据的Command,说到底:一个生产者线程一个消费者线程中间是无阻塞任务队列,这是个几乎我们所有人都接触过的练习程序,其应用却如此广泛和持久,足见:大道至简。

  Akka对线程的细粒度管理是通过线程池实现,可以控制线程池的配置:

fork-join-executor {

  # Min number of threads to cap factor-based parallelism number to 最少core线程数

  parallelism-min = 2

  # Parallelism (threads) ... ceil(available processors * factor) 并行度=CPU核数倍数

  parallelism-factor = 2.0

  # Max number of threads to cap factor-based parallelism number to 常备活跃线程数

  parallelism-max = 10

}

# Throughput defines the maximum number of messages to be processed per actor before the thread jumps to the next actor. Set to 1 for as fair as possible. 处理多少消息后actor应该让出线程

throughput = 100 //对于CPU核绑定任务可以设置较大增加CPU缓存持续命中利用率

  阻塞需要小心管理起来,代码示例直接使用sleep模拟阻塞、演示了阻塞actor对主业务actor的明显影响,推导出舱壁模式,本质上这是线程隔离、属于资源隔离,云套路。下图表现了一个系统没有仔细管理自己任务情况下,大部分线程都在瞎忙活 + 线程饥饿的场景:

青色- Sleeping睡眠/阻塞状态; 橙色 - Waiting等待状态;绿色 - Runnable运行状态;使用YourKit profiler记录

对于如上执行大量IO阻塞的线程池可以这样:

blocking-io-dispatcher {

  type = Dispatcher

  executor = "thread-pool-executor"

  thread-pool-executor {    fixed-pool-size = 32  } //固定大小以防线程全部阻塞不可用造成大量创建新线程,控制了系统最多能有多少阻塞线程,fine tuned depending on the workload

  throughput = 1 //一个actor吞吐一条消息必须让出线程以防该阻塞时间过长其他actor等待过久

}

阻塞类型作业适用的设计模式

  Akka对共享数据问题的解决办法是Shared-Nothing(SN),你有问题是吗?说实话我也想不出来好办法解决它,不过我能让它消失biubiubiu... 现在,你还有问题吗?Akka几乎是最新技术思路集大成者,另一个有力武器是深度解耦,对象(actor)之间的极致解耦,它带来的开脑洞好处就是let it Crash.  如果说用好线程行为控制权是原子弹、那么深度解耦是氢弹。

  目前并发的方式可以称之为是一种delegation委托并发模型: task-delegating concurrency style 任务委托并发(and even more so with networked/distributed computing),这个场景下,call stack-based的错误处理方式早不好使了,我们急需新机制:把异常错误尽量看做显式的控制信号,因此,Scala提供了Try、Failures等组成面向并发领域模型OC domain model的组成部分,我们说,使用框架的目的并不全是借助几个好用的API而已,框架一定是某个技术/业务领域的框架,当我们进入一个相对全新的领域,对于这个领域的知识是不了解的,如何快速掌握?框架,因为框架远不只是几个API组合,它还是该领域最佳实践的积累。并发是一个相对新的技术领域,在这个领域c++后知后觉落后太多,java稍好一些但也已停滞多年,Scala、Go是未来的方向。

 基于委托并发模式的并发系统需要一种处理和恢复服务失败的准则,服务的客户端也需要在服务重启期间能够得知任务已经失败了,即使没失败,响应也可能因为排队、GC而延迟返回,因此,并发系统应该处处都可以处理响应的deadlines in the form of timeouts, just like networked/distributed systems.

  在单核上,每秒可以转发五千万条消息;部署层面上,每个GB堆250万个actor。CPU每3~4纳秒可以做一次如加减法这种基本运算,每秒可以做2亿次、也就是说转发一条消息需要大约4次基本运算、耗时12~16纳秒。

Akka and the Java Memory Model

  用Akka/scala的主要好处在于简化了并发软件开发。Java5之前的JMM被错误定义了,导致多线程访问共享内存时可能得到各种不一致的奇怪结果:

1、一条线程看不到另一个线程写入的值:可见性问题—volatile:线程请不要缓存我

2、一条线程看到了其他线程不应该出现的行为:指令重排问题—Atomic:不要重排

  随着Java 5实现JSR 133,大量此类问题得到解决,现在 JMM 是一揽子规则、用来保证“happens-before” 原则,该原则约束了什么情况下两次内存访问必须保证先后顺序one memory access must happen before another, and conversely、什么情况下他们无需顺序保证when they are allowed to happen out of order.  这些规则比如有:

1、The monitor lock rule监视器锁规则:一个锁的释放必须发生在随后的所有对该锁的获取之前;

2、The volatile variable rule挥发性变量规则:对volatile变量的写必须发生在随后的所有对该变量的读之前;

  我们看到即使是JVM这么主流的软件运行平台,也直到5版本才开始真正去适配多核多CPU的硬件架构,足见软件架构落后硬件架构由来已久,从仙童八叛徒开始,芯片半导体行业疯狂扩张了至少四十年,软件架构其实一直跟不上硬件狂飙突进的脚步。

  Akka中多线程有两种方式访问共享内存(对actor来说message消息就是共享的):

1、如果一个message消息被发送给一个actor (e.g. by another actor). 通常消息应该是immutable, 但如果消息没有被正确地构造为immutable对象的话、你就会失去“happens before”规则保护,此时消息接收者就可能会看到部分初始化的消息数据甚至幻读values out of thin air (longs/doubles).

2、如果一个actor在处理消息时修改了自身内部状态、并且在随后处理其他消息时又访问了这些状态,此时要注意你得不到任何actor模型的完备性保证,比如that the same thread will be executing the same actor for different messages.

  为了解决visibility and reordering可见性和重排序问题,对同一个actor实例Akka给出如下两点“happens before”规则保证:

1、The actor send rule 消息收发规则:给一个actor发送消息保证在该actor接收到该消息之前完成—如果是跨节点机消息还好说,关键是本地;

2、The actor subsequent processing rule 顺序处理规则:对同一个actor来说,对一条消息的处理保证在下一条消息的处理之前完成—其实是线程安全保证;

Future和JMM

  Future completion happens before 它所有的回调,我们建议不要close over non-final fields (final in Java and val in Scala), and if you do choose to close over non-final fields, they must be marked volatile in order for the current value of the field to be visible to the callback.

  close over即Closure闭包,有冻结的意思,回调代码片段是闭包,这里建议闭包代码不要访问非final的java变量或非val的scala变量,如果必须要这么做那么应该用volatile修饰,以使得回调线程可以看到最新值,否则会导致行为不确定:可能看到也可能看不到。其实在闭包中也可以赋值给新的本地变量来“冻结现场”,注意此时对于基本类型在多线程间传递是传值、对于atomic原子类型即使重新赋值给一个变量仍然是传引用,也就是变化会被多线程看到,这也是为什么说atomic原子类型是“更好的volatile”,因为原子不仅解决了可见性也解决了原子性。


Actor怎么解决懵逼问题?

  ActorSystem是actor的上下文,它管理公共基础设施如线程调度、日志等,它也是所有actor系统配置的容器,几个不同配置的ActorSystem可以共存在一个JVM里,这么看,即使对于分布式系统我们也可以在开发机上启动多个actorSys方式调试测试,至于它们在一个JVM还是多个也不重要了。AK本身并不存在全局共享状态(也就是不存在跨ActorSystem的任何东西),ActorSystem之间的actor还可以透明通讯,因此:ActorSystem本身(也像actor一样)可以用作building blocks.

  Actor应该把大块的功能分解成更小的任务,交付给下属actor去并行完成,自己则专注于监管下级actor。所有actor都只有一个监管者,也就是创建它的那一个。actor系统最典型的行为特征就是任务分解、并行执行,设计这样一个系统的难点在于决定谁应该监管什么、怎么处理异常?怎么异步处理错误?一些指导原则:

1、任务的管理者应该监管任务的执行者,因为管理者知道这个任务可能出现什么样的失败以及如何处理。这样,很多严重异常可以以消息形式上报上级,而上级往往是分配该项任务的actor,它了解任务整体和业务语义,怎么处理这个异常的选择路径一下子打开了,比如Spark任务DAG的并行分解容错;

2、如果一个actor携带有很重要的状态数据,那么它应该把一些危险任务委托给下级子actor去做,比如说为每个请求创建一个子actor(类似每请求每线程),这样也会简化结果收集状态管理,在ErLang中这就是著名的Error Kernel Pattern模式;

3、如果actor A需要依赖actor B分担它的职责,则A应该Watch B的存活状态并对B的终止提醒消息进行响应,Watch并不会干扰监管,它是另一套方法也就是经典的探活、心跳whatever。functional dependency alone is not a criterion for deciding where to place a certain child actor in the hierarchy. 这句话意思应该是只有功能性依赖的话(也就是职责分担),是不能作为把某个actor放到监管层次某一个位置上的标准的,总之,对于职责的分担而不是任务的分解可以用Watch、和监管无关的Watch

  顶级actor筑成整个系统容错能力的内核Error Kernel,所以不可滥用,创建要节制谨慎,更推荐真正层次化的系统,这样有利于故障处理/隔离(both considering the granularity of config and performance),同时还可以降低guardian actor 监管actor的压力,当过载或使用不当时,guardian actor会形成单点争用瓶颈 —— 意思是应该创建少数顶级actor,一般也就1~3个左右,而且他们要慎用,不要去做有风险或阻塞的事情,只要做好它的角色就好,因为它们是当系统出现大范围故障时的最后火种,要仰仗它们恢复整个系统,要尽量多去分层,每层都有监管actor,把风险用多层的结构化解掉。

Actor是真正严密的封装

  1、actor解耦了调用和执行(线程池),actor之间只能通过消息形式进行线程安全的异步调用,而消息不会把执行线程控制权从调用方过渡到被调用方(method calls transfer execution,  message passing does not. Sending a message does not transfer the thread of execution from the sender to the destination.),避免了一大堆不受控的线程在你的系统中随意乱窜:

actor模型强制你从消息通讯的角度看待一切,因为这才是真实世界

  2、Akka保证:actor本身是线程安全的、消息是原子的;

  3、你不再需要锁,结合舱壁模式,可以做到在核心业务代码彻底消灭锁,实现一个如丝般“顺畅”的系统。无锁的异步世界,任何调用方不再会被阻塞,上百万的actors可以由十几二十个线程高效执行、充分利用现代CPU潜力(很多遗留系统CPU可能一半以上时间在做业务无关的阻塞、等待、无效轮询、线程调度)

  4、actor是线程安全的,数据只能通过消息传递,这符合实际的现代高速缓存、内存层次 (In many cases, this means transferring over only the cache lines that contain the data in the message while keeping local state and data cached at the original core. The same model maps exactly to remote communication where the state is kept in the RAM of machines and changes/data is propagated over the network as packets.)

  Erlang程序员说:提高他们的程序的运行速度的技巧就是找出代码中需要顺序执行的部分。而对于任何对于其他编写顺序执行程序的程序员来说,提高他们程序速度的方法是找出他们程序中可以并行执行的部分。

  类似actor模型的运行时模型早已有之,最典型的就是消息系统ESB,再早的消息系统MQ由于不需要在多个环节之中处理消息,所以错过了这个发展道路。ESB不仅要处理大并发消息还要支持多个处理环节,催生了前任务队列后线程池微观运行时结构,比如老牌的mule(现在已经是商业产品),还有完全开源的Spring Integration,它们都遵循《EIP》定义了类似的关键组件:channel,这是整个ESB的解耦、并发核心组件,它相当于actor mailbox.  它在由于提升了并发度从而提升整体性能方面确实有效。

监管是啥意思?

  supervision监管描述了一种actor之间的上下级依赖关系,是一种递归的异常处理结构。监管者actor会把任务委托给下属actor去干,当下属抛出异常那么它会立即挂起suspend(不再处理消息,直到被恢复)自己以及它的所有下属,然后向监管者上报一个消息:我搞不定了,老大您看咋办?老大有四个选项:

1、让下属继续/恢复工作(保留下属内部状态)

2、重启下属(清空其内部状态)

3、永久终止下属;

4、继续Escalate上报,从而表明自己也搞不定了;

任何一个actor都必然属于某一个监管体系(actor不能独立存在只能存在于ActorSystem)

  所有actor都监管其他actor同时也被其他actor监管者(除了大Boss根actor),这样一个体系下:让下属继续/恢复工作也就是让下属的下属全部恢复工作、重启下属也就是让下属的下属全部重启;终止下属也就是让下属的下属全部终止——这便是递归的含义;actor的preRestart钩子方法默认就是终止所有下属(但该方法可以被覆盖),该方法执行完了才会递归重启所有下属。监管者无法针对不同actor采取不同的处理策略,但是如果在一层想做的太多,是不利于reason about的,因此,更好的做法是添加一个监管层,也就是说实现层层监管,每一层的策略都是一致的。

  AK的实现称做“强制父监管”,由一个actor所创建的actor一定是其子,在actor之外的代码通过system.actorOf()创建的actor全都是user的子actor,比如你的引导代码初始创建的少量业务actor,称为Toplevel actors(means those which are created using system.actorOf(), and they are children of theUser Guardian.)。所有actor都是由其创建者监管着的,无一例外。也就是说actor不可能没有监管者,也绝不可能被外界改变其监管者,除了顶级大Boss root,这共同构筑了干净的actor监管逻辑树体系,actor监管树类似于操作系统管理进程构建的进程树,也像稳固的java classLoader层次,这些都是久经考验绝对稳固的基础设施,逻辑结构绝对可靠。注意:监管相关消息使用独立邮箱,不会和常规业务消息混杂。监管逻辑树如下图示:

An actor system will during its creation start at least three actors: root  user  system,这3个创始Actor是随着每个ActorSystem的创建而创建的,不受你的控制。

三大系统创始Guardian Actor:

/user: 监管者

  所有用户创建actor的父,所有system.actorOf()创建的actor都是其子,比如你在ActorSys之外的引导代码初始创建的Top-level actor(系统启动时,你的引导代码会初始创建几个top-level normal Actor,它们是你的所有业务actor的起点和顶级监管者所以称为顶级Actor,但实际上它们之上还有/user和/,normal普通区别于三个系统创始Actor)。当/user终止时,系统中所有的普通actor都将被关闭。所以,该守护者的监管策略决定了top-level normal actor是如何被监督的。自Akka 2.1起可以使用这个设定akka.actor.guardian-supervisor-strategy,来指定top-level normal Actor如何被监管,在application.conf以一个SupervisorStrategyConfigurator的实现类名进行配置:

akka.actor {

  guardian-supervisor-strategy ="com.cache.ResumeSupervisorStrategy"

}

ResumeSupervisorStrategy是一个忽略所有异常的监管策略,比如对于兼任缓存的actor,一定程度容许错误,但是缓存不要轻易丢掉,也就是不要Restart/Stop

/system: 守护者

  这个特殊的守护者是为了保障有序关闭:即使所有普通actor终止,日志logging也要保持可用。其实现方法是Watch守望/user监管者,直到收到其Terminated消息之后它才开始它自己的关闭过程。top-level system顶级系统的actor的监管策略是对收到的除ActorInitializationException 和 ActorKilledException之外的所有Exception无限地执行重启,这也将终止其所有子actor。所有其他的Throwable也就是Error会被上报,然后将导致整个actor系统的关闭。

/: 大Boss

  根守护者,它监督所有在Actor路径的顶级作用域中定义的顶级actor,使用发现任何Exception就终止子actor的SupervisorStrategy.stoppingStrategy策略。其他所有Throwable都会被上报……但是上报给谁?所有的真实actor都有一个监管者,但是根守护者没有父actor,因为它就是整个树结构的根,它被称为bubble-walker

  其他的系统actor还有: 

  /deadLetters:接收所有死信dead letter的actor.  非actor发送Tell消息给actor,其发送者就是deadLetters.

  /temp:which keeps temporary actor refs used with ask  监管所有系统创建的临时actor比如ActorRef.ask创建的,当使用ask时我们会得到一个Future占位符,用它可以在后继接收ask操作的回复,那么谁把回复消息塞进Future的?就是一个临时actor. 这个actor由/temp监管。

  /remote:which enables remote deployment  它下面是所有监管者是远程actor的actor.

重启的含义

  原文最后一段:the ability to do this is one of the reasons for encapsulating actors within special references. The new actor then resumes processing its mailbox, meaning that the restart is not visible outside of the actor itself with the notable exception that the message during which the failure occurred is not re-processed.

  用友译文翻译太生硬不到位,这句话是讲:当一个actor失败并且原地重启了,则它的所有ActorRef也会被更新(原地的意思是所在节点机并没有宕机),这种能力是设计ActorRef的重要原因之一(参考远程Rremoting的定义要求,AKKA要求P2P对等通讯,actor和actorRef是可以对等互相通讯的独立对象),新创建的actor接着就会重新开始处理它自己的邮箱(这里也有重点,就是原actor尚未来得及处理的消息是否能得到继续处理呢?下面解释),这个过程意味着本地重启在actor外部是不可见的、完全透明的,外部是看不到这个actor重启了的,因为外部没感觉到消息得不到处理的异常情况。可以明确:原actor的消息会得到继续处理、不会丢失,邮箱是属于ActorSystem而非actor的,赞!即使Actor因为消息处理异常导致重启了,其邮箱不受影响:If an exception is thrown while a message is being processed, nothing happens to the mailbox. If the actor is restarted, the same mailbox will be there. So all messages on that mailbox will be there as well.  总之,重启不影响ActorRef,但是要注意重启和Stop紧接着重新创建同名actor不一样,后者会导致原ActorRef不可用。

  对于Actor可以放心使用重启监管策略,当然,在处理消息过程当中如果异常了,正在处理的消息最终会丢失,不过因为监管异常的处理其实是和普通消息一样的,在处理当中当前Actor的状态、sender都可以访问,所以在异常处理代码中有条件去做妥善的处理。

Monitoring/Watch守望

  如上所述,监管是父子间的特殊关系,除此以外,任意一个actor都可以Watch守望任意另一个actor,Watch的含义是:由于actor从创建开始就是活跃的,而且其原地重启除了直接监管者,在外部都是不可见的,所以所谓的Watch守望就只能监视一件事:死亡、或者说从活跃到死亡的状态转移。Watch绑定了两个actor,被监视者的Death会给监视者发出Terminated消息通知,Terminated消息不可转发而且属于本地(同一个JVM)消息,对应的远程消息是DeathWatchNotification.

Thanks Joe

Joe was a rare visionary, and his groundbreaking work on Erlang and general distributed systems design can be traced back to the early ’80s. He was way ahead of his time and it is not until the past 5-10 years that our industry at large has had to catch up with the architectural styles Joe has been promoting for decades—without “Let it Crash!”, location transparency, async messaging, immutability, declarative programming, and a vision of systems with millions of loosely coupled isolated processes/actors working together as a single cohesive system, there would be no Akka, Reactive, or modern distributed systems
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,509评论 6 504
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,806评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,875评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,441评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,488评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,365评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,190评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,062评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,500评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,706评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,834评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,559评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,167评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,779评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,912评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,958评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,779评论 2 354

推荐阅读更多精彩内容