【译】Rust async/await的开销

989 阅读5分钟

原文链接:https://github.com/jkarneges/rust-async-bench

原文标题:The cost of Rust async/await

公众号:Rust 碎碎念

写在前面:本文是翻译自一个github项目的README,该项目是一个测试Rust手写poll循环和async/await之间的性能差异,项目代码未在文中列出,可在上方原文链接找到该项目并查阅源码。

本项目意在比较手写poll循环和async/await之间的性能差异。它使用在内存中工作的“仿造的” I/O对象。异步执行器(async executor)没有使用分配(allocs)、锁或者线程本地存储,并尝试在进行I/O调用时尽可能地高效。

运行和打印 I/O 调用的用法(Run and print I/O call usage)

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/rust-async-bench`
sync:  register=33 unregister=33 poll=34 accept=64 read=64 write=64
async: register=33 unregister=33 poll=34 accept=64 read=64 write=64

运行基准测试(Run benchmark)

cargo bench

每个基准测试测量了32个请求/响应事务。下面是再Linux上运行后的一些结果:

run_sync                time:   [4.7783us 4.7919us 4.8089us]
run_async               time:   [16.847us 16.928us 17.025us]
run_sync_with_syscalls  time:   [138.07us 141.20us 147.21us]
run_async_with_syscalls time:   [152.09us 153.50us 155.30us]

分析(Analysis)

Rust的异步是“零开销(zero cost)”么?

(在本项目中)非异步基本测试胜出,并且这个项目中的异步引擎是边缘化( borderline contrived )的,可能无法进行更进一步的优化。因此,可以说异步Rust是有开销的。但是,正确看待这一点很重要:

  • 开销仅是一些应用内的状态和函数调用。异步Rust不需要堆分配、线程原语、或其他传统的有开销的操作。系统调用的次数可以保持与poll循环相同。

  • 在一个应用内做任何有意义的事情的开销都可能使得异步执行的开销显得微不足道。例如,仅仅是增加了虚假的系统调用,就大大缩小了基准测试之间的差距,非异步实现只快了9%。

  • 基准测试测试了32个请求。异步和非异步系统调用之间基准测试的差距是12.3us。除以32,每个请求的开销是385ns。在一个服务器程序中,几乎可以忽略不计。为了对比,Box::new(mem::MaybeUninit::<[u8; 16384]>::uninit())在相同的机器上花费了465ns。

它是如何工作的(How it works)

为了模拟一个真实的应用程序,基本测试被实现为一个伪造的网络服务器。它接受“连接”,这是一个双向的字节流。对于每个连接,它读取一行文本作为请求,然后写入一行文本作为响应。

I/O原语是FakeListenerFakeStreamPoll,类似于TcpListenerTcpStream和Mio的Poll。没有客户端,也因此在测试中没有客户端的开销。

要执行的任务有两种:接受连接和处理连接。非异步的版本被实现为一个poll循环,所有的任务都交织在一起。异步版本为每个任务实现单独的future实例, 然后这些任务并发地执行。

两个版本的程序有没有系统调用都可以运行。当开启系统调用时,每当有I/O操作时,libc::read都会在一个空管道上被调用。写一个不使用堆分配或者线程本地存储的单线程的poll循环服务器是相对直观的。而要用async/await做同样的事情,且不使用额外的系统调用,就比较棘手了。下面是在异步实现中用到的一些技巧:

  • 与使用I/O future相比,使用poller的I/O对象在当其被初始化/析构时进行注册/注销。他们还随时记录自己的准备状态。这有助于减少I/O futures的开销。例如,如果一个stream被认为是不可读的并且read被调用,返回的future当轮询(poll)时将立即返回Pending,而不会执行一个系统调用。

  • 对于单一的future类型,F,executor是泛型的。它将future存储为non-boxed值。为了支持只有一种future类型的两种任务,接受处理程序和连接处理程序在同一个async函数中实现,并且通过参数选择所需的任务。这样我们就可以在生成任务时避免堆分配,但代价是所有的future都会占用相同大小的内存。

  • waker指向一个整个future生命周期内都不会move的结构,这个结构包含了对相关executor和任务的引用。这使得waker能够找到executor和它负责的任务,而不需要自己进行任何堆分配或使用线程本地存储来寻找executor。为了保证安全,一个waker(或者更具体的说是waker的底层共享数据,因为waker可以被克隆)不能超过它所服务的future。这是一个非常合理的条件,并且只要一个future完成,executor就会在运行时断言这个条件。

  • 生命周期注解无处不在! 没有使用Rc,所有的共享对象都是以引用的形式传递的。reactor必须与executor和I/O对象活的一样长,executor必须与顶层future活的一样长,顶层future必须活得与I/O对象一样长,I/O对象必须活得与I/O future一样长。不知为何,这一切都能成功。Rust编译器很神奇。

更多说明

  • waker概念的存在似乎是为了实现任务执行与I/O的解耦。然而,不使用单独的reactor对象或使用waker也可以实现一个executor。无论如何,实现都会使用waker,因为Rust async/await就是这样工作的。

  • 一个全局的executor是合理的,它可以让我们更容易管理waker生命周期的安全性。然而,当F是一个匿名的future时,一个泛型F的执行者如何被实例化为一个全局变量还不清楚。

本文首发于个人公众号:Rust碎碎念,非授权禁止转载,谢谢配合。