作者介绍:
Zimon Dai,阿里云城市大脑 Rust 开发工程师。
本文根据 Zimon 在首届 RustCon Asia 大会上的演讲整理。
大家好,我今天分享的是我们团队在做的 Distributed Actor System。首先我想说一下这个 Talk「不是」关于哪些内容的,因为很多人看到这个标题的时候可能会有一些误解。
<center> 图 1</center>
第一点,我们不会详细讲一个完整的 Actor System 是怎么实现的,因为 Actor System 有一个很完善的标准,比如说像 Java 的 Akka,Rust 的 Actix 这些都是很成熟的库,在这里讲没有特别大的意义。第二,我们也不会去跟别的流行的 Rust 的 Actor System 做比较和竞争。可能很多人做 Rust 开发的一个原因是 Rust 写的服务器在 Techpower 的 benchmark 上排在很前面,比如微软开发的 Actix,我们觉得 Actix 确实写的很好,而我们也没有必要自己搞一套 Actix。第三,我们不会介绍具体的功能,因为这个库现在并没有开源,但这也是我们今年的计划。
这个 Talk 主要会讲下面几个方向(如图 2),就是我们在做一个 Actor System 或者大家在用 Actor System 类似想法去实现一个东西的时候,会遇到的一些常见的问题 。
<center> 图 2</center>
首先我会讲一讲 Compilation-stable 的 TypeId 和 Proc macros,然后分享一个目前还没有 Stable 的 Rust Feature,叫做 Specialization,最后我们会介绍怎么做一个基于 Tick 的 Actor System,如果你是做游戏开发或者有前端背景的话会比较了解 Tick 这个概念,比如做游戏的话,有 frame rate,你要做 60 帧,每帧大概就是 16 毫秒,大概这样是一个 Tick;前端的每一个 Interval 有一个固定的时长,比如说 5 毫秒,这就是一个 Tick。
1. The TypeId Problem
<center> 图 3</center>
首先讲一下 TypeId。如图 3,比如说我们现在已经有了两个 Actor,它们可能是在分布式系统里面的不同的节点上,要进行网络传输。这个时候你能想到一个很简单的方式:Actor A 通过机器的 Broker A 发了一个消息,这个消息通过网络请求到达了另一个 Broker B,通过这个 Broker B,把这个 Buffer 变成一个 Message 给了目标 Actor B,这是一个常见的网络通信。
<center> 图 4</center>
但是这里面会有一个问题,比如,我们要进行网络通讯的时候,我们实际上是把他编译成了一个没有信息的 Buffer,就是一个 Vec<u8>,Message 本身是有 Type 的(因为 Rust 是强类型的语言,Rust 中所有东西都是有类型的)。怎么把这个信息抹掉,然后当到了目标 Actor 的时候,再把这个类型恢复回来?这是我们今天要讲 TypeId 的问题。
1.1 常见的解决办法
有一个很常见的解决方法,就是给每一个 message 的消息头里加上这个 message 的类型描述,大家可以看下图是一段我写的伪代码:
<center> 图 5</center>
最重要的就是第一个 field,叫做 type_uid,这个 Message 里 payload 具体是什么类型。如果我们给 Actor System 里每一个消息类型都赋予一个独特的 TypeId,那么就可以根据 TypeId 猜出来这个 Message 的 payload 具体是什么东西。第二个 field 就是 receiver,其实就是一个目标的 address。第三个是一个 Buffer,是通过 serialization 的 Buffer。
现在我们把这个问题聚焦到一个更小的具体问题上:我们怎么给每个消息类型赋予一个独特的 TypeId?刚好 Rust 有一个东西可以做这个事情——std::any::Any(图 6)。
<center> 图 6</center>
Rust 里面所有的类型都实现了 Any 这个 Trait,它有一个核心方法,叫做 get _type_id,这个方法刚刚在上周 stable。对任何一个类型调用这个方法的话,就能得到一个独特的 TypeId,它里面是一个 64 位的整数。
有了 TypeId 之后,大家可以想一下对 TypeId 会有什么样的要求?下图中我列举了一些最重要的事情 :
<center> 图 7</center>
首先,这个 TypeId 要对所有的节点都是一致的。比如你有一个消息类型,TypeId 是 1,但在另一个节点里面 1 这个整数可能表示的是另一个消息类型,如果按照新的消息类型去解码这个消息的话,会出现解码错误。所以我们希望这个 TypeId 是在整个 Network 里面都是稳定的。这就导致我们并不可以使用 std 提供的 TypeId。因为很不幸的是 std 的 TypeId 是跟编译的流程绑定的,在你每次编译时都会生成新的 TypeId,也就是说如果整个网络里部署的软件正好是来自两次不同的 Rust 编译的话,TypeId 就会有 mismatch。
这样就会导致一个问题:即便是更新了一个小小的组件,也可能要重新编译整个网络,这是很夸张的 。所以我们现在是利用 Proc Macro 来获得一个稳定的 TypeId 从而解决这个问题。
1.2 Proc Macro
其实这也是社区里面一个很长久的问题,大概从 2015 年左右就有人开始问,特别是很多做游戏编程的人,因为游戏里 identity 都需要固定的 TypeId。
<center> 图 8</center>
这个问题怎么解决呢?很简单,用一个很粗暴的方式:如果我们能够知道每一个消息名字 name,就可以给每一个 name 分一个固定的整数 id,然后把这个组合存到一个文件里,每次编译的时候都去读这个文件,这样就可以保证每次生成的代码里面是固定的写入一个整数,这样 TypeId 就是固定的。
我们怎么做到在编译的时候去读一个文件呢?其实现在几乎是唯一的方法,就是去用 Proc Macro 来做这事。我们看一下这边我们定义了(图 9)一个自己的 TypeId 的类型:
<center> 图 9</center>
UniqueTypeId 这个 Trait 只有一个方法,就是获取 Type-uid,相当于 std 的 Any;struct TypeId 内部只有一个 field,一个整数 t,TypeId 就相当于 std 的 TypeId。
<center> 图 10</center>
图 10 上半部分有一个 Message 叫做 StartTaskRequest,这是我们要使用的消息。然后我们在上面写一个 customer derive。图 10 下半部分就是我们真正去实现它的时候写的 Proc Macro,大家可以看到,我们是用的 quote,里面是真正去实现前面我们讲的 UniqueTypeId 的这个 Trait。然后里面这个 type_uid 方法他返回的 TypeId,实际上是固定写死的。这个 t 的值是 #id,#id 可以在 customer derive 写的过程中从文件中固定读出来的一个变量。
通过这种方法,我们就可以固定的生成代码,每次就写好这个 Type,就是这个 integer,很多的 customer derive 可能只是为了简化代码,但是固定 TypeId 是不用 Proc macro 和 Customer derive 绝对做不到的事情。
然后我们只需要在本地指定一个固定的文件,比如 .toml(图 10 右下角),让里面每一个 message 类型都有一个固定的 TypeId,就可以解决这个问题。
<center> 图 11</center>
获得固定的 TypeId 之后,就可以用来擦除 Rust 中的类型。可以通过 serde 或者 Proto Buffer 来做。把 TypeId 序列化成一个 Buffer,再把 Buffer 反序列化成一个具体的 Type。
<center> 图 12</center>
前面讲了一种方法,根据 Buffer header 的 signature 猜 Type 类型。这个方法整体感觉很像 Java 的 Reflection,就是动态判断一个 Buffer 的具体类型。具体判断可能写这样的代码依次判断这个 message 的 TypeId 是什么(如图 12),比如先判断它是否是 PayloadA 的 TypeId,如果不是的话再判断是否是 PayloadB 的 TypeId……一直往下写,但是你这样也会写很多很多代码,而且需要根据所有的类型去匹配。怎么解决这个问题呢?我们还是要用 Proc Macro 来做这个事情。
<center> 图 13</center>
如图 13,我们在 Actor 里定义一个 message 叫做 handle_message,它内部其实是一个 Macro,这个 Macro 会根据你在写这个 Actor 时注册的所有的消息类型把这些 if else 的判断不停的重复写完。
<center> 图 14</center>
最后我们会得到一个非常简单的 Actor 的架构(如图 14)。我们这里比如说写一个 Sample Actor,首先你需要 customer derive Actor,它会帮你实现 Actor 这个 Trait。接下来要申明接收哪几种消息,#[Message(PayloadA, PayloadB)] 表示 SampleActor 接收的是 PayloadA 和 PayloadB,然后在实现 Actor 这个 Trait 时,customer derive 就会把 if else 类型匹配全部写完全,然后只需要实现一个 Handler 的类把消息处理的方法再写一下。这样下来整个程序架构会非常清晰。
<center> 图 15</center>
总的来说,通过 Proc Macro 我们可以得到一个非常干净的、有 self-explaining 的 Actor Design,同时还可以把 Actor 的声明和具体的消息处理的过程完全分割开,最重要的是我们可以把不安全的 type casting 全部都藏在背后,给用户一个安全的接口。而且这个运行损耗会非常低,因为是在做 integer comparison。
2. Specialization
第二个议题是介绍一下 Specialization,这是 Rust 的一个还没有进入 Stable 的 Feature,很多人可能还不太了解,它是 Trait 方向上的一个重要的 Feature。
<center> 图 16</center>
图 16 中有一个特殊的问题。如果某个消息是有多种编码模式,比如 Serde 有一个很流行的编码叫 bincode(把一个 struct 编码成一个 Buffer),当然也有很多人也会用 Proto-buffer,那么如果 Message 是来自不同的编码模式,要怎么用同样的一种 API 去解码不同的消息呢?
<center> 图 17</center>
这里需要用到一个很新的 RFC#1212 叫做 Specialization,它主要是提供两个功能:第一个是它可以让 Trait 的功能实现互相覆盖,第二个是它允许 Trait 有一个默认的实现。
<center> 图 18</center>
比如说我们先定义了一个 Payload(如图 18),这个 Payload 必须支持 Serde 的 Serialization 和 Deserialization,Payload 的方法也是常规的方法,Serialize 和 Deserialize。最重要的是默认的情况下,如果一个消息只支持 Serde 的编码解码,那我们就调用 bincode。
<center> 图 19</center>
这样我们就可以写一个实现(图 19),前面加一个 Default,加了 Default 之后,如果一个 struct 有这几个 Trait 的支持,那他就会调用 Default。如果多了一个 Trait 的话,就会用多出来的 Trait 的那个新方法。这样大家就可以不断的去通过限制更多的范围来支持更多 Codec。
Specialization 这个 feature,现在只有 nightly 上有,然后只需要开一个 #![feature(specialization)] 就可以用。
3. Tick-based actor system
<center> 图 20</center>
下面来介绍一下 Tick-based actor system,就是我们怎么在一个基于 Tokio 的 actor system 上面实现 Tick,大家都知道 Tokio 是异步的架构,但是我们想做成基于 Tick 的。
Tick 有哪些好处呢?首先 Tick 这个概念会用在很多的地方,然后包括比如说游戏设计、Dataflow、Stream computation(流式计算),还有 JavaScript 的 API,也有点 Tick 的 感觉。如果整个逻辑是基于 Tick 的话,会让逻辑和等待机制变得更加简单,同时也可以做 event hook。
<center> 图 21</center>
具体做法其实很简单。我们可以设计一个新的 struct,比如图 21 中的 WaitForOnce,首先声明一个 deadline,意思是在多少个 Tick 之内我必须得收到一个消息,然后可以提交这个消息的 signature。我们在使用 Tokio 来进行 Network IO 时就可以生成一个 stream,把 stream 每次输出时 Tick 加 1,我们就只需要维护一个 concurrent 的 SkipMap,然后把每一个 Tick 的 waits 全部注册进来。当到达这个 Tick 时,如果该 Tick 所有的 waits 都已经覆盖到了,那你就可以 release 这个 feature,解决掉。
另外,通过 Tick 也可以去做一些 actor system 这个 spec 里面没有的东西。
<center> 图 22</center>
比如在图 22 中列举的,第一点 actor system 很少会允许等待别的 actor,但是基于 Tick 的架构是可以做的,比如设置 deadline 等于 1,表示在下一个 Tick 执行之前,必须得收到这个消息,实际上就实现了一种 actor 之间互相依赖消息的设置。第二个,我们还可以做 pre-fetch,比如现在要去抓取一些资源做预存,不会立刻用这个资源,这样当我真正使用这些资源的时候他可以很快得到,那么可以设置一个比较“遥远”但是没有那么“遥远”的 deadline,比如设置 1000 个 tick 之后,必须拿到一个什么东西,实际上这个消息的 fetch 会有比较大的时间容错。
4. 总结
<center> 图 23</center>
最后总结一下我们的 Distributed Actor System 的一些特性,首先它是基于 Tick 的,并且可以通过 Specialization 支持多种不同的 codecs,然后我们可以通过 TypeId 实现类似 reflection 的效果。最后我们计划在 2019 年左右的时候开源这个 actor system。其实我们有很多系统和线上的业务都是基于 Rust 的,我们也会逐渐的公开这些东西,希望能够在从今年开始跟社区有更多的互动,有更多的东西可以和大家交流。
大会 Talk 视频合集:
https://www.youtube.com/playlist?list=PL85XCvVPmGQjPvweRqkBgnh_HKE5MBB8x