IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    在 Rust 中同时支持异步和同步代码

    smallnest发表于 2024-08-28 00:32:16
    love 0

    来,过路人,请坐到我身边来,听老衲讲一讲我对 Rust 过分要求的故事。

    介绍

    想象一下,你打算用Rust创建一个新库。这个库的唯一功能就是封装一个你需要的公共API, 比如 Spotify API或者 ArangoDB 之类的数据库。这并不是造火箭,你也不是在发明什么新东西或者处理复杂的算法,所以你认为这应该相对简单直接。

    你决定用异步方式实现这个库。你的库中大部分工作都涉及执行HTTP请求,主要是I/O操作,所以使用异步是有道理的(而且,这也是Rust圈里现在的潮流)。你开始编码,几天后就准备好了v0.1.0版本。当 cargo publish 成功完成并将你的作品上传到 crates.io 时,你暗自得意地想: "不错嘛"。

    几天过去了,你在GitHub上收到了一个新通知。有人提了一个问题:

    我如何同步使用这个库?

    我的项目不使用异步,因为对我的需求来说太复杂了。我想尝试你的新库,但不确定怎么轻松地使用它。我不想在代码中到处使用 block_on(endpoint())。。我见过像 reqwest 这样的 crate导出一个 blocking模块,提供完全相同的功能,你能不能也这么做?

    从底层来看,这听起来是个很复杂的任务。为异步代码(需要像 tokio 这样的运行时、awaiting future、pinning等)和普通的同步代码提供一个通用接口?好吧,既然他们提出请求的态度很好,也许我们可以试试。毕竟,代码中唯一的区别就是 async 和 await 关键字的出现,因为你没有做什么花哨的事情。

    好吧,这或多或少就是crate 发生的事情 rspotify ,我曾经和它的创建者 Ramsay 一起维护它。对于那些不知道的人来说,它是 Spotify Web API 的一个包装器。对不了解的人来说,这是一个Spotify Web API的封装。说明一下,我最终确实实现了这个功能,尽管不如我希望的那么干净利落;我会在Rspotify系列的这篇新文章中试图解释这个情况。

    第一种方法

    为了提供更多背景信息,Rspotify 的客户端大致如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct Spotify { /* ... */ }
    impl Spotify {
    async fn some_endpoint(&self, param: String) -> SpotifyResult<String> {
    let mut params = HashMap::new();
    params.insert("param", param);
    self.http.get("/some-endpoint", params).await
    }
    }

    本质上,我们需要让 some_endpoint 同时支持异步和阻塞两种使用方式。这里的关键问题是,当你有几十个端点时,你该如何实现这一点?而且,你怎样才能让用户在异步和同步之间轻松切换呢?

    老掉牙的复制粘贴大法

    这是最初实现的方法。它相当简单,而且确实能用。你只需要把常规的客户端代码复制到 Rspotify 的一个新的 blocking模块里。reqwest(我们用的 HTTP 客户端)和 reqwest::blocking 共用一个接口,所以我们可以在新模块里手动删掉 async 或 .await 这样的关键字,然后把 reqwest 的导入改成 reqwest::blocking。

    这样一来,Rspotify 的用户只需要用 rspotify::blocking::Client 替代 rspotify::Client,瞧!他们的代码就变成阻塞式的了。这会让只用异步的用户的二进制文件变大,所以我们可以把它放在一个叫 blocking 的特性开关后面,大功告成。

    不过,问题后来就变得明显了。整个 crate 的一半代码都被复制了一遍。添加或修改一个端点就意味着要写两遍或删两遍所有东西。

    除非你把所有东西都测试一遍,否则没法确保两种实现是等效的。这主意倒也不坏,但说不定你连测试都复制粘贴错了呢!那可怎么办?可怜的代码审查员得把同样的代码读两遍,确保两边都没问题 —— 这听起来简直就是人为错误的温床。

    根据我们的经验,这确实大大拖慢了 Rspotify 的开发进度,尤其是对于不习惯这种折腾的新贡献者来说。作为 Rspotify 的一个新晋且热情的维护者,我开始研究其他可能的解决方案。

    召唤 block_on

    第二种方法是把所有东西都在异步那边实现。然后,你只需为阻塞接口做个包装,在内部调用 block_on。block_on 会运行 future 直到完成,本质上就是把它变成同步的。你仍然需要复制方法的定义,但实现只需写一次:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    mod blocking {
    struct Spotify(super::Spotify);
    impl Spotify {
    fn endpoint(&self, param: String) -> SpotifyResult<String> {
    runtime.block_on(async move {
    self.0.endpoint(param).await
    })
    }
    }
    }

    请注意,为了调用block_on,您首先必须在端点方法中创建某种运行时。例如,使用tokio :

    1
    2
    3
    4
    5
    let mut runtime = tokio::runtime::Builder::new()
    .basic_scheduler()
    .enable_all()
    .build()
    .unwrap();

    这就引出了一个问题:我们是应该在每次调用端点时都初始化运行时,还是有办法共享它呢?我们可以把它保存为一个全局变量(呃,真恶心),或者更好的方法是,我们可以把运行时保存在 Spotify 结构体中。但是由于它需要对运行时的可变引用,你就得用 Arc<Mutex<T>> 把它包起来,这样一来就完全扼杀了客户端的并发性。正确的做法是使用 Tokio 的 Handle,大概是这样的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    use tokio::runtime::Runtime;
    lazy_static! { // You can also use `once_cell`
    static ref RT: Runtime = Runtime::new().unwrap();
    }
    fn endpoint(&self, param: String) -> SpotifyResult<String> {
    RT.handle().block_on(async move {
    self.0.endpoint(param).await
    })
    }

    虽然使用 handle 确实让我们的阻塞客户端更快了^1,但还有一种性能更高的方法。如果你感兴趣的话,这正是 reqwest 自己采用的方法。简单来说,它会生成一个线程,这个线程调用 block_on 来等待一个装有任务的通道 [^2] (https://nullderef.com/blog/rust-async-sync/#block-on-channels) [^3] (https://nullderef.com/blog/rust-async-sync/#block-on-reqwest)。

    不幸的是,这个解决方案仍然有相当大的开销。你需要引入像 futures 或 tokio 这样的大型依赖,并将它们包含在你的二进制文件中。所有这些,就是为了...最后还是写出阻塞代码。所以这不仅在运行时有成本,在编译时也是如此。这在我看来就是不对劲。

    而且你仍然有不少重复代码,即使只是定义,积少成多也是个问题。reqwest 是一个巨大的项目,可能负担得起他们的 blocking 模块的开销。但对于像 rspotify 这样不那么流行的 crate 来说,这就难以实现了。

    复制 crate

    另一种可能的解决方法是,正如 features 文档所建议的那样,创建独立的 crate。我们可以有 rspotify-sync 和 rspotify-async,用户可以根据需要选择其中一个作为依赖,甚至如果需要的话可以两个都用。问题是 —— 又来了 —— 我们究竟该如何生成这两个版本的 crate 呢?即使使用 Cargo 的一些技巧,比如为每个 crate 准备一个 Cargo.toml 文件(这种方法本身就很不方便),我也无法在不复制粘贴整个 crate 的情况下做到这一点。

    采用这种方法,我们甚至无法使用过程宏,因为你不能在宏中凭空创建一个新的 crate。我们可以定义一种文件格式来编写 Rust 代码的模板,以便替换代码中的某些部分,比如 async/.await。但这听起来完全超出了我们的范畴。

    最终版是:maybe_async crate

    第三次尝试基于一个名为 maybe_async 的 crate。我记得当初发现它时,天真地以为这就是完美的解决方案。

    总之,这个 crate 的思路是,你可以用一个过程宏自动移除代码中的 async 和 .await,本质上就是把复制粘贴的方法自动化了。举个例子:

    1
    2
    #[maybe_async::maybe_async]
    async fn endpoint() { /* stuff */ }

    生成以下代码:

    1
    2
    3
    4
    5
    #[cfg(not(feature = "is_sync"))]
    async fn endpoint() { /* stuff */ }
    #[cfg(feature = "is_sync")]
    fn endpoint() { /* stuff with `.await` removed */ }

    你可以通过在编译 crate 时切换 maybe_async/is_sync 特性来配置是要异步还是阻塞代码。这个宏适用于函数、trait 和 impl 块。如果某个转换不像简单地移除 async 和 .await 那么容易,你可以用 async_impl 和 sync_impl 过程宏来指定自定义实现。它处理得非常好,我们在 Rspotify 中已经使用它一段时间了。

    事实上,它效果如此之好,以至于我让 Rspotify 变成了HTTP 客户端无关的,这比异步/同步无关更加灵活。这使我们能够支持多种 HTTP 客户端,比如 reqwest 和 ureq ,而不用管客户端是异步的还是同步的。

    如果你有 maybe_async,实现HTTP 客户端无关并不是很难。你只需要为 HTTP 客户端定义一个 trait,然后为你想支持的每个客户端实现它:

    一段代码胜过千言万语。(你可以在这里找到 Rspotify 的 reqwest客户端的完整源代码, ureq 也可以在这里找到 )

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #[maybe_async]
    trait HttpClient {
    async fn get(&self) -> String;
    }
    #[sync_impl]
    impl HttpClient for UreqClient {
    fn get(&self) -> String { ureq::get(/* ... */) }
    }
    #[async_impl]
    impl HttpClient for ReqwestClient {
    async fn get(&self) -> String { reqwest::get(/* ... */).await }
    }
    struct SpotifyClient<Http: HttpClient> {
    http: Http
    }
    #[maybe_async]
    impl<Http: HttpClient> SpotifyClient<Http> {
    async fn endpoint(&self) { self.http.get(/* ... */) }
    }

    然后,我们可以进一步扩展,让用户通过在他们的 Cargo.toml 中设置特性标志来选择他们想要使用的客户端。比如,如果启用了 client-ureq,由于 ureq 是同步的,它就会启用 maybe_async/is_sync。这样一来,就会移除 async/.await 和 #[async_impl] 块,Rspotify 客户端内部就会使用 ureq 的实现。

    这个解决方案避免了我之前提到的所有缺点:

    • 完全没有代码重复
    • 无论是在运行时还是编译时都没有额外开销。如果用户想要一个阻塞客户端,他们可以使用 ureq,这样就不会引入 tokio 及其相关依赖
    • 对用户来说很容易理解;只需在 Cargo.toml 中配置一个标志

    不过,先停下来想几分钟,试试看你能不能找出为什么不应该这么做。实际上,我给你9个月时间,这就是我花了多长时间才意识到问题所在...

    问题

    预览

    嗯,问题在于 Rust 中的特性必须是叠加的:"启用一个特性不应该禁用功能,而且通常应该可以安全地启用任意组合的特性"。当依赖树中出现重复的 crate 时,Cargo 可能会合并该 crate 的特性,以避免多次编译同一个 crate。如果您想了解更多详细信息,参考资料对此进行了很好的解释。

    这种优化意味着互斥的特性可能会破坏依赖树。在我们的情况下,maybe_async/is_sync 是一个由 client-ureq 启用的 切换特性。所以如果你试图同时启用 client-reqwest 来编译,它就会失败,因为 maybe_async 将被配置为生成同步函数签名。不可能有一个 crate 直接或间接地同时依赖于同步和异步的 Rspotify,而且根据 Cargo 参考文档,maybe_async 的整个概念目前是错误的。

    新特性解析器 v2

    一个常见的误解是,这个问题可以通过"特性解析器v2"来修复,参考文档也对此进行了很好的解释。从2021版本开始,这个新版本已经默认启用了,但你也可以在之前的版本的 Cargo.toml 中指定使用它。这个新版本除了其他改进,还在一些特殊情况下避免了特性的统一,但不包括我们的情况:

    • 对于当前未在构建的目标,启用在平台特定依赖项上的特性会被忽略。
    • 构建依赖和过程宏不会与普通依赖共享特性。
    • 除非构建需要它们的目标(如测试或示例),否则开发依赖不会激活特性。

    为了以防万一,我自己尝试复现了这个问题,结果确实如我所料。这个代码库是一个特性冲突的例子,在任何特性解析器下都会出错。

    其他失败

    有一些 crate也存在这个问题:

    • arangors 和 aragog :ArangoDB 的包装器。两者都用于 maybe_async 在异步和同步之间切换(arangors 事实上,的作者是同一个人)^5 [^6] (https://nullderef.com/blog/rust-async-sync/#aragog-error)。

    • inkwell :LLVM 的包装器。它支持多个版本的 LLVM,但彼此之间不兼容[7]。

    • k8s-openapi :Kubernetes 的包装器,与 inkwell ^8存在同样的问题。

    修复 maybe_async

    随着这个 crate 开始变得流行起来,有人在 maybe_async 中提出了这个问题,解释了情况并展示了一个修复方案:
    async 和 sync 在同一程序中 fMeow/maybe-async-rs #6

    maybe_async 现在会有两个特性标志:is_sync 和 is_async。这个 crate 会以同样的方式生成函数,但会在标识符后面添加 _sync 或 _async 后缀,这样就不会冲突了。例如:

    1
    2
    #[maybe_async::maybe_async]
    async fn endpoint() { /* stuff */ }

    现在将生成以下代码:

    1
    2
    3
    4
    5
    #[cfg(feature = "is_async")]
    async fn endpoint_async() { /* stuff */ }
    #[cfg(feature = "is_sync")]
    fn endpoint_sync() { /* stuff with `.await` removed */ }

    然而,这些后缀会引入噪音,所以我在想是否有可能以更符合人体工程学的方式来实现。我fork了maybe_async并尝试了一下,你可以在这一系列评论中读到更多相关内容。总的来说,这太复杂了,我最终放弃了。

    修复这个边缘情况的唯一方法就是让Rspotify对所有人的可用性变差。但我认为,同时依赖异步和同步版本的人可能很少;实际上我们还没有收到任何人的抱怨。与reqwest不同,rspotify是一个"高级"库,所以很难想象它会在一个依赖树中出现多次。

    也许我们可以向Cargo的开发者寻求帮助?

    官方支持

    虽然不是官方的,但 Rust 中可以进一步探索的另一种有趣方法是“Sans I/O”。这是一个 Python 协议,它抽象了网络协议(如 HTTP)的使用,从而最大限度地提高了可重用性。Rust 中现有的一个示例是 tame-oidc。

    Rspotify 远不是第一个遇到这个问题的项目,所以阅读之前的相关讨论可能会很有趣:

    • 这个现已关闭的 Rust 编译器 RFC 添加 oneof 配置谓词(类似 #[cfg(any(…))])来支持互斥特性。这只是让在别无选择的情况下拥有冲突特性变得更容易,但特性仍应该是严格叠加的。
    • 前一个 RFC 在 Cargo 本身允许互斥特性的背景下引发了一些讨论,尽管有一些有趣的信息,但并没有取得太大进展。
    • Cargo 中的这个问题 解释了 Windows API 的类似情况。讨论包括更多示例和解决方案想法,但还没有被 Cargo 采纳。
    • Cargo 中的另一个问题 要求提供一种方法来轻松测试和构建不同标志组合。如果特性是严格叠加的,那么 cargo test --all-features 将涵盖所有情况。但如果不是,用户就必须用多个特性标志组合运行命令,这相当麻烦。非官方的 cargo-hack 已经可以实现这一点。
    • 一种完全不同的方法 基于关键字泛型倡议。这似乎是解决这个问题的最新尝试,但仍处于"探索"阶段, 截至目前还没有可用的 RFC。

    根据这条旧评论,这不是 Rust 团队已经否决的东西;它仍在讨论中。

    虽然是非官方的,但另一个可以在 Rust 中进一步探索的另一种有趣方法是 “Sans I/O”。这是一种 Python 协议,它在我们的案例中抽象了 HTTP 等网络协议的使用,从而最大化了可重用性。Rust 中现有的一个例子是 tame-oidc。

    结论

    我们目前面临以下选择:

    • 忽视 Cargo 参考。我们可以假设没有人会同时使用 Rspotify 的同步和异步版本。
    • 修复 maybe_async 并为我们库中的每个端点添加 _async 和 _sync 后缀。
    • 放弃支持异步和同步代码。这已经变成了一团糟,我们没有足够的人力来处理,而且它影响了 Rspotify 的其他部分。问题是一些依赖 rspotify 的 crate,如 ncspot 或 spotifyd 是阻塞的,而其他如 spotify-tui 使用异步,所以我不确定他们会怎么想。

    我知道这是我给自己强加的问题。我们可以直接说"不。我们只支持异步"或"不。我们只支持同步"。虽然有用户对能够使用两者感兴趣,但有时你就是得说不。如果这样一个特性变得如此复杂,以至于你的整个代码库变成一团糟,而你没有足够的工程能力来维护它,那这就是你唯一的选择。如果有人真的很在意,他们可以直接 fork 这个 crate 并将其转换为同步版本供自己使用。

    毕竟,大多数 API 封装库等只支持异步或阻塞代码中的一种。例如,serenity (Discord API)、sqlx (SQL 工具包)和 teloxide (Telegram API)是仅异步的,而且它们非常流行。。

    尽管有时候很沮丧,但我并不后悔花了这么多时间兜圈子试图让异步和同步都能工作。我最初为 Rspotify 做贡献就是为了_学习。我没有截止日期,也没有压力,我只是想在空闲时间尝试改进 Rust 中的一个库。而且我确实学到了_很多;希望在读完这篇文章后,你也是如此。

    也许今天的教训是,我们应该记住 Rust 毕竟是一种低级语言,有些事情如果不引入大量复杂性是不可能实现的。无论如何,我期待 Rust 团队将来如何解决这个问题。

    那么你怎么看?如果你是 Rspotify 的维护者,你会怎么做?如果你愿意,可以在下面留言。



沪ICP备19023445号-2号
友情链接