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