首先说一下Actor Model,作为一种进程或者线程间的通信模型,一般来说有两种选择,一种是CSP,比如Go语言就使用的是这个模型,goroutine之间可以通过channel进行通信,channel本身可以是阻塞的也可以是非阻塞有缓存的,本质上是通过信号的方式处理通信,使用起来依然比较底层且容易造成阻塞,需要自己声明各种channel并传递信号或者数据给运行某个函数的goroutine,得益于golang runtime优秀的调度系统,阻塞goroutine不会真正的block底层的线程,另外一种就是Actor Model,抽象程度较高,erlang语言基本上都是这个模型的一个实现,广泛的应用在通信行业。
Scala语言初期本身包含Actor Model,在后来的版本从语言核心的实现中移除,以库的方式在语言外部实现,也就是AKKA所维护的现在这个版本,包含了大量的工具,比如http的实现,stream的构建以及分布式和持久化等特性。Scala语言有比较自由的扩展方式,可以自己定义DSL,AKKA作为外部库的实现也有自己的一套DSL,学习Scala的类库经常有学习新语言的感觉,这也是DSL的双刃剑特点,简化了开发提高了表现力,增加了一些学习成本。
大概是从三年前开始使用Akka,最初只是用来作线程使用场景的替代,我们的开发团队里边有很多人对线程的认识不够,经常发现有随意启动线程池,并没有在使用后释放导致长期运行下来整个进程出现万级别的线程现象,CPU负载飙升,大量的时间浪费在调度上,进程无法正常工作。线程作为一般操作系统所能获取CPU时间片的最小单位,在使用上是需要很小心的,并不是越多越好,而是要匹配当前服务器环境可以并发运行的线程数而恰当的安排,太少了不足以发挥服务器的性能,太多了会导致大量的时间花费在CPU的调度以及线程上下文的切换,引起性能下降,同时尽可能的避免线程中出现阻塞的调用,总的来说线程是一种昂贵的资源,用起来需要比较小心,如何利用好线程发挥CPU最大的能力是一个很讲究的问题,细节可以参考另外的文章,一个比较优秀的例子就是Netty,如何利用很少的worker线程和boss线程来处理几千级别的连接,只在需要CPU的时候将线程资源分配给对应的连接,最大化的提高了线程的使用效率并且减少了连接在分配线程时上下文的切换,保证CPU始终在处理有效的运算,最大化了CPU的处理能力。
Akka的特点可以很好的避免以上问题,同时提供了很高层的抽象,使用起来简单舒服,当然也是有一定的代价的。优势主要有如下几点:
- 优秀的底层线程池支持和任务分发(Dispatcher)。不管是什么工具最终运行起来都要依赖于线程的实现,Akka也不例外,底层默认是通过ForkJoin线程池来处理所有的并发运算,ForkJoin线程池在并发运算中可以说是非常优秀的实现,能够最大化的保证每个CPU的任务队列均衡,如果出现空闲会从其他的队列偷取任务来计算。当然Akka也支持自己配置其他的线程池并定义线程池的一些参数来针对不同的环境调优自身表现。
高度抽象,每个Actor作为一个处理逻辑的最小单元,接收各种消息,进行处理并将处理结果发送给另外的Actor,整个系统看起来就是各种Actor在做自己的事情并互相发消息的过程,完全隐藏了使用线程时的各种细节。比现在流行的微服务架构更加的微小,可以将Actor看作是比线程资源更微小的处理单元,可以放心的启动成千上万个Actor,具体Actor的处理调度由自身的Dispatcher完成,不用担心过渡的消耗线程资源。虽然Actor比较微小,但是高度的抽象可以很快的让开发组织自己想要的处理逻辑,所作的就是完成每个逻辑的执行过程就好,执行完成发送一个消息,这样整个系统就可以“完美”的运行了,有一些坑接下来会说。 - 全异步&高吞吐量,这跟前边所说的底层实现直接相关,Actor消息的发送过程是非阻塞的,在Local ActorSystem只是将消息放到了对应Actor的Mailbox,占用非常低的CPU资源,Remote消息的发送稍微复杂些,但是也只是必要的网络IO消耗,依赖于NIO技术,这部分也只会消耗很小的系统资源,只有真正处理消息的过程才会主要的占用线程资源,因此也跟线程的使用要点一样,尽可能的不要在Actor的Receive方法里边进行阻塞调用,否则会降低CPU的使用率,推荐的方式就是全异步处理,一旦涉及可能阻塞的调用,最好是通过异步的消息发送给另外的处理单元,当前Actor的Receive处理过程完成,释放线程资源,另外的Actor在处理完成后将消息异步的发送回当前的Actor进行接下来的处理,减少线程等待的时间,因为ActorSystem底层的线程数量有限,一旦有一个被无意义的等待阻塞,就不能分配给其他的Actor来处理自己的Mailbox了,降低CPU使用效率。因为Actor自身的全异步特性,整个系统都是通过事件驱动,满足了相对很高的抽象层次的同时也达到了也比较高的执行效率,可以很容易的让CPU发挥最大的处理效率,也就是达到系统最大的吞吐量,所牺牲的一点就是可能会造成请求的延迟略高,接下来会讨论高吞吐量和低延时之间的矛盾。
- 弹性的,可以用来构建分布式计算系统。Akka提供了Cluster、Stream等更高层次的抽象,包含DistributedPubSub/Cluster Singleton/Cluster Distributed Data,以及一些集群级别的router,balance loader等解决方案。这样原本单机级别的Actor model就扩展到了分布式层面,由于Actor Model屏蔽了发送消息时目标Actor是否是Local等细节,在使用过程中保持了一致性,使得通过Akka构建的系统非常容易扩展,只需要在初始阶段或者通过配置文件修改,就可以把原来单机的服务扩展到分布式集群,并且不改变原来的逻辑代码,极大的增强了系统的可扩展性。
同样Actor Model也不是那么的完美,很高层次的抽象让Actor Model能够应对非常多样的业务需求,同样也增加了系统消耗,增加了一层由Actor组成的一堆逻辑处理单元到线程池的Dispatcher,对于目前主流的硬件架构和操作系统而言,最小的调度级别也只是线程而已,无论多么漂亮的表现层都会归结到对线程的操作,因此Actor Model在增加了易用性和高抽象表达能力之外,同样也损失了一部分性能,如果真的需要计算密集型的计算框架依然是推荐从线程的角度去构建,而不是依赖于Actor Model,而Actor Model更适合于计算不那么密集,需要由各种复杂的事件而驱动的业务逻辑场景,或者是复杂的IO类型的需求,特点是更加灵活,很快的实现各种业务逻辑的微小服务之间的互相协同工作,同样具有很好的弹性,也就是说更适合于比密集计算更高一层的业务逻辑的模拟,比如在Spark中,虽然也大量的应用了Akka,但是具体的计算都是在线程之中进行的,只是在整个集群的资源和任务调度以及Driver-Executor通信的过程中才使用Actor Model,在我们开发的轻量级分布式任务调度系统中也是这样,Actor Model更多的是负责宏观的服务拓扑,事件的流转,而最底层的密集计算,直接通过操作线程资源来实现。
Actor Model另外的缺点是不方便调试,有别于习惯概念中的同步函数调用,我们可以在某个线程的运行堆栈中追溯到函数的调用来源,而全异步的程序中,我们只能知道这个处理是有一个消息或者说事件触发的,其来源可考但不方便直接调试查看,跟当前的执行过程没有调用堆栈那样的关系,同样也有事件顺序的问题,很多时候不像我们期待的那样,事件到达当前Actor的Mailbox的时间是不确定的,有时候会出现特别意外的顺序导致系统运行出现了随机性的故障,其Actor的状态变幻莫测,因此再写某个Actor的处理过程的时候需要对状态做更多的检查以防止各种事件顺序引起的错误。
使用Akka除了通常使用Actor Model的场景之外,还有一些更高层次的抽象,比如Reactive风格的Stream构造,我们可以以Actor Model为基础通过表达能力更好的DSL来构建Actor拓扑,消息在不同的节点之间流转,同时支持back pressure,支持Source/Sink/FLow/FanIn/FanOut等不同种类的输入输出的抽象,使用起来非常方便。
还有一些更高层次的抽象,我们可以基于Akka来构造动态的API,一般情况我们使用的Web Container只能启动固定的几个监听端口,如果需要动态的根据业务需要启动新的监听端口,在负载要求不那么高的情况可以通过Akka进行轻量级的构建,将某个Actor的输入对应到某个Socket连接,处理完之后再通过这个连接返回,减少了启动新的Web进程的时间,更高效的使用了当前服务进程的资源,使用完之后还可以动态的关闭,并停止当前服务的这组Actor,释放资源。
基于Akka我们可以非常自然的组织双向的RPC服务,而不用担心复杂的网络IO造成损耗,Akka对网络环境要求不高,可以配置高效的不同种类的协议和序列化框架来进行网络通信。对比其他的RPC框架比如Thrift,Finagle,Storm等,轻重量级不同,在纯粹的Scala+Java环境中Actor Model的抽象要比直接使用Thrift简单的多,同样比Storm轻盈的多,可以在线程级别组织自己的微服务拓扑,对比Twitter· finagle使用的Future方式,Actor Model要直观,表达能力更高,更容易组织复杂的流程。