被认为有害的非惰性 Future

2022 年 1 月 26 日 - 4 分钟

用吸引点击的标题引起注意后,解释其含义。当前 Rust 代码可能因对 async Rust 代码的某些假设而以非常微妙的方式出错,这些假设不一定总是正确的。

故事始于最近在sentry-rust中修复的一个 bug(表现为内存泄漏),该 bug 突出了问题的两个方面。

问题的根本原因并非特定于 Sentry SDK,在其他情况下也很容易发生。将使用tracing生态系统中的示例,因为其用户基础可能比 Sentry 更广泛。

问题归结为async fn X()fn X() -> impl Future之间非常微妙的差异,以及第二个不保证完全惰性的事实。

async fn按定义是惰性的。通过async_fn()调用它只会将参数捕获到一个匿名Future类型中,但在其他方面保证不会立即执行函数体,而是在poll时执行。对于返回impl Future的函数以及返回(泛型或命名)期货的特性函数则不是这样。这些函数在调用时poll 时可能有不同数量的代码运行。

fn fully_lazy() -> impl Future<Output = ()> {
   async move {
      println!("print happens on *poll*");
   }
}

fn not_lazy() -> impl Future<Output = ()> {
   println!("print happens on *call*");
   std::future::ready(())
}

如果可能,将所有代码移到 async 块中,但这并不总是可行的。

[](#aside-named-types) 旁白:命名类型

目前存在async fn返回匿名类型的问题,返回impl Future也有同样的问题。在稳定的 Rust 中,今天无法命名该类型。当希望将未来放在struct或集合中时,需要命名类型。一般来说,为一个箱子的每个公共 API 类型命名也是一种良好的实践。

同样的原则也是每个Iterator组合器返回其自己的命名类型的原因,也是存在[tracing::instrument::Instrumented](https://docs.rs/tracing/latest/tracing/instrument/struct.Instrumented.html)[sentry::SentryFuture](https://docs.rs/sentry/latest/sentry/struct.SentryFuture.html)的原因。

夜间 Rust 提供了[type_alias_impl_trait](https://github.com/rust-lang/rust/issues/63063)特性,允许为其他匿名期货命名。不幸的是,由于是夜间特性,在稳定的 Rust 中尚未可用。

在稳定的 Rust 上我们可以使用的下一个最好的东西是Pin<Box<dyn Future>>,如下所示:

fn dyn_future() -> Pin<Box<dyn Future<Output = ()>>> {
    Box::pin(async move {
        println!("print happens on *poll*");
    })
}

这也是async-trait箱子所做的去糖操作(嗯几乎)。生成的类型有一个名称,我们可以将其放入struct和集合中。然而,它有堆分配和动态调度的缺点,并且在#![no_std]构建中也不可用。

因此,更广泛的社区似乎已经决定创建自己的Future类型,这些类型通常通过泛型包装内部期货。这本身就带来了很多不便,因为手动实现Future::poll是一场噩梦,通常需要手动unsafe代码,或者引入一个外部依赖pin-project

出于这个原因,我采用了以下模式,我打赌很多其他人也这样做:

fn returns_named_future() -> NamedFuture {
    // 尽可能多地进行逻辑而无需使用`await`

    // 返回结果期货,在`poll`内部完成其余操作
    NamedFuture
}

[](#problematic-tracing-example) 有问题的tracing示例

为了进一步说明问题,用一个完整的实际tracing示例来演示两个问题,该示例遵循[tracing::Span文档](https://docs.rs/tracing/lates...中的指南:

use std::future::Future;

use tracing::Instrument;

fn fully_lazy() -> impl Future<Output = ()> {
    async move {
        tracing::info!("log happens on *poll*");
    }
}

fn not_lazy() -> impl Future<Output = ()> {
    tracing::info!("log happens on *call*");
    std::future::ready(())
}

fn broken_parent_lazy() -> impl Future<Output = ()> {
    let span = tracing::info_span!("broken_parent");
    fully_lazy().instrument(span)
}

fn broken_parent_not_lazy() -> impl Future<Output = ()> {
    let span = tracing::info_span!("broken_parent");
    not_lazy().instrument(span)
}

fn correct_parent_lazy() -> impl Future<Output = ()> {
    let span = tracing::info_span!("correct_parent");
    span.in_scope(|| fully_lazy()).instrument(span)
}

fn correct_parent_not_lazy() -> impl Future<Output = ()> {
    let span = tracing::info_span!("correct_parent");
    span.in_scope(|| not_lazy()).instrument(span)
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    broken_parent_lazy().await;
    broken_parent_not_lazy().await;

    correct_parent_lazy().await;
    correct_parent_not_lazy().await;
}

运行此示例输出以下内容:

2022-01-26T14:48:11.115115Z  INFO broken_parent: lazy_futures: log happens on *poll*
2022-01-26T14:48:11.115511Z  INFO lazy_futures: log happens on *call*
2022-01-26T14:48:11.115732Z  INFO correct_parent: lazy_futures: log happens on *poll*
2022-01-26T14:48:11.116020Z  INFO correct_parent: lazy_futures: log happens on *call*

可以看到,第二行日志(来自broken_parent_not_lazy().await)没有在预期的正确范围内运行。其他示例按预期工作,要么调用者处理在调用中执行代码的不太可能的情况,要么被调用者更正确地不在调用中执行任何代码。

[](#conclusion) 结论

这里有两个建议,涵盖了问题的两个方面:

  • 不要对第三方代码是惰性的做出假设。外部代码可能以在调用poll中执行实际代码的方式“行为不端”。
  • 使所有代码完全惰性。不要在调用poll中进行任何计算。外部代码可能依赖于期货代码是完全惰性的,否则可能会违反预期。
阅读 7
0 条评论