下一站 - Ihcblog!

远方的风景与脚下的路 | 子站点:ihc.im

0%

Rust Runtime 设计与实现-设计篇 Part1

本系列文章主要介绍如何设计和实现一个基于 io-uring 的 Thread-per-core 模型的 Runtime。

我们的 Runtime 最终产品 Monoio 现已开源,你可以在 github.com/bytedance/monoio 找到它。

  1. Rust Runtime 设计与实现-科普篇
  2. Rust Runtime 设计与实现-设计篇-Part1
  3. Rust Runtime 设计与实现-设计篇-Part2
  4. Rust Runtime 设计与实现-组件篇
  5. Rust Runtime 设计与实现-IO兼容篇

本文是系列的第二篇,开始以 Monoio 为例讲一些 Runtime 上的设计取舍,同时会参考和对比 Tokio 和 Glommio 的设计。

Monoio

Motivation

我在字节参与 Mesh Proxy(基于 Envoy)的研发过程中,我感觉我们不得不因为 C++ 的问题而采取非常不优雅的代码组织和设计。

因此我尝试调研了基于 Linkerd2-proxy(一个基于 Rust + Tokio 的 Mesh Proxy)来替代现有版本。压测数据显示,在 HTTP 场景性能提升只有 10% 左右;而 Envoy 压测数据显示非常多的 CPU 消耗是在 syscall 上。

我们可以利用 Rust 泛型编程消除 C++ 中的基于动态分发的抽象带来的运行时开销;在 IO 上,我们考虑利用 io-uring 来替代 epoll。

前期调研

项目前期我们对比了几种方案下的性能:1. Tokio 2. Glommio 3. 裸 epoll 4. 裸 io-uring。之后发现裸 io-uring 性能上确实领先,但基于 io-uring 的 Glommio 的表现并不如人意。我们尝试 fork Glommio 的代码并做优化,发现它的项目本身存在较大问题,比如创建 uring 的 flag 似乎都没有正确理解;同时,它的 Task 实现相比 Tokio 的也性能较差。

自己造轮子

最终我们决定自己造一套 Runtime 来满足内部需求,提供极致的性能。

该项目是我和 @dyxushuai 两人共同完成的。在我们实现过程中,我们大量参考了 Tokio、Tokio-uring 等项目,并且尝试了一些自己的设计。

模型讨论

不同的设计模型会有各自擅长的应用场景。

Tokio 使用了公平调度模型,其内部的调度逻辑类似 Golang,任务可以在线程之间转移,这样能尽可能充分地利用多核心的性能。

Glommio 也是一个 Rust Runtime,它基于 io-uring 实现,调度逻辑相比 Tokio 更加简单,是 thread-per-core 模型。

两种模型各有优劣,前者更加灵活,通用型更强,但代价也并不小:

  1. 在多核机器上的性能表现不佳。

    在我的 1K Echo 的测试中(2021-11-26 latest version),Tokio 4 Core 下性能只是 1 Core 下性能的 2.2 倍左右。而我们自己的 Monoio 可以基本保持线性。

    1 Core 4 Cores
    1core 4cores

    详细的测试报告在这里

  2. 对 Task 本身的约束也不能忽视。如果 Task 可以在 Thread 之间调度,那么它必须实现 Send + Sync。这对用户代码是一个不小的限制。

    举例来说,如果要实现一个 cache 服务,基于公平调度模型的话,cache 对应的 map 就要通过 Atomic 或 Mutex 等来确保 Send + Sync;而如果实现成 thread-per-core 模型,直接使用 thread local 就可以了。以及,nginx 和 envoy 也是基于这种模型。

但是 thread-per-core 并不是银弹。例如,在业务系统中,不同的请求可能处理起来的逻辑是不同的,有的长连接需要做大量的运算,有的则几乎不怎么消耗 CPU。如果基于这种模型,那么很可能导致 CPU 核心之间出现不均衡,某个核心已经被满载,而另一个核心又非常空闲。

事件驱动

这里主要讨论 io-uring 和 epoll。

epoll 只是通知机制,本质上事情还是通过用户代码直接 syscall 来做的,如 read。这样在高频 syscall 的场景下,频繁的用户态内核态切换会消耗较多资源。io-uring 可以做异步 syscall,即便是不开 SQ_POLL 也可以大大减少 syscall 次数。

io-uring 的问题在于下面几点:

  1. 兼容问题。平台兼容就不说了,linux only(epoll 在其他平台上有类似的存在,可以基于已经十分完善的 mio 做无缝兼容)。linux 上也会对 kernel 版本有一定要求,且不同版本的实现性能还有一定差距。大型公司一般还会有自己修改的内核版本,所以想持续跟进 backport 也是一件头疼事。同时对于 Mac/Windows 用户,在开发体验上也会带来一定困难。
  2. Buffer 生命周期问题。io-uring 是全异步的,Op push 到 SQ 后就不能移动 buffer,一定要保证其有效,直到 syscall 完成或 Cancel Op 执行完毕。无论是在 C/C++ 还是 Rust 中,都会面临 buffer 生命周期管理问题。epoll 没有这个问题,因为 syscall 就是用户做的,陷入 syscall 期间本来就无法操作 buffer,所以可以保证其持续有效直到 syscall 返回。

