用 Elixir 的方式编写 Rust
我是Elixir的忠实粉丝,这早已不是什么秘密了。所以,当我开始从事 Rust 开发时,我尝试将 Elixir 的一些理念引入Rust的世界。这篇文章介绍了我正在构建的一些工具,旨在将 Elixir 的强大功能引入 Rust。
是什么让 Elixir 如此出色?
很难只挑选其中的几个,但我认为 Elixir 最大的优势来自于使用Erlang作为底层虚拟机,特别是从以下两个属性来看:
- 大规模并发
- 容错
大规模并发
除非你亲身体验,否则很难解释。我职业生涯早期就认识到,在处理请求时切勿创建线程。线程繁重、成本高昂,过多的线程可能会导致整个系统崩溃。大多数情况下,使用线程池就足够了,但一旦并发任务数量超过池中线程数,这种方法就会失效。
让我们看一个例子:想象一个 Rust 应用程序,它只创建 2000 个线程,每 100 毫秒唤醒一次,然后立即返回睡眠状态。
use std::thread;
use std::time::Duration;
fn main() {
for _ in 0..2_000 {
thread::spawn(|| loop {
thread::sleep(Duration::from_millis(100));
});
}
thread::sleep(Duration::from_secs(1_000));
}
即使线程不执行任何操作,只要在我的 MacBook 上运行这个程序,它就会在几秒钟后强制重启。这使得使用线程实现大规模并发变得不切实际。这个问题有很多解决方案。Elixir 选择的解决方案是使用名为Processes的东西来抽象并发任务。它们非常轻量级,因此即使运行200 万个 Processes也不成问题。
Rust 中的大规模并发
您可以使用异步 Rust实现惊人的并发性和性能,但使用异步 Rust 并不像编写常规 Rust 代码那么简单,而且它不能提供与 Elixir Processes 相同的功能。
在思考了很长时间如何用 Rust 重新组装 Elixir 进程之后,我想到引入一个中间步骤,即WebAssembly。WebAssembly是 Rust 可以针对的低级字节码规范。这个想法很简单,不是为 x86-64 编译 Rust,而是将其编译为 WASM 目标。从那里我将构建一组库和一个 WebAssembly 运行时,以公开Rust 进程的概念。与操作系统进程或线程相反,它们重量轻,占用内存小,创建和终止速度快,调度开销低。在其他语言中,它们也被称为绿色线程和goroutines,但我将它们称为进程,以接近 Elixir 的命名约定。
这是迈向Lunatic 的第一步。
让我们看一下相同的 Rust 示例,但现在使用 Lunatic 实现。同时,我们将并发进程数提升到 20k。
use lunatic::{Channel, Process};
fn main() {
let channel: Channel<()> = Channel::new(0);
for _ in 0..20_000 {
Process::spawn((), process).unwrap();
}
channel.receive();
}
fn process(_: ()) {
loop {
Process::sleep(100);
}
}
要运行它,您需要.wasm
先将此 Rust 代码编译为一个文件:
○ → cargo build --release --target=wasm32-wasi
然后运行它:
○ → lunaticvm example.wasm
与前面的例子相反,这个程序在我的 2013 年末的 Macbook 上运行起来没有任何卡顿,而且 CPU 利用率也很低,即使我们同时运行了 10 倍以上的并发任务。让我们来看看这里到底发生了什么。
Lunatic 生成的进程实际上充分利用了异步 Rust 的强大功能。它们被调度在一个窃取工作的异步执行器 (async executor)之上,与async-std使用的相同。调用它Process::sleep(100)
实际上会调用smol 的at
函数 (function)。
等一下!.await
你可能会问自己,没有这个关键字,这怎么行得通呢?Lunatic 采用了与 Go、Erlang 以及 Rust 早期基于绿色线程的实现相同的方法。它创建一个很小的堆栈来执行进程,并在应用程序需要更多堆栈时增加堆栈大小。这比异步 Rust 在编译时计算确切堆栈大小的效率略低,但我认为这是合理的权衡。
现在您可以编写常规的阻塞代码,但如果您正在等待,执行程序将负责将您的进程移出执行线程,因此您永远不会阻塞线程。
正如我们之前所见,线程调度对于操作系统来说是一项艰巨的任务。要用一个正在执行的线程替换另一个线程,需要做大量的工作(包括保存所有寄存器和一些线程状态)。然而,在 Lunatic 进程之间切换只需要尽可能少的工作。借助libfringe 库的开创性想法以及一些asm! 宏的魔力,Lunatic 可以让 Rust 编译器计算出在上下文切换期间需要保留的最小寄存器数量。这使得 Lunatic 进程的调度成本为零。在我的机器上通常为 1ns,相当于一次函数调用。
在用户空间中调度进程而不是使用线程的另一个好处是,即使您的应用程序行为异常,其他应用程序仍将继续在您的机器上正常运行。
现在我们已经了解了 Lunatic 如何允许您创建具有大量并发性的应用程序,让我们来看看容错能力。
容错
也许最著名的 Eralng/Elixir 哲学就是“让它崩溃”。如果你正在构建复杂的系统,那么预测所有故障场景是不可能的。你的应用程序中不可避免地会出现一些故障,但这些故障不应该导致整个系统崩溃。
Elixir 进程是完全隔离的,彼此之间只能通过消息进行通信。这使得你能够以一种方式设计应用程序,将故障限制在一个进程内,而不会影响其他进程。
Lunatic 在这方面提供了比 Erlang 更强的保障。
每个 Lunatic 进程都有自己的堆、堆栈和系统调用。
让我们看一个 Lunatic 中简单的 TCP 回显服务器的示例:
use lunatic::{Process, net}; // Once WASI gets networking support you will be able to use Rust's `std::net::TcpStream` instead.
use std::io::{BufRead, Write, BufReader};
fn main() {
let listener = net::TcpListener::bind("127.0.0.1:1337").unwrap();
while let Ok(tcp_stream) = listener.accept() {
Process::spawn(tcp_stream, handle).unwrap();
}
}
fn handle(mut tcp_stream: net::TcpStream) {
let mut buf_reader = BufReader::new(tcp_stream.clone());
loop {
let mut buffer = String::new();
buf_reader.read_line(&mut buffer).unwrap();
tcp_stream.write(buffer.as_bytes()).unwrap();
}
}
该应用程序监听localhost:1337
tcp 连接,生成一个进程来处理每个传入连接并仅回显传入的行。
您可以使用以下方法进行测试telnet
:
○ → telnet 127.0.0.1 1337
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello world
Hello world
您首先会注意到的是,我们没有使用任何async
或.await
关键字,即使这个应用程序将充分利用 Rust 的异步 IO。
此外,即使我们调用了崩溃的不安全 C 代码,tcp 连接也会完全封装在进程中:
fn handle(mut tcp_stream: net::TcpStream) {
...
unsafe { crashing_c_function() };
...
}
在这种情况下,崩溃仅局限于一个连接。Elixir 无法实现类似的功能,因为如果调用 C 函数崩溃,整个虚拟机都会崩溃。
Lunatic 的另一个独有功能是可以限制进程的系统调用访问权限。如果我们将上面的spawn
调用替换为:
// Process::spawn_without_fs is not implemented yet.
Process::spawn_without_fs(tcp_stream, handle).unwrap();
任何从handle
函数内部调用的代码都将被禁止使用系统调用进行文件系统访问。这也适用于 C 依赖项,因为强制执行发生在非常低的级别。它允许您表达进程的沙盒要求,并无所畏惧地使用任何依赖项。我尚不清楚是否有其他运行时允许您这样做。
未来
这只是Lunatic即将提供的功能的预告。未来还会有更多功能。一旦打下基础,一个充满无限可能的新世界就将向你敞开。以下是一些我非常期待的功能:
-
透明地将进程从一台机器移动到另一台机器的能力。该编程模型依赖于进程通过消息进行通信,因此这些消息是在本地发送还是在网络上的不同计算机之间发送并不重要。
-
热重载。现在我们有了 WASM 字节码作为中间步骤,因此可以在整个系统仍在运行时从中生成新的 JIT 机器码并替换它。
-
将编译为 WASM 的完整应用程序作为一个进程运行。例如,将文件读写操作从应用程序重定向到 TCP 流,因为我们完全负责系统调用。这样做的好处是,您可以使用代码来建模执行环境。
Lunatic 尚处于早期阶段,因此还有很多开发工作要做。如果您对此感兴趣,或者有任何想法想使用 Lunatic,请通过邮件me@kolobara.com或 Twitter @bkolobara与我联系。
我也想借此机会向 Rust、 Wasmer、Wasmtime、Lucet和waSCC的团队致以诚挚的谢意。如果没有大家的辛勤付出,Lunatic 的诞生是不可能的。
附言:如果你想了解更多关于 Erlang 和 Elixir 的神奇之处,Saša Jurić 的演讲是我最喜欢的之一:Erlang 和 Elixir 的灵魂。真的,去看看吧!
鏂囩珷鏉ユ簮锛�https://dev.to/bkolobara/writing-rust-the-elixir-way-2lm8