前两天,在 envoy-wasm Slack 群里看到,有个 Google 大佬 Yan,又在提把 Rust 搞到 Envoy 里来
这次他提到了 Google 的 crubit,这个 C++ Rust 双向互操作工具,看着还蛮有意思的样子,简单记录下的
用 Rust 开发 Envoy
我印象中,这个在 Envoy 社区其实一直有讨论,包括 Envoy 的创始大佬 Matt 也是 Rust 的粉丝
有两个方向,一个是用 Rust 来写 Envoy 核心,另一个则是写扩展
前者是很激进的,我还没看到有人在尝试,后者则相对容易一些,至少可以看到 snowp 大佬尝试的 PR:用 Rust 来写了个 echo 的 demo,https://github.com/envoyproxy/envoy/pull/25409
哈哈,有点类似于,当年春哥给 Nginx 写的 echo-nginx-module:https://github.com/openresty/echo-nginx-module
不过,这个尝试已经弃坑了,Rust 与 Envoy 这种大型 C++ 交互,并不那么简单
Why not Wasm
Rust 可以编译为 Wasm,并且 Wasm 在 Envoy 也已经有集成,为什么还有人想折腾 Rust 原生扩展呢
群里大佬们也说了不少,结合我的理解聊聊:
Wasm 需要拷贝内存
Wasm 也有内存安全机制,字节码读写 VM 内的线性内存地址,所以拷贝是少不了的,现实的实现中,一次读取可能会有多次拷贝
API 能力有限
必须 proxy-wasm-cpp-sdk 包一层 API,Wasm 才能调用,封装链路很长
Rust 语言特性支持不完善
比如异步函数支持就不够好,需要 Wasm Vm 对异步运行时有支持
Crubit 解决什么
Crubit:C++/Rust 双向互操作工具,这是来自官方的定义:https://github.com/google/crubit
具体来说,包括两个点:
函数互相调用
C++ 和 Rust 之间的函数可以互相调用
内存互相访问
C++ 和 Rust 之间的数据结构,可以互相访问
理想的期望效果就是,Rust 可以像现在 C++ 一样方便来开发扩展
对比来说,可以简化的是:
- 不需要包一层 FFI API 来做跨语言调用,这是现有的非 C++ 扩展都面临的问题
- 不需要手写内存 Struct 的映射,甚至可以直接跨语言读写内存
工作机制
简单说,就是分析源码,自动生成 FFI Wrapper 代码
以 struct 为例,从这个 C++ struct:
1 | struct Position { |
会自动生成如下的 Rust struct:
1 | pub struct Position { |
这样,写 Rust 代码的时候,就可以像 Rust 一样来操作 C++ Struct 了,还是挺方便的
尤其是,像 Envoy 这种大型 C++ 工程,有很多的 Struct 嵌套,手写是个不小的工程,且维护成本也不小的
前景如何
首先 Crubit 这个项目目前还是初始 “MVP” 版本,看文档貌似还有不少的限制
并且,Yan 大佬也还只是一个想法,也还没有玩起来,估计任重而道远
如果真搞成了的话,应该用起来会方便很多了,估计会有新扩展用 Rust 来实现了
并且,如果走出去了这一步,以后没准越来越多的 Envoy 核心代码,也会由 Rust 来写了
那就让我们拭目以待吧~
还有硬骨头
不过,即使搞定了 C++ 和 Rust 的互相操作,还有一个硬骨头在前面等着,也就是异步调度
因为,Envoy 有一套基于单线程的异步并发模型,Rust 也有自己的异步抽象,这两个如何顺利的配合呢
简单说,前面解决的都是 Rust 的同步函数在 Envoy 上运行的问题
现在要解决的是,Rust 的异步函数如何在 Envoy 上运行的问题
tokio 可以么
直接用 tokio 这种异步运行时可以么?答案是不太好搞
tokio 是一个多线程的运行时,有自己的调度机制,我们可以简单类比为 Golang 的 runtime 调度
一个异步函数,有可能会被调度到不同的线程上来执行,这个就会打破 Envoy 的单线程并发模型的约定
(当然,理论上也是可以解决,每个 Envoy 线程,绑定一个单线程的 tokio 运行时)
并且,tokio 需要有自己的主循环来触发 epoll_wait,这个与 Envoy 自己的 epoll_wait 会有冲突的
除非将两者合并,或者 Envoy 自己撸一个 Rust 的异步运行时
这其中的工作量嘛,想想都头大
跟 MoE 有啥区别
哈哈,我的主业是搞 MoE 的,自然是需要拿出对比一番的
MoE 是把 Golang 嵌入到了 Envoy,跟这里聊的 Rust 嵌入到 Envoy 是同一个领域的不同方案
首先,我们碰到的问题是一样的
- 跨语言的函数调用,内存操作,需要一些 Wrapper 胶水代码
- Envoy 有单线程并发模型约定
但是,解题思路不太一样
互操作
MoE 是老老实实自己写 Wrapper 代码,因为定性是扩展开发,其实需要用的 API 并不会太多,总量也比较有限
Rust 方案,其实后续有进入 Envoy Core 代码的可能性,所以这块期望会更高
如果有了更通用的方案,以后 Envoy Core 中的 C++ 代码,慢慢被 Rust 替换掉,也未可知
单线程约束
MoE 比较取巧的绕过了单线程的约束,保留 Goroutine 自由的被调度到其他线程,也就是不需要 Golang runtime 加额外的限制
这样我们可以支持标准的 Golang runtime,现有的 Golang 库直接拿过来就可以用,而不需要改造
但是,我们自己写的 Wrapper 框架代码,又会同时保证 Envoy 的单线程并发约定,所以,也不会有并发问题
而 Rust 的异步机制,并不像 Golang 的 Goroutine 这种完善的重封装,本质上有点类似的 Lua 的协作式协程
甚至,异步运行时都交给第三方库来实现了(好歹 Lua 还是内置提供了 resume 和 yield 这样的调度 API)
所以,Rust 是有机会像 OpenResty 一样,将语言的异步调度和宿主的事件循环结合起来的
最终效果
MoE 一大亮点就是支持原生的 Golang,现有的 Golang 库直接拿来就可以用,而不需要改造
Rust 方案,如果真搞成的话,普世性会比 OpenResty 嵌入 Lua 的效果更好一些
因为 OpenResty 中 Lua 非阻塞库,需要依赖 Nginx 的 event loop 重写一次,比如网络库需要基于 cosocket 重写
而 Rust 的异步运行时,如果与 Envoy 的事件循环结合起来了话,应该现有的异步实现,也可以跑起来的
或许也不需要改造?估计还得取决于具体的实现
不过,虽然我对 Rust 并不熟,不过依然可以笃定这个坑小不了,没那么快到的
最后
还是很期待 Rust 进入 Envoy 的,虽然如果成了的话,会多了一个 Rust 扩展机制,跟 Golang 扩展机制竞争
不过,以我对 Rust 和 Golang 浅显的理解,这两个发展的路线并不太相同,Rust 更偏向于系统编程,Golang 更偏向于业务编程
也就是 Rust 更适合做 Envoy 核心,Golang 更适合做 Envoy 扩展
当然啦,纯粹个人 YY,欢迎大家技术交流~