生命周期、IO 接口与 GAT

前一小节提到了 io-uring 的这个问题:需要某种机制保证 buffer 在 Op 执行期间是有效的。

考虑下面这种情况:

  1. 用户创建了 Buffer
  2. 用户拿到了 buffer 的引用(不管是 & 还是 &mut)来做 read 和 write。
  3. Runtime 返回了 Future,但用户直接将其 Drop 了。
  4. 现在没有人持有 buffer 的引用了,用户可以直接将其 Drop 掉。
  5. 但是,buffer 的地址和长度已经被提交给内核,它可能即将被处理,也可能已经在处理中了。我们可以推入一个 CancelOp 进去,但是我们也不能保证 CancelOp 被立刻消费。
  6. Kernel 这时已经在操作错误的内存啦,如果这块内存被用户程序复用,会导致内存破坏。

如果 Rust 实现了 Async Drop,这件事还能做——以正常的方式拿引用来使用 buffer;然鹅木有,我们不能保证及时取消掉内核对 buffer 的读写。

所以,我们很难在不拿所有权的情况下保证 buffer 的有效性。这样就对 IO 接口有个新的挑战:常规的 IO 接口只需要给 &self&mut self,而我们必须要给所有权。

这部分设计我们参考了 tokio-uring,并把它定义为了 trait。这个 Trait 必须启动 GAT。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/// AsyncReadRent: async read with a ownership of a buffer
pub trait AsyncReadRent {
/// The future of read Result<size, buffer>
type ReadFuture<'a, T>: Future<Output = BufResult<usize, T>>
where
Self: 'a,
T: 'a;
/// The future of readv Result<size, buffer>
type ReadvFuture<'a, T>: Future<Output = BufResult<usize, T>>
where
Self: 'a,
T: 'a;

/// Same as read(2)
fn read<T: IoBufMut>(&self, buf: T) -> Self::ReadFuture<'_, T>;
/// Same as readv(2)
fn readv<T: IoVecBufMut>(&self, buf: T) -> Self::ReadvFuture<'_, T>;
}

/// AsyncWriteRent: async write with a ownership of a buffer
pub trait AsyncWriteRent {
/// The future of write Result<size, buffer>
type WriteFuture<'a, T>: Future<Output = BufResult<usize, T>>
where
Self: 'a,
T: 'a;
/// The future of writev Result<size, buffer>
type WritevFuture<'a, T>: Future<Output = BufResult<usize, T>>
where
Self: 'a,
T: 'a;

/// Same as write(2)
fn write<T: IoBuf>(&self, buf: T) -> Self::WriteFuture<'_, T>;

/// Same as writev(2)
fn writev<T: IoVecBuf>(&self, buf_vec: T) -> Self::WritevFuture<'_, T>;
}

类似 Tokio 的做法,我们还提供了一个带默认实现的 Ext:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
pub trait AsyncReadRentExt<T: 'static> {
/// The future of Result<size, buffer>
type Future<'a>: Future<Output = BufResult<usize, T>>
where
Self: 'a,
T: 'a;

/// Read until buf capacity is fulfilled
fn read_exact(&self, buf: T) -> <Self as AsyncReadRentExt<T>>::Future<'_>;
}

impl<A, T> AsyncReadRentExt<T> for A
where
A: AsyncReadRent,
T: 'static + IoBufMut,
{
type Future<'a>
where
A: 'a,
= impl Future<Output = BufResult<usize, T>>;

fn read_exact(&self, mut buf: T) -> Self::Future<'_> {
async move {
let len = buf.bytes_total();
let mut read = 0;
while read < len {
let slice = buf.slice(read..len);
let (r, slice_) = self.read(slice).await;
buf = slice_.into_inner();
match r {
Ok(r) => {
read += r;
if r == 0 {
return (Err(std::io::ErrorKind::UnexpectedEof.into()), buf);
}
}
Err(e) => return (Err(e), buf),
}
}
(Ok(read), buf)
}
}
}

pub trait AsyncWriteRentExt<T: 'static> {
/// The future of Result<size, buffer>
type Future<'a>: Future<Output = BufResult<usize, T>>
where
Self: 'a,
T: 'a;

/// Write all
fn write_all(&self, buf: T) -> <Self as AsyncWriteRentExt<T>>::Future<'_>;
}

