本章将基于类型安全的 Akka Typed 库探索 Actor 系统的不同设计模式,相关库都在原包名的基础上添加了 -typed
后缀。比如:
// 基于 JDK 11 版本,sbt 1.7.1
val AkkaVersion = "2.6.19"
libraryDependencies += "com.typesafe.akka" %% "akka-cluster-typed" % AkkaVersion
非 Typed 版本仍然可用,它们被官方标记为 Akka Classic,Akka Typed 包含了 Akka Classic 的内容。通过 InteliJ IDEA & Lightbend 提供的模板可以快速启动一个 Akka Typed 项目。除此之外,自 Akka 2.6.18 版本起将实验性地支持 Scala 3。见:Scala 3 support • Akka Documentation
前菜
Akka Typed 的编程风格明显有别于 Akka Classical,但核心思想仍然是相通的。Actor 在此处被定义成了 Behavior[M]
类型,我们在编译期就需要指定消息协议类型 M
。结合 Scala 3 的并类型 ( Union Type ) 语法,我们可以拓展消息为多种可能的类型。
akka.actor.typed.scaladsl.Behaviors
提供了各种闭包 ( 或称高阶函数 ) 去定义一个 Actor 的行为。比如,下面是一个最简单的例子:
case class Ping(replyTo : ActorRef[Pong])
case class Pong()
object Pinger:
// 接收 Ping 消息, 回复 Pong 消息
def apply() : Behavior[Ping] = Behaviors.receive {(ctx,ping) =>
ctx.log.info("get ping")
ping.replyTo ! Pong()
Behaviors.same
}
官方推荐通过单例对象的 apply
来返回一个 Behavior[M]
。
Behaviors.receive
闭包接收一个 (ActorContext[M],M) => Behavior[M]
函数。首先,返回值 Behavior[M]
表明了该 Actor 只处理 M
类型的消息。其次,通过参数列表,Actor 可以定义如何操作接受到的消息 M
,或者是通过上下文 ActorContext[M]
做哪些工作,比如日志记录 ( 即原有的 ActorLogging
特质被取代了 )。
除此之外,无论是要向一个 Actor 增添定时器 Timer,还是添加监控策略 SupervisorStretagy 等行为,都是通过嵌套组合 Behaviors
提供的各种闭包来表述的,后文在介绍设计模式时就会给定类似的例子。
Actor as FSM 状态机
上段的闭包以 Behaviors.same
作为一个返回值并结束,这是什么?简单地说,它是一个 Behavior[M]
类型的标记,这样就和 apply()
方法的类型签名匹配了。Scala 编译器可以妥当地推导类型,因此我们没必要写成: Behaviors.same[Ping]
。
另一方面,该标记表明在处理完这一条消息之后要维持行为,或称之状态 ( state )。状态可以维持,可以切换,也可以停止。因此,Actor 现在表现得更像一个有限状态机 ( Finite State Machine,FSM ) 了。Behaviors
一共提供 5 个预设行为:
-
Behaviors.same
:在 Actor 处理完一条消息后,复用行为。 -
Behaviors.unhandled
:复用行为,同时将本次接受的消息作为死信 ( dead letter ) 记录到日志。 -
Behaviors.empty
:后续不处理任何消息。将后续收到的消息作为死信记录到日志。 -
Behaviors.ignore
:后续不会处理任何消息。将后续收到的消息直接忽略掉。 -
Behaviors.stopped
:销毁当前被创建出的 ActorRef。Actor 生命周期的 PostStop 钩子函数将被触发。
Behaviors.empty
和 Behaviors.ignore
一般用于顶级 Actor,因为它只需守护子 Actors 以维持工作状态,但自身不处理任何消息。
也可以返回任意以 Behaviors[N <: M]
为 return Type 的函数 / 属性以切换到其它状态,这和 Akka Classical 中的 become
/ unbecome
是等价的 ( 因此,这两个 API 在 Akka Typed 中被取消了 )。下面是一个简单的 Actor 状态机的演示:
trait Command
case object Switch extends Command
case class DoLogging(any : Any) extends Command
object FSM:
def apply() : Behavior[Command] = activate
// 状态转移。
def activate: Behavior[Command] = Behaviors.setup {
ctx =>
ctx.log.info("activate mode.")
Behaviors.receiveMessagePartial {
case DoLogging(any) => ctx.log.info("recording: {}",any);Behaviors.same
case Switch =>
ctx.log.info("change mode")
non_activate
}
}
// 状态转移。
def non_activate: Behavior[Command] = Behaviors.setup {
ctx =>
ctx.log.info("non-activate mode.")
Behaviors.receiveMessagePartial {
case DoLogging(any) => Behaviors.empty
case Switch =>
ctx.log.info("change mode");
activate
}
}
object Guardian:
def apply() : Behavior[Nothing] = Behaviors.setup {
ctx =>
// activate
val fsm: ActorRef[Command] = ctx.spawn(FSM(),"FSM-demo")
fsm ! DoLogging("working...1")
fsm ! Switch // non-activate
fsm ! DoLogging("working...2 (should ignore)")
fsm ! Switch // activate
fsm ! DoLogging("working...3")
Behaviors.ignore
}
关于 FSM 的更多信息,可以去参考 Behaviors as finite state machines • Akka Documentation。额外地,也可以在笔者的 Scala:在纯函数中优雅地转移状态 - 掘金 (juejin.cn) 中了解函数式状态转移的理论基础。
创建 Actor 与启动
在当前上下文中调用 spawn
函数将定义的 Behavior[M]
转化成活跃的 ActorRef[M]
,而这类初始化工作通常要在 Behaviors.setup
闭包下进行。比如:
object Ponger:
def apply() : Behavior[Pong] = Behaviors.setup { ctx =>
// 创建一个处理 Ping 消息的 ActorRef
// 如果你不想定义这个 Actor 的名字,也可以调用 ctx.spawnAnonymous 方法.
val pinger: ActorRef[Ping] = ctx.spawn(Pinger(),"pinger")
// 发送消息
pinger ! Ping(ctx.self)
// 等待消息。
Behaviors.receiveMessage{pong =>
ctx.log.info("get pong")
Behaviors.stopped // 接收到消息后关闭。它创建的子 Actor 也会一同关闭。
}
}
提倡类型安全的 Akka Typed 库不会再提供 sender()
函数直接定位消息发送者。因此,最直接的解决办法是在发送消息时将自身的引用 ctx.self
一起封装进去。官网的这边文章还介绍了 Typed 和 Classical 库的其它不同之处:Learning Akka Typed from Classic • Akka Documentation
Typed ActorSystem 需要传入一个 Guardian Behavior 作为顶级用户 Actor 的驱动。现在将 Ponger()
传入其中,并测试这个最小化案例吧:
@main
def firstDemo(): Unit =
ActorSystem(Ponger(),"ping-pong-test")
正如我们期望的那样,ActorSystem 启动后会依次创建 Ponger 和 Pinger,并在两者回传一次 Ping-pong 消息之后关闭。
管道与过滤器
基于 Actor 模型,我们需要引入一些经典的企业集成模式 ( Enterprise Integration Patterns,EIP ) 去协调并调度 Actors 去工作,这里先从最基本的管道与过滤器模式开始。管道连接 ( piping ) 是指一个处理过程将结果发送到另一个地方继续处理,这一概念来源于 Unix。管道模式又被认为是责任链模式的一种变体。
先从一个简单的例子开始。比如,某交通系统持续地监控车流量,假定它已经为每一辆经过的车都记录了牌照 ( license ) 和车速 ( speed ) 信息,Actor 的任务是从这些照片中筛出无照上路和超速行驶的违法车辆。
一个很重要的约束是:管道之间必须接收并返回相同的消息类型。这样,我们之后无论是铺设新的管道,或者是调换管道次序都会十分地方便。
case class Photo(license : String, speed : Int)
object LicenseFilter:
def apply(pipe : ActorRef[Photo]) : Behavior[Photo] = Behaviors.receive {
(ctx, photo) =>
if photo.license.isEmpty then
ctx.log.info("finding a car with no license, speed = {}",photo.speed)
else
pipe ! photo
Behaviors.same
}
object SpeedFilter:
def apply(pipe : ActorRef[Photo], allowedMaxSpeed : Int = 120) : Behavior[Photo] = Behaviors.receive {
(ctx, photo) =>
if photo.speed > allowedMaxSpeed then
ctx.log.info("finding a car in danger speed = {}, license = {}",
photo.speed,
if photo.license.nonEmpty then photo.license else "Non-license")
else
pipe ! photo
Behaviors.same
}
使用 Actor 来模拟这种管道和过滤器模式很容易。在上述的代码中,我们设置 pipe
参数为下一个管道,同时限定它们都是 Behavior[Photo]
类型。
思考这样一件事情:SpeedFilter
的处理速度比 LicenseFilter
更快,并且能比 LicenseFilter
过滤掉更多的信息。在该情况下,首先将接受到的信息送给 SpeedFilter
更加合理,这为运行更慢的 LicenseFilter
减轻了工作负担。
看,仅仅是调整管道次序也可能为业务代码带来优化,并且通常来说只需要简单修改配置,因为管道的功能并不受顺序的影响。
收集-分发模式
下面是分发 - 收集 ( scatter-gather pattern ) 模式,Akka 内在的异步分配任务的能力可满足该模式的大部分需求。该模式有两种不同的应用场合:
一:竞争任务
第一种情况是,子任务的功能是相同的,但最终只会有一个传递给收集器组件 ( Aggregator ) 结果。比如用户 App 将一笔订单派发给多个厂商,但是只会收集价格最低的响应反馈给用户。当然,系统也可以选择最先响应的厂商,这取决于业务的设计。
二:并行协作处理
比较常见的是第二种情况,比如 Scatter 将大的任务切分成多个不同的子任务,然后将子任务分别派给专门的 Actor 处理,然后再由下游的 Aggregator 整合处理结果。
收集分发模式可以和管道-过滤器模式组合使用。
Akka 并行任务的案例
我们复用开始的例子介绍第二种情况。假定识别车牌和车速由两个不同的组件负责,绘制的流程图如下:
对于 Scatter 而言,它只是简单地将上游的图像信息 PhotoImage
添加标记一个标识符 id
之后 ( 即 PhotoTag
) 再群发给两个组件,它们在各自提取出信息之后又会以 PhotoMsg
的形式发送给 Aggregator,Aggregator 会将 id
标识符相同的两条 PhotoMsg
消息聚合成一条完整的 Photo
消息发送给下游。
出于效率考虑,Aggregator 不会同步地等到某一个 id
的所有 PhotoMsg
消息都到达之后再去接收并处理下一条消息,即各个 id
的 PhotoMsg
消息是乱序到达的。因此我们需要为 Aggregator 开辟一个 cache 空间,以暂存尚未全部到达的那些 id
的消息。代码实现如下:
case class PhotoImage(bytes: Array[Byte])
case class PhotoLabel(id : Int, bytes : Array[Byte] = Array(), to : ActorRef[PhotoMsg])
case class PhotoMsg(id: Int, speed: Option[Int] = None, license: Option[String] = None)
case class Photo(license : String, speed : Int)
object LicenseProcedure:
def apply(): Behavior[PhotoLabel] = Behaviors.receive {
(ctx, photoImage) =>
// 假定 LicenseOCR 会完成车牌识别的功能
photoImage.to ! PhotoMsg(id = photoImage.id,license = LicenseOCR(photoImage.bytes))
Behaviors.same
}
object SpeedProcedure:
def apply(): Behavior[PhotoLabel] = Behaviors.receive {
(ctx, photoImage) =>
// 假定 SpeedChecking 会完成车辆测速的功能
photoImage.to ! PhotoMsg(id = photoImage.id,speed = SpeedChecking(photoImage.bytes))
Behaviors.same
}
object Scatter:
def apply(aggregator: ActorRef[PhotoMsg]): Behavior[PhotoImage] = Behaviors.setup {
ctx =>
var globalId = 0
val p1: ActorRef[PhotoLabel] = ctx.spawn(SpeedProcedure(), "speedProcedure")
val p2: ActorRef[PhotoLabel] = ctx.spawn(LicenseProcedure(), "licenseProcedure")
val workers: List[ActorRef[PhotoLabel]] = List(p1,p2)
Behaviors.receiveMessage {
photoImage =>
workers.foreach(_ ! PhotoLabel(id = globalId,photoImage.bytes,aggregator))
globalId += 1
Behaviors.same
}
}
// Ver1.
object AggregatorV1:
def apply(downstream: ActorRef[Photo]): Behavior[PhotoMsg] = Behaviors.setup {
ctx =>
val cache: ListBuffer[PhotoMsg] = ListBuffer[PhotoMsg]()
Behaviors.receiveMessage {
photoMsg =>
cache.find(_.id == photoMsg.id) match {
case Some(previous) =>
cache -= photoMsg
downstream ! Photo(
photoMsg.license.getOrElse(previous.license.getOrElse("None")),
photoMsg.speed.getOrElse(previous.speed.getOrElse(-1))
)
case None => cache += photoMsg
}
Behaviors.same
}
}
现在完成了 Aggregator 的第一版内容。不过,考虑到如果某些 id
的部分任务失败了,则已到达的剩余消息就会一直累积在 cache
得不到清理。随着并发的不断发生,我们的 cache
可能会无限地增大,这会消耗掉大量的内存。
在这个例子中,某个 id
的第二条消息迟迟不到达,则认为它已经丢包 ( 我们这里认为少量的信息丢失不是致命错误 )。我们不妨为每一条消息设定一个计时器 ( 通过 Behaviors.withTimers
闭包实现 ),在计时后强制将未处理完的消息从 cache
中移除。
case class Timeout(whichId: Int)
// Ver2. 附带超时检测。
object AggregatorV2:
import scala.concurrent.duration.*
def apply(downstream: ActorRef[Photo], after: FiniteDuration = 3 seconds): Behavior[Timeout | PhotoMsg] =
// 视作 timers => ctx => msg => Behavior 的柯里化高阶函数
Behaviors.withTimers {
timers =>
Behaviors.setup {
ctx =>
val cache: ListBuffer[PhotoMsg] = ListBuffer[PhotoMsg]()
Behaviors.receiveMessage {
// 如果某条 id 消息超时了,则将其剩下无用的信息从 cache 中移除。
case Timeout(whichId) =>
// 如果找不到,说明期限内已经将该 id 的消息处理完毕了。
(cache.find(_.id == whichId): @unchecked) match {
case Some(expired) =>
cache -= expired
ctx.log.info("过期数据被删除: id = {}", whichId)
case None => // Do nothing
}
Behaviors.same
case photoMsg: PhotoMsg =>
cache.find(_.id == photoMsg.id) match {
// 如果某个 id 的消息全部到达了,则向下游发送完整信息,清理 cache
case Some(previous) =>
cache -= previous
val p: Photo = Photo(
photoMsg.license.getOrElse(previous.license.getOrElse("None")),
photoMsg.speed.getOrElse(previous.speed.getOrElse(-1))
)
downstream ! p
// 如果某个 id 的第一条消息到达了,则将它放到 cache
case None =>
timers.startSingleTimer(Timeout(photoMsg.id), after)
cache += photoMsg
}
Behaviors.same
}
}
}
我们通过 timers.startSingleTimer
为每一条消息开启了新的计时器。在达到 after
设定的延迟之后,每个计时器便会向 Aggregator 自身发送一条 Timeout(photoMsg.id)
消息,Aggregator 可以依据携带的 id
信息将不可用的消息从 cache
中移除。
有关于 Timers 的更多内容,可以参考官方的:Interaction Patterns • Akka Documentation
这并不是可能出现的唯一问题。如果 Aggregator 因为某种原因失败重启了,我们会丢失 cache
的全部信息。如何解决这个问题呢?ActorRef 在重启之前,其生命周期的钩子方法 preRestart
会被调用。可以利用它实现一版最简单的方案:在真正重启之前,将目前缓存消息全部取出并发送给自身 ActorRef。等下一个 Actor 实例启动时,它会将这些消息接收并重新存储。
关于 Actor 生命周期的重启事件,可以参考笔者的:Akka 实战:解析 Akka 容错机制 - 掘金 (juejin.cn)
在 Akka Typed 中,我们首先通过 .onFailure(SupervisorStrategy.restart)
声明接收到异常时的策略,然后再通过 receiveSignal
闭包 + 信号量判断的方式声明处于 PreRestart 阶段时的动作。最终版本的 Aggregator 代码清单如下所示。
object AggregatorV3:
import scala.concurrent.duration.*
def apply(downstream: ActorRef[Photo], after: FiniteDuration = 3 seconds): Behavior[Timeout | PhotoMsg] =
// 视作 timers => ctx => msg 的柯里化高阶函数
Behaviors.supervise[Timeout | PhotoMsg] {
Behaviors.withTimers {
timers =>
Behaviors.setup {
ctx =>
val cache: ListBuffer[PhotoMsg] = ListBuffer[PhotoMsg]()
Behaviors.receiveMessage[Timeout | PhotoMsg] {
msg =>
/*
太多了。这里我们忽略消息处理本身的部分。
参考 AggregatorV2
*/
Behaviors.same
}.receiveSignal {
case (_, PreRestart) =>
ctx.log.error("准备重启,转出数据")
cache.foreach{
p =>
ctx.self ! p
ctx.log.info(s"消息 {} 已经被转出。",p)
}
Behaviors.same
}
}
}
}.onFailure(SupervisorStrategy.restart)
Akka Typed 容错模式,见:Fault Tolerance • Akka Documentation
路由表模式
路由表模式 ( routing slip ) 可以被看作是管道和过滤器的模式的动态版本。假定有一个汽车工厂,它的订单类可如此表示:
case class Car(color : String = "", hasNavi: Boolean = false, hasSensors: Boolean = false)
它提供两种订单:
- 黑色,无多余配件,即
hasNavi
和hasSensors
为false
。 - 灰色,其余配件的选项均为
true
。
订单分配将由 RouteSlip
完成。我们可以为它画出一个路线图:
一旦用户的选择不同,RouteSlip
会选择不同的路线填充订单内容。就和管道-过滤器模式中一样,每个节点接收,返回的均是同一种数据类型。其实现如下:
case class RouteSlip(routeSlip: List[ActorRef[RouteSlip]], car: Car = Car(), downstream : ActorRef[Car]):
def map(f: Car => Car) : RouteSlip =
RouteSlip(routeSlip, car = f(this.car), downstream = downstream)
def sendMsgToNextTask() : Unit =
this.routeSlip.headOption match {
case Some(nextActor) =>
nextActor ! RouteSlip(
routeSlip = this.routeSlip.tail,
car = this.car,
downstream = downstream
)
case None => downstream ! car
}
对于可能的选择,我们使用一个 List[ActorRef[RouteSlip]]
类型的列表去保存;所有 Actor 节点 ( 我们后文将其命名为 Routee
) 之间的消息以 Car
作为载体;downstream
指向了订单生成之后的下游节点。
map
在这里是一个特化版本,为了方便我们使用 routeSlip.map(_.withColor("Black"))
这样的语法直接操作订单内容。sendMsgToNextTask()
定义了递归消费任务列表的方式:它激活列表中的第一个 ActorRef
,同时将列表剩下的部分 ( this.routeSlip.tail
) 也发送过去,如此循环,当列表任务为空时,最后一个节点将完成的订单消息发送给 downstream
。
我们只需令 Routee
自动完成上述的逻辑,这样用户就不用再关心消息的传递过程了。至于每个 Routee
操作订单的过程,可以抽象成统一的 f : Car => Car
函数,然后委托给 RouteSlip.map
转换子完成。
object Routee:
def apply(f : Car => Car) : Behavior[RouteSlip] = Behaviors.receive {
(ctx,routeSlipMessage) =>
routeSlipMessage.map(f).sendMsgToNextTask()
Behaviors.same
}
抽象 Routee
的本意是减少重复的代码结构,但对于业务员来说,Routee
太过泛化了。枚举出预设好的装配器是一个不错的主意:
enum CarOptions(var behavior: Behavior[RouteSlip]):
case PAINT_CAR(color : String) extends CarOptions(Routee(_.copy(color = color)))
case ADD_NAVI extends CarOptions(Routee(_.copy(hasNavi = true)))
case ADD_SENSORS extends CarOptions(Routee(_.copy(hasSensors = true)))
最后,在单元测试中验证 RouteSlip
的功能。我们首先在上下文中创建出所有的装配器 ( 可以通过隐式转换屏蔽这一过程 ) 并保持活跃状态,因为每个装配器都有可能被选择到。用户要做的就是将想要的配置组合以列表形式提交给 RouteSlip
即可。
class AkkaQuickstartSpec extends ScalaTestWithActorTestKit with AnyWordSpecLike {
import com.example.typeSafe.*
//#definition
"The Probe" must {
"receive Car(Grey,true,true)" in {
import com.example.typeSafe.CarOptions.*
val endPoint: scaladsl.TestProbe[Car] = createTestProbe[Car]()
// 刚运行时,根据 Behavior 创建出所有负责装配的 routee。
val List(paint_green,paint_car,add_navi,add_sensors) =
List(PAINT_CAR("GREEN"),PAINT_CAR("BLACK"),ADD_NAVI,ADD_SENSORS)
.zip(List("paint_green","paint_black","add_navi","add_sensors"))
.map {(op, name) => spawn(op.behavior, name)}
// 根据不同的订单自由选择装配的 routee.
val routing = List(paint_grey,add_navi,add_sensors)
RouteSlip(routing,downstream = endPoint.ref)
.sendMsgToNextTask()
endPoint.expectMessage(Car("Grey",true))
}
}
}
在这里,我们相当于用 Akka 完成了 建造者模式。路由表模式也可以和前文的 FSM 联系起来,通过切换不同的模式将消息导入到不同的路径当中。
使用 Akka Typed 路由进行负载均衡
Akka Typed 中实现的 Router 要更加简单。比如 Akka Classical 支持动态负载均衡,但是 Akka Typed 并未提供此功能。我们最后还会专门讨论在两个包下的 Router 存在的性能差异以及原因。
我们前一章介绍了基于内容的路由模式,本章则介绍基于资源的路由模式,即负载均衡。尤其当我们的企业系统决定进行拓展时,这种模式是必须的。一个 Router 管理多个执行相同功能的 routees,当新的消息到达时,Router 可以将任务派发给空闲的 routee,通过这种方式充分利用计算资源。
Akka 提供了两类 Router :
- Router 池 —— 这类 Router 根据配置的
poolSize
初始化等量的 routee,用户不需要手动创建。 - Router 群组 —— 这类 Router 不负责创建 routees。routees 由用户管理,Router 只负责在需要时向这些 routees 请求服务。
Router 池是最简单的,它自己提供了管理功能,但代价是没有足够的空间容纳 routee 复杂的逻辑。而 Router 群组本质上基于订阅 — 发布模式,这个机制又是通过 Akka Receptionist 完成的,我们也会探讨如何去使用它。两者的区别可用图来表示:
Router Pool
对于简单的任务,选择 Router Pool 更加合适。下面是本例中测试的消息类型 Command
和 routee Worker
。
trait DoLog(var text : String) // Scala 3 特质可以携带参数
case class PlainLog(text_ : String) extends DoLog(text_)
case class BroadCastLog(text_ : String) extends DoLog(text_)
object Worker:
def apply(): Behavior[DoLog] = Behaviors.receivePartial[DoLog]{
case (ctx, PlainLog(text_)) => ctx.log.info("record plain log: {}", text_);Behaviors.same
case (ctx, BroadCastLog(text_)) => ctx.log.info("record global log : {},{}",ctx.self.path.name,text_);Behaviors.same
}
在 Akka Typed 版本中,Router Pool 的创建只需要 Routers.pool
闭包完成。
val poolProps: PoolRouter[DoLog] = Routers.pool(poolSize = 4) {
// 确保 pool 可监控 workers 的状态
Behaviors.supervise(Worker()).onFailure[Exception](SupervisorStrategy.restart)
}.withBroadcastPredicate{_.isInstanceOf[BroadCastLog]}
withBroadcastPredicate
是额外的广播功能,用户通过传入 M => Boolean
的函数决定将哪些消息广播给所有活跃的 routees。比如在这个例子中,poolProps
会将 BroadCastLog
类型的消息广播。
Router Pool 只在初始化阶段创建 routee 并自动监控 ( watch
) 其生命周期。当 routee 处理完消息切换为 Behaviors.stopped
状态,或者因运行异常而停止时,Router Pool 会获悉并将其移除。如果所有的 routee 都被移除了,则 Router Pool 自身也会关闭。我们想要 routees 在遇到错误时应尽可能重启而不是直接挂掉,因此传入 Behaviors.supervise
闭包是更明智的选择。
下方的 Guardian Behavior 可以用于测试 Router Pool。在正常情况下,它应当打印 10 条单发消息 + 4 条广播消息。
// Route Pool
object GuardianBehavior:
def apply() : Behavior[Unit] = Behaviors.setup[Unit] {
ctx =>
val poolProps: PoolRouter[DoLog] = Routers.pool(poolSize = 4) {
// 确保 pool 是监控 workers 的状态
Behaviors.supervise(Worker()).onFailure[Exception](SupervisorStrategy.restart)
}.withBroadcastPredicate{_.isInstanceOf[BroadCastLog]}
val router: ActorRef[DoLog] = ctx.spawn(poolProps,"worker-router-pool")
// 默认是 balancingPool,将消息均匀地散给 routees.
(0 to 10).foreach {n =>
router ! PlainLog(s"msg : ${n}")
}
// 测试全局广播消息
router ! BroadCastLog("heart beat")
Behaviors.ignore
}
Receptionist
在介绍 Router Group 之前,首先介绍基于 Akka Receptionist 构建的订阅 — 发布模式,详情可见官网:Actor discovery • Akka Documentation。由于 Akka Typed 取消了 sender()
方法,消息发送方必须将自身的 ActorRef 放到消息内部,使用 Receptionist 则可以降低通讯双方的耦合关系,即:将联系方式交付给 Akka 系统去保存。
首先,创建全局唯一的 ServiceKey[M]
作为一个群组 Group 的标识。
val Key: ServiceKey[Request] = ServiceKey[Request]("group-1")
群组内的每一个成员称之 receptionist ( 意:接待员 ),它们接收 M
类型的消息。和一般的 Actor 初始化步骤相比,它们额外多了一步注册 ( Register ) 的步骤。注册时需要指定群组标识 ServiceKey
,以及订阅者的 ActorRef
( 通常都是 ctx.self
)。见下文中的代码:
case class Request()
case class Response()
// 发布 - 订阅模式
// 向 ActorSystem 将自己注册为 ServiceKey 标识下的新的 Receptionist.
// 即某一个 Service 的职责由多个 Receptionist 负责。
object Service:
// 接受 Sender 的 Request() 信息
def apply(): Behavior[Request] =
Behaviors.setup { ctx =>
// 发布
ctx.system.receptionist ! Receptionist.Register(Key,ctx.self)
Behaviors.receiveMessage{ req =>
ctx.log.info("a receptionist get {}",req)
Behaviors.same
}
}
订阅方同样通过 ServiceKey
获取某个群组的服务。第一种方式是 Receptionist.Subscribe(Key,ctx.self)
,每当群组的 Receptionists 成员发生变化时,系统会向订阅方推送 ( pull ) 一条 Key.Listing(list)
消息,list
是已经向该服务注册的 ActorRef[M]
成员列表。除非主动取消订阅,否则这种消息推送将是持续的。
object ClientV1:
// 根据 ServiceKey 寻找其下的所有 Receptionist。
def apply(): Behavior[Receptionist.Listing] = Behaviors.setup[Receptionist.Listing]{ ctx =>
// 创建
ctx.spawn(Service(),"receptionist-1")
ctx.spawn(Service(),"receptionist-2")
// 自身 (ctx.self) 长期订阅 Key 的消息。
ctx.system.receptionist ! Receptionist.Subscribe(Key,ctx.self)
// 接受 ActorSystem 回传的订阅消息
Behaviors.receiveMessagePartial {
// 每当有一个 receptionist 注册时,它就会接受到一次消息。
case Key.Listing(list) =>
list.foreach(ps => ps ! Request())
Behaviors.same
}
}
第二种途径是由订阅方在需要时主动从系统中拉取 ( fetch ) 服务的 receptionists,通过 Receptionist.Find(Key,ctx.self)
实现:
object ClientV2:
// 根据 ServiceKey 寻找其下的所有 Receptionist。
def apply(): Behavior[Receptionist.Listing] = Behaviors.setup[Receptionist.Listing]{ ctx =>
// 创建
ctx.spawn(Service(),"receptionist-1")
ctx.spawn(Service(),"receptionist-2")
// 这里要先等待 spawn 完毕,否则有可能接收到不完全的 Key.Listing
Thread.sleep(1000)
// 一次性查找,将找到的 List 发送到 ctx.self.
ctx.system.receptionist ! Receptionist.Find(Key,ctx.self)
// 接受 ActorSystem 回传的订阅消息
Behaviors.receiveMessagePartial {
// 每当有一个 receptionist 注册时,它就会接受到一次消息。
case Key.Listing(list) =>
ctx.log.info("有 {} 个 Receptionist.",list.size)
list.foreach(ps => ps ! Request())
Behaviors.same
}
}
Router Group
Router Group 要求用户自行管理 routees 的生存周期,它只负责向活跃的 routees 请求服务,是基于 Akka Receptionist 机制的拓展。我们使用 SeriviceKey[M]
标记一组唯一的 Workers ( routees )。
case class DoGet(id : Int,txt : String)
val key: ServiceKey[DoGet] = ServiceKey[DoGet]("router-group")
object Worker2:
def apply(): Behavior[DoGet] = Behaviors.setup {
ctx =>
ctx.system.receptionist ! Receptionist.Register(key,ctx.self)
Behaviors.receiveMessage {
doGet =>
ctx.log.info("receive msg: {}",doGet)
Behaviors.same
}
}
在另一端,则需要通过 Router.group(key)
创建一个与对应 routees 建立订阅关系的 GroupRouter[M]
。
object MasterGuardian:
def apply() : Behavior[Unit] = Behaviors.setup{
ctx =>
(1 to 4).foreach { n=>
ctx.spawn(Worker2(),s"worker-${n}")
}
val group: GroupRouter[DoGet] = Routers.group(key)
val router: ActorRef[DoGet] = ctx.spawn(group,"router")
(1 to 14).foreach(n => router ! DoGet(n,"aabaabraa"))
Behaviors.same // trampoline 表示继续维持该状态以处理下一个信息。
}
Router Groups 内部的 Stach 机制保证 Router 至少有一个 worker 注册时再转发消息。
Consisting Hash
截止 2.6.19 版本,Akka Typed 仅保留了三个策略:
- Round-Robin:轮询,是默认的策略,对应
withRoundRobinRouting
选项。当 routees 的成员比较固定时,这种策略能够实现公平的负载均衡。 - Random:随机,对应
withRandomRouting
选项,适用于 routees 成员频繁变动的场合。 - Consistent Hashing:哈希,对应
.withConsistentHashingRouting
选项,将某一批消息发送给固定的 routee。
其中,Consistent Hashing 可以和 Nginx 的 ip_hash
路由策略做类比。ip_hash
保证同一个 ip 的用户请求总是发到同一个下游节点处理,以维持 Session 状态。同样地,如果有多条消息是相关的,则应当通过这种策略发送给同一个 routee 做处理。
Consistent Hashing 策略有两个参数:n 和 mapper。mapper 是一个 M => String
的函数,由用户决定从 M 中提取主码 ( 转换成 String ) 的方式,并参与哈希计算。比如:
object Master:
def apply() : Behavior[Unit] = Behaviors.setup{
ctx =>
(1 to 4).foreach { n=>
ctx.spawn(Worker2(),s"worker-${n}")
}
val group: GroupRouter[DoGet] = Routers.group(key)
// 将 4 个 Actor 划分为 10 个 '桶' (Virtual Nodes)
// 依据 Doget 的 id 进行哈希.
.withConsistentHashingRouting(10,doget => doget.id.toString)
val router: ActorRef[DoGet] = ctx.spawn(group,"router")
// 在 Consisting Hash 模式下,
// 设定 id 相同的消息必定会发送给同一个 routee
router ! DoGet(1,"aaba")
router ! DoGet(1,"aaba")
router ! DoGet(2,"aaaga")
router ! DoGet(2,"aaaga")
router ! DoGet(3,"aaaga")
router ! DoGet(3,"aaaga")
Behaviors.same
}
*为什么 Typed Router 是未优化的?
Akka Typed 的 Router 是基于 Actor 实现的,这意味着它会依赖 Mailbox 依次接收外界的消息并转发给 routees。在高吞吐量环境下,这种机制会限制 Router 的性能,但是 Akka Typed 目前并未为此进行优化。我们不妨先分别翻阅 Actor Classical 中 Actor 和 Router 的实现,然后再分析目前 Typed Router 的设计为什么是次优的。
在默认情况下,Actor 的底层基于 java.util.concurrent.ConcurrentLinkedQueue
构建消息队列 ,同时内部 API 的 MessageQueue
特质声明该队列是一个 N producer - 1 consumer 的线程安全队列。正因如此,开发者在上层操作 Actor 时无需考虑其内部属性的竞态条件。
这个消息队列被封装在 Mailbox
类内部,它混入了 java.lang.Runnable
接口,可以被 ExecutorService
直接调度执行。
我们先来追踪 "向另一个 Actor 发送消息" 具体要经过哪些步骤。首先,我们会调用 !
方法,它实际指向的是对应 ActorCell
的 sendMessage
方法。ActorCell
是 Actor 的运行时。
def underlying: ActorCell = actorCell
override def !(message: Any)(implicit sender: ActorRef = Actor.noSender): Unit =
actorCell.sendMessage(message, sender)
sendMessage
( 可能在序列化之后 ) 会进一步调用 dispatcher.dispatch(this, msgToDispatch)
方法将这条消息投递出去。
def sendMessage(msg: Envelope): Unit =
try {
val msgToDispatch =
if (system.settings.SerializeAllMessages) serializeAndDeserialize(msg)
else msg
dispatcher.dispatch(this, msgToDispatch)
} catch handleException
继续跟进代码。dispatch
方法的实现见 akka.dispatch.Dispatcher
,它包含了两个过程:
- 获取对方的
receiver.mailbox
,然后把消息压入 ( enqueue ) 前文提到的消息队列中。 - 调用
registerForExecution
准备向线程池提交这个任务。
/**
* INTERNAL API
*/
protected[akka] def dispatch(receiver: ActorCell, invocation: Envelope): Unit = {
val mbox = receiver.mailbox
mbox.enqueue(receiver.self, invocation)
registerForExecution(mbox, true, false)
}
Dispatcher
绑定了一个线程池的代理 executorService
。当 registerForExecution
方法确认传入的 mbox
可被执行时,便会调用 executorService.execute(mbox)
真正地将这条处理消息的任务提交给线程池。
protected[akka] override def registerForExecution(
mbox: Mailbox,
hasMessageHint: Boolean,
hasSystemMessageHint: Boolean): Boolean = {
if (mbox.canBeScheduledForExecution(hasMessageHint, hasSystemMessageHint)) { //This needs to be here to ensure thread safety and no races
if (mbox.setAsScheduled()) {
try {
executorService.execute(mbox)
true
} catch {
case _: RejectedExecutionException =>
try {
executorService.execute(mbox)
true
} catch { //Retry once
case e: RejectedExecutionException =>
mbox.setAsIdle()
eventStream.publish(Error(e, getClass.getName, getClass, "registerForExecution was rejected twice!"))
throw e
}
}
} else false
} else false
}
如源代码注释中所写的,mbox
在底层通过 CAS 机制保证了同一个时刻它只会被一个线程处理。最后观察 Mailbox 的 run
方法是如何被定义的:
override final def run(): Unit = {
try {
if (!isClosed) { //Volatile read, needed here
processAllSystemMessages() //First, deal with any system messages
processMailbox() //Then deal with messages
}
} finally {
setAsIdle() //Volatile write, needed here
dispatcher.registerForExecution(this, false, false)
}
}
/**
* Process the messages in the mailbox
*/
@tailrec private final def processMailbox(
left: Int = java.lang.Math.max(dispatcher.throughput, 1),
deadlineNs: Long =
if (dispatcher.isThroughputDeadlineTimeDefined)
System.nanoTime + dispatcher.throughputDeadlineTime.toNanos
else 0L): Unit =
if (shouldProcessMessage) {
val next = dequeue()
if (next ne null) {
if (Mailbox.debug) println("" + actor.self + " processing message " + next)
actor.invoke(next)
if (Thread.interrupted())
throw new InterruptedException("Interrupted while processing actor messages")
processAllSystemMessages()
if ((left > 1) && (!dispatcher.isThroughputDeadlineTimeDefined || (System.nanoTime - deadlineNs) < 0))
processMailbox(left - 1, deadlineNs)
}
}
processMailbox
通过 dispatcher.throughput
限制每次处理的消息数上限,同时通过 deadlineNs
限制每次处理消息的时间上限。到此为止,发起消息的一个完整过程就回顾完了。
那么 Akka Classical Router 又是如何被定制优化的呢?Akka 专门声明了一个拓展了 ActorCell
的 RoutedActorCell
类型,它重写了 sendMessage
方法:
override def sendMessage(envelope: Envelope): Unit = {
if (routerConfig.isManagementMessage(envelope.message))
super.sendMessage(envelope)
else
router.route(envelope.message, envelope.sender)
}
可以看到,向 Router 发送的消息不会像普通 Actor 那样保存到邮箱并向线程池登记,而是直接驱动 router
( 它事实上是一个单独实现的类,而非 Actor ) 寻找可用的 routee 并 立刻 将消息转发出去,见下方的代码。
def route(message: Any, sender: ActorRef): Unit =
message match {
case akka.routing.Broadcast(msg) => SeveralRoutees(routees).send(msg, sender)
case msg => send(logic.select(msg, routees), message, sender)
}
private def send(routee: Routee, msg: Any, sender: ActorRef): Unit = {
if (routee == NoRoutee && sender.isInstanceOf[InternalActorRef])
sender.asInstanceOf[InternalActorRef].provider.deadLetters.tell(unwrap(msg), sender)
else
// 相当于 routee ! unwarp(msg)
routee.send(unwrap(msg), sender)
}
logic
是 RoutingLogic
特质,它定义了如何选择 routee 的 select
抽象方法:
/**
* The interface of the routing logic that is used in a [[Router]] to select
* destination routed messages.
*
* The implementation must be thread safe.
*/
trait RoutingLogic extends NoSerializationVerificationNeeded {
/**
* Pick the destination for a given message. Normally it picks one of the
* passed `routees`, but in the end it is up to the implementation to
* return whatever [[Routee]] to use for sending a specific message.
*
* When implemented from Java it can be good to know that
* `routees.apply(index)` can be used to get an element
* from the `IndexedSeq`.
*/
def select(message: Any, routees: immutable.IndexedSeq[Routee]): Routee
}
因此,Classical Router 相当于节省了一次消息传递的过程,笔者在此做了一副对比图,上面是当 Router 作为普通 Actor 时的路由过程,而下面是被优化之后的路由过程。
这也意味着:在优化的路由过程中,router 的 RoutingLogic
不再受 Mailbox 的线程保护。即,它可能会被多个正在执行的 Actor 并发调用。这也是为什么 RoutingLogic
的源码注释中要求它的实现必须是线程安全 ( thread-safe ) 的。
Typed Router 的这一问题在 Routers • Akka Documentation:Routers and performance 中提出,而解答和线索在:Classic Routing • Akka Documentation How routing is designed in akka 提到。其余的参考链接见:
akka - Difference between a router and an actor - Stack Overflow
Akka源码分析-Router - gabry.wu - 博客园 (cnblogs.com)
简述在akka中发送消息的过程_weixin_30911809的博客-CSDN博客
Akka(4): Routers - 智能任务分配 - 雪川大虫 - 博客园 (cnblogs.com)