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

来,过路人,请坐到我身边来,听老衲讲一讲我对 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等)和普通的同步代码提供一个通用接口?好吧,既然他们提出请求的态度很好,也许我们可以试试。毕竟,代码中唯一的区别就是 asyncawait 关键字的出现,因为你没有做什么花哨的事情。

好吧,这或多或少就是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_onblock_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)。

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

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

复制 crate

另一种可能的解决方法是,正如 features 文档所建议的那样,创建独立的 crate。我们可以有 rspotify-syncrspotify-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_implsync_impl 过程宏来指定自定义实现。它处理得非常好,我们在 Rspotify 中已经使用它一段时间了。

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

如果你有 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也存在这个问题:

修复 maybe_async

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

maybe_async 现在会有两个特性标志:is_syncis_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,如 ncspotspotifyd 是阻塞的,而其他如 spotify-tui 使用异步,所以我不确定他们会怎么想。

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

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

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

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

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