引言
在本文中,我将讨论不同的策略,以逐步将 Rust 引入使用其他语言编写的服务器中,例如 JavaScript、Python、Java、Go、PHP、Ruby 等。你之所以想要这样做,主要是因为你已经对服务器进行了性能分析,发现了一个热点函数,该函数由于 CPU 瓶颈而无法满足你的性能要求,而且由于某种原因,通常的技巧,如记忆函数或改进其算法,在这种情况下不可行或无效。你得出结论,值得研究将函数实现替换为用更高效的 CPU 语言编写的代码,比如 Rust。太好了,那么这篇文章绝对适合你。
这些策略按层次排序,其中“层次”是“Rust 采用层次”的简称。第一层是完全不使用 Rust。最后一层是用 Rust 重写整个服务器。
我们将应用并基准测试的示例服务器将用 JS 实现,在 Node.js 运行时上运行。这些策略可以推广到任何其他语言或运行时。
本文中每个示例的完整源代码都可以在 这个仓库[1] 中找到。
策略
Tier 0: 不使用 Rust
假设我们有一个 Node.js 服务器,它有一个 HTTP 端点,接受一个文本字符串作为查询参数,并返回一个 200px 乘以 200px 的 PNG 图像,该图像将文本编码为 QR 码。
以下是服务器代码的样子:
const express = require('express');
const generateQrCode = require('./generate-qr.js');
const app = express();
app.get('/qrcode', async (req, res) => {
const { text } = req.query;
if (!text) {
return res.status(400).send('缺少 "text" 查询参数');
}
if (text.length > 512) {
return res.status(400).send('文本必须 <= 512 字节');
}
try {
const qrCode = await generateQrCode(text);
res.setHeader('Content-Type', 'image/png');
res.send(qrCode);
} catch (err) {
res.status(500).send('生成 QR 码失败');
}
});
app.listen(42069, '127.0.0.1');
热点函数看起来像这样:
const QRCode = require('qrcode');
/**
* @param {string} text - 要编码的文本
* @returns {Promise<Buffer>|Buffer} - qr 码
*/
module.exports = function generateQrCode(text) {
return QRCode.toBuffer(text, {
type: 'png',
errorCorrectionLevel: 'L',
width: 200,
rendererOpts: {
// 这些选项是在测试中选择的,因为它们提供了速度和压缩之间的最佳平衡
deflateLevel: 9, // 0 - 9
deflateStrategy: 3, // 1 - 4
},
});
}
我们可以通过调用以下方式来访问该端点:
http://localhost:42069/qrcode?text=https://www.reddit.com/r/rustjerk/top/?t=all
这将正确生成这个 QR 码 PNG:
无论如何,让我们向这个服务器发送数万个请求,持续 30 秒,看看它的性能如何:
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应 | 内存 |
---|---|---|---|---|---|
Tier 0 | 1464 req/sec | 68 ms | 96 ms | 1506 字节 | 1353 MB |
由于我还没有描述我的基准测试方法,这些结果本身是没有意义的,我们不能说这是“好”还是“坏”的性能。没关系,因为我们不关心绝对数字,我们将使用这些结果作为基线,与以下所有实现进行比较。每个服务器都在相同的环境下进行测试,所以相对比较将是准确的。
关于异常高的内存使用,这是因为我在“集群模式”下运行 Node.js,它为我的测试机器上的每个 12 个 CPU 核心生成 12 个进程,每个进程都是一个独立的 Node.js 实例,这就是为什么即使我们有一个非常简单的服务器,它也占用了 1300+ MB 的内存。JS 是单线程的,所以如果我们要让 Node.js 服务器充分利用多核 CPU,我们必须这样做。
Tier 1: Rust CLI 工具
对于这个策略,我们用 Rust 重写热点函数,将其编译为一个独立的 CLI 工具,然后从我们的主机服务器调用它。
让我们首先用 Rust 重写函数:
/** qr_lib/lib.rs **/
use qrcode::{QrCode, EcLevel};
use image::Luma;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
pub type StdErr = Box<dyn std::error::Error>;
pub fn generate_qr_code(text: &str) -> Result<Vec<u8>, StdErr> {
let qr = QrCode::with_error_correction_level(text, EcLevel)?;
let img_buf = qr.render::<Luma<u8>>()
.min_dimensions(200, 200)
.build();
let mut encoded_buf = Vec::with_capacity(512);
let encoder = PngEncoder::new_with_quality(
&mut encoded_buf,
// 这些选项是在测试中选择的,因为它们提供了速度和压缩之间的最佳平衡
CompressionType::Default,
FilterType::NoFilter,
);
img_buf.write_with_encoder(encoder)?;
Ok(encoded_buf)
}
然后让我们将其制作成一个 CLI 工具:
/** qr_cli/main.rs **/
use std::{env, process};
use std::io::{self, BufWriter, Write};
use qr_lib::StdErr;
fn main() -> Result<(), StdErr> {
let mut args = env::args();
if args.len() != 2 {
eprintln!("用法:qr-cli <text>");
process::exit(1);
}
let text = args.nth(1).unwrap();
let qr_png = qr_lib::generate_qr_code(&text)?;
let stdout = io::stdout();
let mut handle = BufWriter::new(stdout.lock());
handle.write_all(&qr_png)?;
Ok(())
}
我们可以使用这个 CLI 如下:
qr-cli https://youtu.be/cE0wfjsybIQ?t=74 > crab-rave.png
这将正确生成这个 QR 码 PNG:
现在让我们更新主机服务器中的热点函数,以调用这个 CLI:
const { spawn } = require('child_process');
const path = require('path');
const qrCliPath = path.resolve(__dirname, './qr-cli');
/**
* @param {string} text - 要编码的文本
* @returns {Promise<Buffer>} - qr 码
*/
module.exports = function generateQrCode(text) {
return new Promise((resolve, reject) => {
const qrCli = spawn(qrCliPath, [text]);
const qrCodeData = [];
qrCli.stdout.on('data', (data) => {
qrCodeData.push(data);
});
qrCli.stderr.on('data', (data) => {
reject(new Error(`生成 qr 码时出错:${data}`));
});
qrCli.on('error', (err) => {
reject(new Error(`启动 qr-cli 失败 ${err}`));
});
qrCli.on('close', (code) => {
if (code === 0) {
resolve(Buffer.concat(qrCodeData));
} else {
reject(new Error('qr-cli 未成功退出'));
}
});
});
};
现在让我们看看这个变化对性能的影响:
绝对测量
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应 | 内存 |
---|---|---|---|---|---|
Tier 0 | 1464 req/sec | 68 ms | 96 ms | 1506 字节 | 1353 MB |
Tier 1 | 2572 req/sec 🥇 | 39 ms 🥇 | 78 ms 🥇 | 778 字节 🥇 | 1240 MB 🥇 |
相对测量
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应 | 内存 |
---|---|---|---|---|---|
Tier 0 | 1.00x | 1.00x | 1.00x | 1.00x | 1.00x |
Tier 1 | 1.76x 🥇 | 0.57x 🥇 | 0.82x 🥇 | 0.52x 🥇 | 0.92x 🥇 |
哇,我没想到吞吐量能增加 76%!这是一个非常原始的策略,所以看到它这么有效很有趣。平均响应大小也从 1506 字节减半到 778 字节,Rust 库中的压缩算法必须比 JS 库中的更好。我们每秒提供更多的请求,并返回更小的响应,所以我认为这是一个伟大的结果。
Tier 2: Rust Wasm 模块
对于这个策略,我们将把 Rust 函数编译成 Wasm 模块,然后使用 Wasm 运行时从主机服务器加载和运行它。不同语言的 Wasm 运行时的一些链接:
语言 | Wasm 运行时 | Github 星星 |
---|---|---|
JavaScript | 内置 | - |
Java | GraalWasm[2] | 20.3k+ |
多种 | wasm3[3] | 7.3k+ |
Go | Wazero[4] | 4.9k+ |
多种 | extism[5] | 4.2k+ |
Python | wasmer-python[6] | 2k+ |
PHP | wasmer-php[7] | 1k+ |
Ruby | wasmer-ruby[8] | 500+ |
由于我们要集成到 Node.js 服务器中,让我们使用 wasm-bindgen
来生成我们的 Rust Wasm 代码和 JS 代码将用于相互交互的胶水代码。
以下是更新后的 Rust 代码:
/** qr_wasm_bindgen/lib.rs **/
use wasm_bindgen::prelude::*;
#[wasm_bindgen(js_name = generateQrCode)]
pub fn generate_qr_code(text: &str) -> Result<Vec<u8>, JsError> {
qr_lib::generate_qr_code(text)
.map_err(|e| JsError::new(&e.to_string()))
}
编译上述代码使用 wasm-pack
后,我们可以将构建的资产复制到我们的 Node.js 服务器,并在热点函数中像这样使用它们:
const wasm = require('./qr_wasm_bindgen.js');
/**
* @param {string} text - 要编码的文本
* @returns {Buffer} - QR 码
*/
module.exports = function generateQrCode(text) {
return Buffer.from(wasm.generateQrCode(text));
};
更新后的基准测试:
绝对测量
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应 | 内存 |
---|---|---|---|---|---|
Tier 0 | 1464 req/sec | 68 ms | 96 ms | 1506 字节 | 1353 MB |
Tier 1 | 2572 req/sec | 39 ms | 78 ms | 778 字节 🥇 | 1240 MB 🥇 |
Tier 2 | 2978 req/sec 🥇 | 34 ms 🥇 | 63 ms 🥇 | 778 字节 🥇 | 1286 MB |
相对测量
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应 | 内存 |
---|---|---|---|---|---|
Tier 0 | 1.00x | 1.00x | 1.00x | 1.00x | 1.00x |
Tier 1 | 1.76x | 0.57x | 0.82x | 0.52x 🥇 | 0.92x 🥇 |
Tier 2 | 2.03x 🥇 | 0.50x 🥇 | 0.66x 🥇 | 0.52x 🥇 | 0.95x |
使用 Wasm 使吞吐量翻倍,与基线相比!然而,与早期使用 CLI 工具调用的原始策略相比,性能提升比我预期的要小。
无论如何,虽然 wasm-bindgen
是一个优秀的 JS 到 Rust Wasm 绑定生成器,但没有适用于其他语言的等效工具,如 Python、Java、Go、PHP、Ruby 等。我不想让这些人失望,所以我将解释如何手工编写绑定。免责声明:代码会变得丑陋,所以除非你真的对看到香肠是如何制作的感兴趣,否则你可以跳过下一个部分。
手工制作 Wasm 绑定
关于 Wasm 的有趣之处在于,它只支持四种数据类型:i32
、i64
、f32
和 f64
。然而,对于我们的用例,我们需要从主机传递一个字符串到 Wasm 函数,并且 Wasm 函数需要返回一个数组到主机。Wasm 没有字符串或数组。那么我们如何解决这个问题呢?
答案取决于有几个洞见:
Wasm 模块的内存在 Wasm 实例和主机之间共享,两者都可以读取和修改它。 Wasm 模块只能请求最多 4GB 的内存,所以每个可能的内存地址都可以编码为 i32
,因此这种数据类型也用作内存地址指针。
如果我们想从主机向 Wasm 函数传递一个字符串,主机必须直接将字符串写入 Wasm 模块的内存,然后向 Wasm 函数传递两个 i32
:一个指向字符串的内存地址,另一个指定字符串的字节长度。
如果我们想从 Wasm 函数向主机传递一个数组,主机首先需要提供 Wasm 函数一个 i32
,指向数组应该被写入的内存地址,然后当 Wasm 函数完成后,它返回一个 i32
,表示写入的字节数。
然而,现在我们有了一个新问题:当主机写入 Wasm 模块的内存时,它如何确保不会覆盖 Wasm 模块使用的内存?为了让主机能够安全地写入内存,它必须首先请求 Wasm 模块为其分配空间。
好了,现在所有的上下文都说清楚了,我们终于可以看看这段代码,实际上理解它:
/** qr_wasm/lib.rs **/
use std::{alloc::Layout, mem, slice, str};
// 主机调用此函数以分配空间,其中它可以安全地写入数据
#[no_mangle]
pub unsafe extern "C" fn alloc(size: usize) -> *mut u8 {
let layout = Layout::from_size_align_unchecked(
size * mem::size_of::<u8>(),
mem::align_of::<usize>(),
);
std::alloc::alloc(layout)
}
// 在分配文本缓冲区和输出缓冲区后,主机调用此函数以生成 QR 码 PNG
#[no_mangle]
pub unsafe extern "C" fn generateQrCode(
text_ptr: *const u8,
text_len: usize,
output_ptr: *mut u8,
output_len: usize,
) -> usize {
// 从内存中读取文本,主机已将其写入
let text_slice = slice::from_raw_parts(text_ptr, text_len);
let text = str::from_utf8_unchecked(text_slice);
let qr_code = match qr_lib::generate_qr_code(text) {
Ok(png_data) => png_data,
// 错误:无法生成 QR 码
Err(_) => return 0,
};
if qr_code.len() > output_len {
// 错误:输出缓冲区太小
return 0;
}
// 将生成的 QR 码 PNG 写入输出缓冲区,主机将在此后从此读取
let output_slice
= slice::from_raw_parts_mut(output_ptr, qr_code.len());
output_slice.copy_from_slice(&qr_code);
// 返回写入的 PNG 数据长度
qr_code.len()
}
编译这个 Wasm 模块后,以下是我们如何从 JS 使用它:
const path = require('path');
const fs = require('fs');
// 获取 Wasm 文件
const qrWasmPath = path.resolve(__dirname, './qr_wasm.wasm');
const qrWasmBinary = fs.readFileSync(qrWasmPath);
// 实例化 Wasm 模块
const qrWasmModule = new WebAssembly.Module(qrWasmBinary);
const qrWasmInstance = new WebAssembly.Instance(
qrWasmModule,
{},
);
// JS 字符串是 UTF16,但我们需要在将它们传递给我们的 Wasm 模块之前将它们重新编码为 UTF8
const textEncoder = new TextEncoder();
// 告诉 Wasm 模块为我们分配两个缓冲区:
// - 第一个缓冲区:一个输入缓冲区,我们将
// 将生成 QR 码函数
// 将读取的 UTF8 字符串写入其中
// - 第二个缓冲区:一个输出缓冲区,生成 QR 码函数将
// 将 QR 码 PNG 字节写入其中,我们将在此后读取
const textMemLen = 1024;
const textMemOffset = qrWasmInstance.exports.alloc(textMemLen);
const outputMemLen = 4096;
const outputMemOffset = qrWasmInstance.exports.alloc(outputMemLen);
/**
* @param {string} text - 要编码的文本
* @returns {Buffer} - QR 码
*/
module.exports = function generateQrCode(text) {
// 将 UTF16 JS 字符串转换为 Uint8Array
let encodedText = textEncoder.encode(text);
let encodedTextLen = encodedText.length;
// 将字符串写入 Wasm 内存
qrWasmMemory = new Uint8Array(qrWasmInstance.exports.memory.buffer);
qrWasmMemory.set(encodedText, textMemOffset);
const wroteBytes = qrWasmInstance.exports.generateQrCode(
textMemOffset,
encodedTextLen,
outputMemOffset,
outputMemLen,
);
if (wroteBytes === 0) {
throw new Error('未能生成 qr');
}
// 从 Wasm 内存中读取 QR 码 PNG 字节并返回
return Buffer.from(
qrWasmInstance.exports.memory.buffer,
outputMemOffset,
wroteBytes,
);
};
这就是我们使用像 wasm-bindgen
这样的库时在幕后生成的内容。无论如何,我测试了它,并且手工编写的绑定的性能与生成的绑定的性能几乎相同。
所以编写 Wasm 胶水代码在主机和客户端之间显然不是一件有趣的事情。幸运的是,积极为 Wasm 规范做出贡献的人们意识到了这一点,他们目前正在研究“组件模型”提案,该提案将标准化一个称为 WIT(Wasm 接口类型)的 IDL(接口定义语言),绑定生成器和 Wasm 运行时可以围绕它构建。
目前,有一个名为 wit-bindgen
的 Rust 项目,它将根据 WIT 文件为 Rust 编写的 Wasm 模块生成胶水代码,但你将需要一个单独的工具来生成主机胶水代码,如 jco
,它可以在给定 Wasm 和 WIT 文件的情况下生成 JS 胶水代码。
使用 wit-bindgen
+ jco
将给你一个与仅使用 wasm-bindgen
类似的结果,但希望将来会有更多针对其他语言的 WIT 主机绑定生成器被编写出来,以便 Python、Java、Go、PHP、Ruby 等程序员有一个解决方案,就像今天的 wasm-bindgen
对 JS 程序员一样方便易用。
Tier 3: Rust 原生函数
对于这个策略,我们将在 Rust 中编写函数,将其编译为本地代码,然后从主机运行时加载和执行它。各种语言的 Rust bindgen 库的表格:
语言 | Rust bindgen | Github 星星 |
---|---|---|
Python | pyo3[9] | 12.2k+ |
JavaScript | napi-rs[10] | 6k+ |
Erlang | rustler[11] | 4.3k+ |
多种 | uniffi-rs[12] | 2.8k+ |
Java | jni-rs[13] | 1.2k+ |
Ruby | rutie[14] | 900+ |
PHP | ext-php-rs[15] | 500+ |
多种 | diplomat[16] | 500+ |
由于我们的示例服务器是用 JS 编写的,我们将使用 napi-rs
。以下是 Rust 代码:
use napi::bindgen_prelude::*;
use napi_derive::napi;
#[napi]
pub fn generate_qr_code(text: String) -> Result<Vec<u8>, Status> {
qr_lib::generate_qr_code(&text)
.map_err(|e| Error::from_reason(e.to_string()))
}
我喜欢它有多容易。在写了前一节的 Rust Wasm 模块之后,我对实现和维护绑定生成器库的人们有了新的认识和尊重。
在构建上述代码后,以下是我们如何从 Node.js 使用它:
const native = require('./qr_napi.node');
/**
* @param {string} text - 要编码的文本
* @returns {Buffer} - QR 码
*/
module.exports = function generateQrCode(text) {
return Buffer.from(native.generateQrCode(text));
};
现在让我们看看这个小家伙能不能飞:
绝对测量
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应 | 内存 |
---|---|---|---|---|---|
Tier 0 | 1464 req/sec | 68 ms | 96 ms | 1506 字节 | 1353 MB |
Tier 1 | 2572 req/sec | 39 ms | 78 ms | 778 字节 🥇 | 1240 MB 🥇 |
Tier 2 | 2978 req/sec | 34 ms | 63 ms | 778 字节 🥇 | 1286 MB |
Tier 3 | 5490 req/sec 🥇 | 18 ms 🥇 | 37 ms 🥇 | 778 字节 🥇 | 1309 MB |
相对测量
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应 | 内存 |
---|---|---|---|---|---|
Tier 0 | 1.00x | 1.00x | 1.00x | 1.00x | 1.00x |
Tier 1 | 1.76x | 0.57x | 0.82x | 0.52x 🥇 | 0.92x 🥇 |
Tier 2 | 2.03x | 0.50x | 0.66x | 0.52x 🥇 | 0.95x |
Tier 3 | 3.75x 🥇 | 0.26x 🥇 | 0.39x 🥇 | 0.52x 🥇 | 0.97x |
原来本地代码确实很快!与基线相比,我们的吞吐量几乎翻了两番,与 Wasm 实现相比也翻了一番。
Tier 4: Rust 重写
在这个策略中,我们将用 Rust 重写主机服务器。诚然,对于大多数现实世界的案例来说,这是不切实际的,通常可以看到 100k+ 行服务器代码库。在那些情况下,我们只能重写主机服务器的一个子集。现在大多数人在后端运行所有东西时都会使用反向代理,所以部署一个新的 Rust 服务器并修改反向代理配置以将一些请求路由到 Rust 服务器不会给许多人的后端设置增加太多额外的操作开销。
所以这里是用 Rust 重写的服务器:
/** qr-server/main.rs **/
use std::process;
use axum::{
extract::Query,
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
#[derive(serde::Deserialize)]
struct TextParam {
text: String,
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/qrcode", get(handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:42069")
.await
.unwrap();
println!(
"server {} listening on {}",
process::id(),
listener.local_addr().unwrap(),
);
axum::serve(listener, app).await.unwrap();
}
async fn handler(
Query(param): Query<TextParam>
) -> Result<Response, (StatusCode, &'static str)> {
if param.text.len() > 512 {
return Err((
StatusCode::BAD_REQUEST,
"text must be <= 512 bytes"
));
}
match qr_lib::generate_qr_code(¶m.text) {
Ok(bytes) => Ok((
[(header::CONTENT_TYPE, "image/png"),],
bytes,
).into_response()),
Err(_) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
"failed to generate qr code"
)),
}
}
让我们看看它是否名副其实:
绝对测量
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应 | 内存 |
---|---|---|---|---|---|
Tier 0 | 1464 req/sec | 68 ms | 96 ms | 1506 字节 | 1353 MB |
Tier 1 | 2572 req/sec | 39 ms | 78 ms | 778 字节 🥇 | 1240 MB |
Tier 2 | 2978 req/sec | 34 ms | 63 ms | 778 字节 🥇 | 1286 MB |
Tier 3 | 5490 req/sec | 18 ms | 37 ms | 778 字节 🥇 | 1309 MB |
Tier 4 | 7212 req/sec 🥇 | 14 ms 🥇 | 27 ms 🥇 | 778 字节 🥇 | 13 MB 🥇 |
相对测量
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应 | 内存 |
---|---|---|---|---|---|
Tier 0 | 1.00x | 1.00x | 1.00x | 1.00x | 1.00x |
Tier 1 | 1.76x | 0.57x | 0.82x | 0.52x 🥇 | 0.92x |
Tier 2 | 2.03x | 0.50x | 0.66x | 0.52x 🥇 | 0.95x |
Tier 3 | 3.75x | 0.26x | 0.39x | 0.52x 🥇 | 0.97x |
Tier 4 | 4.93x 🥇 | 0.21x 🥇 | 0.28x 🥇 | 0.52x 🥇 | 0.01x 🥇 |
那不是打字错误。Rust 服务器真的只用了 13 MB 的内存,同时每秒提供 7200+ 请求。我得说它确实名副其实!
总结思考
我认为所有策略都很好,但 Tier 4 脱颖而出,性价比最高。如果你可以使用现成的绑定生成器库,那么用 Rust 编写原生函数非常容易,并且它对性能有深远的影响。
Tier 4 最难的部分可能是学习 Rust 本身,如果你还不熟悉它,但如果你正处于这种情况下,你应该阅读 2024 年学习 Rust[17].。
这个仓库: https://github.com/pretzelhammer/using-rust-in-non-rust-servers
[2]GraalWasm: https://github.com/oracle/graal/tree/master/wasm
[3]wasm3: https://github.com/wasm3/wasm3
[4]Wazero: https://github.com/tetratelabs/wazero
[5]extism: https://github.com/extism/extism
[6]wasmer-python: https://github.com/wasmerio/wasmer-python
[7]wasmer-php: https://github.com/wasmerio/wasmer-php
[8]wasmer-ruby: https://github.com/wasmerio/wasmer-ruby
[9]pyo3: https://github.com/pyo3/pyo3
[10]napi-rs: https://github.com/napi-rs/napi-rs
[11]rustler: https://github.com/rusterlium/rustler
[12]uniffi-rs: https://github.com/mozilla/uniffi-rs
[13]jni-rs: https://github.com/jni-rs/jni-rs
[14]rutie: https://github.com/danielpclark/rutie
[15]ext-php-rs: https://github.com/davidcole1340/ext-php-rs
[16]diplomat: https://github.com/rust-diplomat/diplomat
[17]2024 年学习 Rust: ./learning-rust-in-2024.md