Monoio:基于 io-uring 的高性能 Rust Runtime

25,170 阅读4分钟

Monoio 是字节跳动服务框架组开源的基于 io-uring 的 thread-per-core 模型高性能 Rust Runtime,旨在为高性能网络中间件等场景提供必要的运行时。

项目仓库: github.com/bytedance/m…

背景

过去,高性能网络中间件或服务器往往使用 C/C++ 编写,比如我们常见的 Envoy 和 Nginx。它们往往以非常直接的方式和操作系统交互,并且得益于没有垃圾收集机制,相比有 GC 的语言(如 Golang 和 Java),额外开销十分低,延迟稳定。

但是开发这类组件对开发者的专业水平有较高的要求,编程范式上对开发者心智负担巨大,稍有不慎就会造成非预期的后果。举例来说,在 C++ 中要完成一次异步的网络请求,需要将整个流程按照异步点拆分成独立的纯同步函数,并以 callback 的形式将其串联——这一来大大降低了其可读性,二来状态转换和管理容易出错;并且对变量生命周期需要精细管理,否则就会出现悬垂指针等内存问题。

为什么不试试神奇的 Rust 呢?Rust 语言通过引入所有权模型,在不引入垃圾回收的情况下保证了内存安全;并且通过语言内置的异步抽象,支持了 async + await 的异步编程模式。使用一个优秀的 Runtime,即可像写 Golang 一样流畅地在 Rust 中平铺直叙地写异步代码——而性能并不输于 C++。

Rust Runtime 与 thread-per-core 模型

与 Golang 不同,Rust 语言中标准库并没有提供异步运行时(Runtime),只提供了必要的结构抽象。Runtime 负责与操作系统打交道,并对齐标准库的 Future 和 Waker 等定义,用户可以自主选择 Runtime。

当前被广泛使用的 Runtime 是 Tokio,它提供了类似 Golang 调度器的实现,用户的 Task 可以在多个线程之间被调度,较为有效地利用了多核心的性能。

但问题也随之而来:在部分强依赖高性能低延迟的场景下,调度带来的开销反而是用户不希望看到的。在核心数较多的情况下,调度开销反而会抵消调度带来的好处。

Nginx 和 Envoy 这类组件往往使用 thread-per-core 模型,即多少核心就运行多少线程,一个任务一旦被一个线程所接收,它后续的处理则都在该线程上。这种做法下几乎没有跨线程的开销,提升了 CPU 利用率,可以较好地保持系统的线性扩展性。此外,由于没有跨线程,处理逻辑也可以尽可能地利用 thread local 的便利,多数时候无需加锁便可操作共享数据。

面向这类场景,Monoio 基于 io-uring 致力于提供最佳的性能;另外,我们还定义了一个更适合 io-uring 的 IO trait。

性能

我们对比了 Monoio、Tokio 和 Glommio(另一个类似的 Runtime,但在性能目标上不如 Monoio 激进)。

在绝大多数测试中,Monoio 都具有更低的延迟和更高的吞吐。对比 Tokio,在多核场景下 Monoio 可以提供 2 到 3 倍的性能提升(原因主要在于模型上,没有了跨线程同步开销);而对比 Glommio,我们可以在降低延迟的同时,节省约 1/4 到 1/3 的 CPU 占用(性能提升在于更优的调度实现,io-uring 批量 submit)。

更进一步的测试报告和设计上的权衡在 Github Repo 中有详细的文档。

另外,我们还对比了生产中使用的(epoll based) Nginx,在 Proxy 场景下基于 Monoio 写的 TCP 代理可以获得差不多的性能(连接数较多时 Monoio 性能优于 Nginx,较少时差于 Nginx,数据整体上差不多)。与 Envoy 的 TCP Proxy 对比也表明 Monoio 有非常明显的性能优势。

结语

Monoio 提供了 thread-per-core 场景下最高性能的 Runtime 实现。我们的目标是能够让 Rust 在高性能场景下成为替换 C/C++ 的更好选择。目前字节已经开始基于 Rust 和 Monoio 构建下一代 Service Mesh。

当然,没有什么 Runtime 是绝对最佳的选择,Runtime 的选型还是要根据具体的业务场景来。希望我们的 Monoio 可以给某些场景用户多一种选择。

在 Monoio 的设计和实现中我们大量参考了 Tokio 等同类产品,感谢这些项目的贡献者;也希望 Monoio 能够在大家的共同努力下变得更加完善更加易用。