impl<A, T> AsyncWriteRentExt<T> for A
where
A: AsyncWriteRent,
T: 'static + IoBuf,
{
type Future<'a>
where
A: 'a,
= impl Future<Output = BufResult<usize, T>>;

fn write_all(&self, mut buf: T) -> Self::Future<'_> {
async move {
let len = buf.bytes_init();
let mut written = 0;
while written < len {
let slice = buf.slice(written..len);
let (r, slice_) = self.write(slice).await;
buf = slice_.into_inner();
match r {
Ok(r) => {
written += r;
if r == 0 {
return (Err(std::io::ErrorKind::WriteZero.into()), buf);
}
}
Err(e) => return (Err(e), buf),
}
}
(Ok(written), buf)
}
}
}

开启 GAT 可以让我们很多事情变得方便。

我们在 trait 中的关联类型 Future 上定义了生命周期,这样它就可以捕获 &self 而不是非要 Clone self 中的部分成员,或者单独定义一个带生命周期标记的结构体。

定义 Future

如何定义一个 Future?常规我们需要定义一个结构体,并为它实现 Future trait。这里的关键在于要实现 poll 函数。这个函数接收 Context 并同步地返回 Poll。要实现 poll 我们一般需要手动管理状态,写起来十分困难且容易出错。

这时你可能会说,直接 asyncawait 不能用吗?事实上 async 块确实生成了一个状态机,和你手写的差不多。但是问题是,这个生成结构并没有名字,所以如果你想把这个 Future 的类型用作关联类型就难了。这时候可以开启 type_alias_impl_trait 然后使用 opaque type 作为关联类型;也可以付出一些运行时开销,使用 Box<dyn Future>

生成 Future

除了使用 async 块外,常规的方式就是手动构造一个实现了 Future 的结构体。这种 Future 有两种:

  1. 带有所有权的额 Future,不需要额外写生命周期标记。这种 Future 和其他所有结构体都没有关联,如果你需要让它依赖一些不 Copy 的数据,那你可以考虑使用 RcArc 之类的共享所有权的结构。
  2. 带有引用的 Future,这种结构体本身上就带有生命周期标记。例如,Tokio 中的 AsyncReadExtread 的签名是 fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> Read<'a, Self>。这里构造的 Read<'a, Self> 捕获了 self 和 buf 的引用,相比共享所有权,这是没有运行时开销的。但是这种 Future 不好作为 trait 的 type alias,只能开启 generic_associated_typestype_alias_impl_trait,然后使用 opaque type。

定义 IO trait

通常,我们的 IO 接口要以 poll 形式定义(如 poll_read),任何对 IO 的包装都应当基于这个 trait 来做(我们暂时称之为基础 trait)。

但是为了用户友好的接口,一般会提供一个额外的 Ext trait,主要使用其默认实现。Ext trait 为所有实现了基础 trait 的类自动实现。例如,read 返回一个 Future,显然基于这个 future 使用 await 要比手动管理状态和 poll 更容易。

那为什么基础 trait 使用 poll 形式定义呢?不能直接一步到位搞 Future 吗?因为 poll 形式是同步的,不需要捕获任何东西,容易定义且较为通用。如果直接一步到位定义了 Future,那么,要么类似 Ext 一样直接把返回 Future 类型写死(这样会导致无法包装和用户自行实现,就失去了定义 trait 的意义),要么把 Future 类型作为关联类型(前面说了,不开启 GAT 没办法带生命周期,即必须 static)。

所以总结一下就是,在目前的 Rust 稳定版本中,只能使用 poll 形式的基础 trait + future 形式的 Ext trait 来定义 IO 接口。

在开启 GAT 后这件事就能做了。我们可以直接在 trait 的关联类型中定义带生命周期的 Future,就可以捕获 self 了。

这是银弹吗?不是。唯一的问题在于,如果使用了 GAT 这一套模式,就要总是使用它。如果你在 poll 形式和 GAT 形式之间反复横跳,那你会十分痛苦。基于 poll 形式接口自行维护状态,确实可以实现 Future(最简单的实现如 poll_fn);但反过来就很难受了:你很难存储一个带生命周期的 Future。虽然使用一些 unsafe 的 hack 可以做(也有 cost)这件事,但是仍旧,限制很多且并不推荐这么做。monoio-compat 基于 GAT 的 future 实现了 Tokio 的 AsyncReadAsyncWrite,如果你非要试一试,可以参考它。

Waker 和 vtable

TODO

Buffer 管理

Buffer 管理参考了 tokio-uring 设计。

Buffer 由用户提供所有权,并在 Future 完成时将所有权返回回去。
利用 Slab 维护一个全局状态,当 Op drop 时转移内部 Buffer 所有权至全局状态,并在 CQE 时做真正销毁。正常完成时丢回所有权。

欢迎关注我的其它发布渠道