我最大的困扰:在Rust中同时支持异步和同步代码
简介 #
假设你想用Rust创建一个新库。这个库只是封装了一个你需要使用的公共API,比如Spotify API或某个数据库如ArangoDB。这并非高深莫测的技术,你既没有在发明新事物,也没有处理复杂算法,因此你认为这应该相对简单。
你决定使用 async 实现该库。库中大部分工作涉及执行 HTTP 请求,而这些请求大多是 I/O 操作,因此使用 async 是有道理的(而且这也是 Rust 社区当前流行的做法)。你开始编写代码,几天后就准备好了 v0.1.0 版本。当 cargo publish
成功完成并将你的作品上传到 crates.io 时,你说道:“太棒了!”
几天后,你在 GitHub 上收到一条新通知。有人提交了一个问题:
如何同步使用这个库?
我的项目不使用异步,因为它对我来说过于复杂。我想尝试你的新库,但不确定如何轻松实现。我不想在代码中填满
block_on(endpoint())
。我看到过像reqwest
这样的 crates,它导出了一个功能完全相同的blocking
模块,您也能做到吗?
从低级角度来看,这似乎是一项非常复杂的任务。为异步代码(需要像 tokio
这样的运行时、等待未来、固定等)和常规同步代码提供一个通用的接口?我的意思是,他们提出的要求很合理,也许我们可以尝试一下。毕竟,代码中唯一的区别就是 async
和 await
关键字的出现,因为你并没有做任何复杂的事情。

好吧,这或多或少就是我与创建者 Ramsay 一起维护的 crate rspotify
发生的事情。对于不了解的人来说,这是一个 Spotify Web API 的封装库。需要澄清的是,我最终让它正常工作了,尽管没有我希望的那样干净利落;我将在 Rspotify 系列 的这篇新文章中尝试解释情况。
初步尝试 #
为了提供更多背景,以下是Rspotify客户端的大致结构:
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::blocking
而非reqwest
。
然后,Rspotify 用户只需使用 rspotify::blocking::Client
而不是 rspotify::Client
,就这样!他们的代码现在是阻塞的。这会增加异步用户二进制文件的大小,所以我们可以将其功能门禁为 blocking
并完成。
不过,问题后来变得更加清晰。Crate 代码的一半是重复的。添加新端点或修改它意味着要写或删除所有内容两次。
除非你测试了所有内容,否则无法确保两个实现是等效的。这也不是一个坏主意,但也许你复制粘贴测试时出了错!怎么样?可怜的审查者必须阅读两次相同的代码,以确保两边都看起来没问题——这听起来非常容易出现人为错误。
根据我们的经验,这确实拖慢了Rspotify的开发进度,尤其是对不习惯这种流程的新贡献者而言。作为Rspotify的新任维护者,我开始探索其他可能的解决方案。
调用 block_on
#
第二种方法 是在异步侧实现所有内容。然后,你只需为阻塞接口创建封装函数,这些函数内部调用block_on
。block_on
会运行未来任务直至完成,本质上使其同步化。你仍然需要复制方法的定义,但实现只需编写一次:
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
:
let mut runtime = tokio::runtime::Builder::new()
.basic_scheduler()
.enable_all()
.build()
.unwrap();
这引发了一个问题:我们应该在每次调用端点时初始化运行时,还是有办法共享它?我们可以将其作为全局变量(ewwww),或者更好的方法是将运行时保存到 Spotify
结构体中。但由于它需要对运行时进行可变引用,你必须用 Arc<Mutex<T>>
进行包装,这将完全破坏客户端的并发性。正确的做法是使用Tokio的Handle
,它看起来像这样:
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
})
}
虽然句柄确实使我们的阻塞客户端更快[1],但还有一种性能更强的做法。如果你感兴趣的话,这就是reqwest
本身所做的。简而言之,它会创建一个线程,调用 block_on
方法,在包含任务的通道上等待[2][3]。
不幸的是,这种解决方案仍然存在相当大的开销。你需要引入像 futures
或 tokio
这样的大型依赖项,并将它们包含在你的二进制文件中。所有这些,只是为了……最终写出阻塞代码。因此,这不仅在运行时有成本,在编译时也有成本。这让我觉得不对劲。
而且,即使只是定义,你仍然会有大量重复的代码,这些代码加起来可能会很多。reqwest
是一个庞大的项目,可能有能力为他们的 blocking
模块承担这些成本。但对于像 rspotify
这样不那么受欢迎的 crate,这很难实现。
复制 crate #
另一个可能的解决方案是,正如功能文档所建议的那样,创建单独的 crates。我们将拥有 rspotify-sync
和 rspotify-async
,用户只需选择他们想要的 crates 作为依赖项,如果需要的话,甚至可以同时选择两个。问题在于——再次——我们如何准确生成这两个版本的 crate?即使使用 Cargo 技巧,例如为每个 crate 创建两个 Cargo.toml
文件(这非常不方便),我也无法在不复制粘贴整个 crate 的情况下做到这一点。
基于这个想法,我们甚至无法使用过程宏,因为你无法在宏中创建一个新的 crate。我们可以定义一种文件格式来编写 Rust 代码模板,以替换 async
/.await
等代码部分。但这听起来完全超出了范围。
最终“奏效”的:maybe_async
crate #
第三次尝试 基于一个名为 maybe_async
的 crate。我记得当我发现它时,愚蠢地认为这是完美的解决方案。
无论如何,这个想法是,使用这个 crate,你可以通过过程宏自动删除代码中的 async
和 .await
,基本上实现了复制粘贴方法的自动化。例如:
#[maybe_async::maybe_async]
async fn endpoint() { /* stuff */ }
生成以下代码:
#[cfg(not(feature = "is_sync"))]
async fn endpoint() { /* stuff */ }
#[cfg(feature = "is_sync")]
fn endpoint() { /* stuff with `.await` removed */ }
在编译 crate 时,可以通过切换 maybe_async/is_sync
功能来配置是否使用异步或阻塞代码。该宏适用于函数、特质和 impl
块。如果转换并不像删除 async
和 .await
那么简单,你可以使用 async_impl
和 sync_impl
过程宏来指定自定义实现。它做得非常出色,我们已经将其用于 Rspotify 有一段时间了。
事实上,它的工作效果如此之好,以至于我让 Rspotify 成为 http 客户端无关 的,这比 异步/同步无关 更加灵活。这使我们能够支持多个 HTTP 客户端,如 reqwest
和 ureq
,而无论客户端是异步还是同步。
实现HTTP客户端无关性并不难,只要你使用了maybe_async
。你只需要为 HTTP 客户端,然后为每个你想支持的客户端实现它。一段代码胜过千言万语(你可以在这里找到 Rspotify 的 reqwest
客户端的完整源代码,以及ureq
的代码在此):
#[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 中的特性必须是 可叠加的:“启用一个特性不应禁用其他功能,且通常可以安全地启用任何特性的组合”。Cargo 可能会合并依赖树中重复出现的 crate 的功能,以避免多次编译相同的 crate。参考资料对此解释得非常清楚,如果您想了解更详细的信息,请参阅。
此优化意味着相互排斥的功能可能破坏依赖关系树。在我们的案例中,maybe_async/is_sync
是由 client-ureq
启用的一个 切换 功能。因此,如果你尝试在启用 client-reqwest
的情况下编译它,编译将失败,因为 maybe_async
将被配置为生成同步函数签名。不可能有一个直接或间接依赖于同步和异步 Rspotify 的 crate,根据 Cargo 参考,maybe_async
的整个概念目前是错误的。
功能解析器 v2 #
一个常见的误解是,这个问题可以通过“功能解析器 v2”来解决,参考文档对此也有详细说明。自 2021 版起,该功能默认启用,但在较早版本中,您可以在 Cargo.toml
中进行指定。此新版本在某些特殊情况下避免了功能的统一,但不包括我们的情况:
- 针对当前未构建的目标,平台特定依赖项上启用的功能将被忽略。
- 构建依赖项和过程宏不会与普通依赖项共享功能。
- 开发依赖项仅在构建需要它们的目标(如测试或示例)时才会激活功能。
为了保险起见,我尝试自行复现此问题,结果与预期一致。此仓库是一个功能冲突的示例,任何功能解析器都会导致其失效。
其他失败案例 #
还有几个 crates 也存在这个问题:
arangors
和aragog
:ArangoDB 的包装器。两者均使用maybe_async
在异步和同步模式之间切换(事实上,arangors
的作者与aragog
的作者是同一人)[4][5]。inkwell
:LLVM 的包装器。它支持多个版本的 LLVM,这些版本彼此之间并不兼容[6]。k8s-openapi
:Kubernetes 的包装器,与inkwell
存在相同的问题[7]。
修复 maybe_async
#
随着该 crate 开始流行,maybe_async
中出现了这个问题,该问题解释了情况并展示了修复方法:
同一程序中的异步和同步 fMeow/maybe-async-rs#6
maybe_async
现在将拥有两个功能标志:is_sync
和 is_async
。该库将以相同的方式生成函数,但会在标识符后附加 _sync
或 _async
后缀,以避免冲突。例如:
#[maybe_async::maybe_async]
async fn endpoint() { /* stuff */ }
现在将生成以下代码:
#[cfg(feature = "is_async")]
async fn endpoint_async() { /* stuff */ }
#[cfg(feature = "is_sync")]
fn endpoint_sync() { /* stuff with `.await` removed */ }
然而,这些后缀会引入冗余信息,因此我思考是否能以更简洁的方式实现。我分支了 maybe_async
并尝试实现,相关细节可参阅 这组评论。简而言之,实现过于复杂,最终我放弃了。
要解决这个边界案例的唯一方法是降低Rspotify对所有用户的易用性。但我认为,同时依赖异步和同步功能的用户并不常见;到目前为止,我们还没有收到任何投诉。与reqwest
不同,rspotify
是一个“高级”库,因此很难想象它会在依赖树中多次出现。
或许我们可以向 Cargo 开发者寻求帮助?
官方支持 #
Rspotify 绝非第一个遇到此问题的项目,因此阅读之前的讨论可能会有趣:
- 这篇现已关闭的Rust编译器RFC建议添加
oneof
配置谓词(类似于#[cfg(any(...))]
等),以支持排他性功能。这仅在无法避免冲突的情况下简化了功能冲突的处理,但功能仍应严格遵循累加原则。 - 之前的 RFC 在讨论 Cargo 本身是否允许独占功能的背景下引发了一些讨论,尽管其中包含一些有趣的信息,但讨论并未深入展开。
- Cargo 中的此问题 解释了与 Windows API 相关的类似情况。讨论中包含更多示例和解决方案思路,但目前尚未在 Cargo 中实现。
- Cargo中的另一个问题 请求一种轻松测试和构建不同标志组合的方法。如果功能严格是累加的,那么
cargo test --all-features
就能覆盖所有情况。但如果无法覆盖,用户必须运行包含多个功能标志组合的命令,这相当繁琐。目前已可通过非官方方式借助cargo-hack
实现。 - 基于关键词泛型倡议(Keyword Generics Initiative)的完全不同方法。这似乎是解决此问题的最新尝试,但目前处于“探索”阶段,且截至本文撰写时尚无相关 RFC。
根据这篇旧评论,这不是Rust团队已经放弃的方案;它仍在讨论中。
尽管非官方,Rust 中另一个值得进一步探索的有趣方法是 “Sans I/O”。这是一个 Python 协议,它抽象了像 HTTP 这样的网络协议的使用,从而最大化可重用性。Rust中现有的一个示例是tame-oidc
。
结论 #
我们目前需要在以下选项中做出选择:
- 忽略 Cargo 参考文档。我们可以假设没有人会同时使用 Rspotify 的同步和异步功能。
- 修复
maybe_async
并为库中的每个端点添加_async
和_sync
后缀。 - 放弃对异步和同步代码的支持。这已经变得一团糟,我们没有足够的人力来处理,而且这会影响 Rspotify 的其他部分。问题是,一些依赖 rspotify 的 crates,如
ncspot
或spotifyd
是阻塞的,而其他一些,如spotify-tui
使用异步方式,因此我不确定它们会如何处理。我明白这是一个我自己强加给自己的问题。我们可以直接说“不行。我们只支持异步”或“不行。我们只支持同步”。虽然有些用户希望能够同时使用这两种模式,但有时你必须拒绝。如果这种功能变得非常复杂,以至于整个代码库都乱成一团,而你又没有足够的工程能力来维护它,那么这是你唯一的选择。如果有人非常在意,他们可以分叉这个 crate,并将其转换为同步模式以供自己使用。毕竟,大多数 API 包装器之类的东西只支持异步或阻塞代码。例如,serenity
(Discord API)、sqlx
(SQL 工具包)和teloxide
(Telegram API)都是仅支持异步的,而且非常受欢迎。
尽管有时令人沮丧,但我并不后悔花大量时间在异步和同步实现之间反复尝试。我最初参与 Rspotify 项目就是为了学习。我没有截止日期,也没有压力,只是想在业余时间尝试改进一个 Rust 库。我确实学到了很多;希望你们在阅读本文后也能有所收获。
或许今天的启示是,我们应该记住Rust毕竟是一种底层语言,有些事情在不增加大量复杂性的情况下是无法实现的。无论如何,我期待Rust团队未来如何解决这个问题。
那么你认为呢?如果你是Rspotify的维护者,你会怎么做?如果你喜欢,可以在下面留言。
本文文字及图片出自 The bane of my existence: Supporting both async and sync code in Rust
另一个选项是将您的 API 以无 I/O 形式实现。既然提到了 k8s-openapi(尽管是出于不同原因),我指出其 API 会为您提供一个请求值,您可以使用任何同步或异步 HTTP 客户端发送该请求。它还提供了一个对应的函数来解析响应,你需要使用客户端返回的响应字节调用该函数。
https://github.com/Arnavion/k8s-openapi/blob/v0.19.0/README….
(过去时态,因为我在该版本发布后因其他原因移除了k8s-openapi中的所有API功能。)
所有语言中异步/同步区分的主要问题在于,所有语言都有一个被优化的执行类别/单子,而异步支持(或其他执行方案)都是“附加”的。Haskell在其类型系统中明确了这一点,尽管单子函数比异步/等待(似乎是其他语言都想实现的单子)更具通用性,但它们也不是“完整的”……预置库中存在一些明显具有单子等价物的函数,但这些函数并未包含在预置库中。然而,即使它们存在,你也无法将它们与“纯”单子(相当于 Identity 新类型)互换,后者是 Hask 类别的 Kleisli 单子。
有人尝试实现将所有 Haskell 代码编译到任意类别的通用编译(参见 Conal Elliot 的《使用类别进行编译》),但遗憾的是这种方法并未流行起来。
实际上,将任意类别作为新型编程语言的基本设计原则来支持,将是一个有趣的设计空间。显然,在某个层面上,必须有一个“特殊”的类别能够编译为机器代码,但如果能够隐藏/抽象化这种复杂性,那确实会很有趣。
在编写一些与IO相关的应用程序时,异步Rust非常出色。对于希望同时支持异步和同步接口的库而言,情况则不那么理想,因为如果仅需同步接口,就不能将异步运行时作为依赖项。相互排斥的功能是禁忌。我非常喜欢 Haskell 的一个特点是,只需将函数与 ‘async’ 函数组合,即可让任何函数在绿色线程中运行。这没有什么特别之处。与 Go 相比,这种方式效果更好,因为 Haskell 是不可变的。
我不明白你最后一句与其他内容有何关联?不可变性与异步/同步转换有何关系?
不可变性对多线程/异步程序非常有益,因为每个线程都可以确信其他线程不会偷偷修改它们当前正在操作的对象。
Haskell 如何处理对本质上可变的共享资源的访问,例如文件系统或外部世界?
(一个诚恳的问题,我开始觉得我想更多地了解这门语言)
据我所知,它们通常通过IO单子进行操作,该单子用于排序读写事件,并标记代码中与程序外部的全球可变状态交互的部分。
因此,可变(或应称“易失性”?)环境确实存在,但你明确知道何时何地与之交互。
Haskell 完全支持 IO 和可变性。它甚至在其标准库中提供了软件事务内存。
Go 可以通过竞态检测器等机制防止此类问题
竞态检测器需要实际遇到竞态条件才能检测到,它并非完整的静态分析。
有时会。
检测器用于检测,而非预防。所有检测器都会出现漏检。
不可变性可能是史上最愚蠢的“万能解决方案”,却被吹捧为解决任何问题的良方。
恭喜,没有人会偷偷更新你的对象,但同样,也没有人知道你的更新。
考虑到它带来的巨大额外工作量,这并不是一个值得的权衡。
不可变性在开发过程中解放了思维,让我惊讶的是它没有成为主流
> 恭喜,没有人会偷偷更新你的对象
我见过海森堡 bug,其中一些随机代码在共享内存缓存中调用对象的 setter 方法。该 setter 调用是用于本地逻辑——因此不可变更新本可以解决问题。它还产生了实际影响:我们为美国数据中心订购了一个带欧洲插头的机架(幸运的是,有人在流程中发现了这个问题)。
此外,你真的经常使用可变性吗?比如……用来做什么?我认为,用表达式来表达逻辑比用一个复杂的状态变换循环更容易。
>有多少海森堡 bug
我怀疑,根据实际测量数据,不可变性和可变性之间难以处理的 bug 数量相当一致。实际测量数据并不支持“更容易理解”或“减少错误”的论点。
>你有多常使用可变性
只要有东西需要改变,而我并不需要不可变性提供的特定功能(几乎 99.99999999% 的状态变化都是这样)。
我只是不明白你具体需要可变性做什么?我理解在进程间通信时需要可变性(STM已经覆盖了这一点)。但对于执行纯逻辑的“正常”代码,使用可变性的好处是什么?
不可变性在纯逻辑方面有很大优势,例如允许容器像数字一样被视为值。如今,各种高效的不可变数据结构已司空见惯。
完全不了解的观点。一些最令人印象深刻的更新通知系统是基于不可变运行时构建的(例如:Phoenix Live View + Phoenix PubSub)。尝试用其他语言实现这一点,你会发现自己陷入困境。
CQRS 的整个理念是构建独立(隔离)的更新路径。不可变传递与CQRS配合得非常默契。而双向数据绑定(例如开箱即用的AngularJS)则是完全的混乱。
我认为你们都在指同一点:无法更新不可变对象,因此必须设置某种机制来保持更改同步。
没错,更新机制并非生而平等。双向数据绑定糟糕在于它回避了分布式一致性的挑战。
即使是不可变的,你仍然可以删除或替换数据。
不可变性“可能”(而且这需要打上巨大的问号,因为我从未在具体项目中使用过它来得出不同结论)在某些用例中表现良好,但这与将应用程序中每一个对象都设为不可变是两回事。
我同意不可变性是一种工具。我的问题在于当你将其视为规则时。
如果你不担心会引发数据竞争或修改任何内容,就可以放心地在另一个线程中运行代码
这绝非免费。预测Haskell程序的内存使用量向来困难(而且所有内存复制操作也不是免费的)。
OCaml、Elixir、Clojure 等语言也是如此。非惰性语言也可以拥有丰富的不可变数据结构,并且内存使用情况比 Haskell 更可预测。另一方面,Go 没有鼓励不可变性的文化或特性。
预测Haskell程序的内存使用其实并不困难。至少在我成为中级Haskell程序员后,我就不再这么认为。构建Haskell运行时系统(RTS)的心理模型与构建JVM的心理模型并无二致。
具备这种能力通常是成为中级专业程序员的基本要求,我认为。在大学时,我不得不绘制图表来解释每行代码执行后 C 语言栈和堆的状态。这是同样的事情。而我当时才 19 岁,哈哈。这并不难。
也许你指的是内存泄漏?在我十年的Haskell职业生涯中,只遇到过两次,而且都没有影响到生产环境。
我实际上看到更多Go和Java的内存泄漏/内存不足错误影响到生产环境,尽管我使用这些语言的总时间比Haskell少!但没有人把这些问题归咎于语言本身。
这并非免费,但它是显式的。代码明确定义内存如何被修改是很好的。Java中也有复制,你只需了解运行时机制就能知道每行代码的作用。
Haskell中的复制完全不显式,对吧?
> 这比Go语言好得多,因为Haskell是不可变的。
不可变性与异步无关。异步是用于IO线程的。如果你想要纯粹的并行性,你使用`par`。但Haskell的IO线程(forkIO等)在使用GHC时也是绿色的。
当数据是不可变时,异步确实更优雅。在现代 CPU 上,异步绿色线程可以轻松由不同操作系统线程在不同 CPU 核心上并行运行,这使得数据竞争成为许多语言的实际问题。异步并不保证不会并行执行,尽管你不应依赖它来实现显式并行性。
Haskell 的异步操作在 IO 中运行,而 IO 允许可变性。异步操作本身是可变的。
Haskell 中的所有线程都是绿色的。Async 只是提供了一种无需使用 MVars 或 Chans 即可从线程中获取返回值的另一种方式。
我认为即使对于大多数 I/O 密集型应用程序,这也不值得。首先想到的几个 I/O 密集型示例(例如进行批量磁盘或网络 I/O 的应用程序,如顺序文件访问或批量数据传输)似乎在没有异步的情况下也能正常工作,因为瓶颈在于磁盘、网络卡或连接。
我猜对于高并发且 CPU 密集型的应用程序,这可能是一个优势,但通过优化用户空间代码,这些应用程序也可能变得 I/O 密集型。然而,操作系统线程非常高效,你可以拥有数以亿计的线程,因此异步的优势相当有限,因此这个细分市场似乎相当小。
忘记“你的函数是什么颜色?”
“你的模块是什么颜色?”
> 修复可能的_async,并在库中的每个端点添加_async和_sync后缀。
你不能只给一个变体添加后缀,比如“_sync”,据我所知你只需要不同的名称?
我使用了许多基于承诺/未来的API,它们都有同步版本和异步版本,而异步方法被命名为get_p或get_f等,这在输入’await’前后时感觉噪音很小。
这就是为什么我认为 async 应该更短,而且对于这个库来说,async 似乎是主要接口。
我喜欢 Rust 中同步 -> 异步的清晰区分,但缺乏对异步内容的第一方语言支持确实令人感到恼火。不过情况正在好转——很高兴看到最近版本中出现了 async traits。
block_on
似乎在代码重复和开销之间找到了合适的平衡,我认为。虽然编译时库的组合有些奇怪,但编译时开销并不算太糟糕。
我也这么认为。编写异步代码,然后使用 block_on。尽管有些烦人,但对我们来说效果很好。我们制作了宏来帮助清理代码。
担心二进制文件大小增加似乎是一种自我施加的限制。将两个版本都包含在同一个 crate 中有多糟糕呢?
不错!这与这里的解决方案类似:https://github.com/python-trio/unasync
我在 JavaScript 中也遇到过 async 和 await 的类似问题。如今我倾向于选择简单方案,将所有内容都设为异步
文章中提到:>问题在于,Rust中的特性必须是可叠加的:
>“启用一个特性不应禁用其他功能,且通常应
>安全地启用任何特性的组合”。
难道真正的问题不是Rust的async会将所有函数标记为红色吗?
我偶尔会在 `std` Rust 库中遇到这个问题,通常涉及网络通信。文章中未直接提及,但我倾向于将 `block_on` 包裹在线程中,并使用 `std::sync::mpsc` 或根据需要管理状态。
在嵌入式系统中,我为所有内容创建自己的库;开源社区已经完全转向异步或类型状态/通用 API。我认为这种情况短期内不会改变,但也许未来会有所不同。我觉得自己是唯一一个不喜欢这两种方式的人。
没错,我认为类型状态模式对开发过程中通常会发现的 bug 过于保护,而异步 Rust 整体上就是个扫兴的东西。
你有开源的嵌入式项目吗?我对你选择的方向很感兴趣。
这里有一个:https://github.com/David-OConnor/stm32-hal
设备 IC 代码通常直接作为文件包含在我的固件中。
我来得有点晚,但我就是这篇文章的作者。如果有任何建议,请告诉我 🙂
这里的评论线程讨论了多种语言中的异步编程:Rust、Go、JavaScript、Python。有没有人能详细描述不同语言中的异步实现有何微妙差异?为什么有些语言中异步编程很痛苦,而另一些则不然?
是否已有文章对此有详细描述?
在所有上下文中都令人头疼,因为涉及函数着色。在 Go 和 JS 中稍好一些,因为它们内置了单一的异步运行时。在 Rust 中,尚未标准化一系列能消除痛点的功能:
std 中的异步特性,而不是每个运行时都有自己的特性
可插拔接口,这样代码中的异步就无需指定它是在哪个运行时上构建的
可能是一个效果系统,使不同的效果更容易组合(例如错误效果 + 异步效果),而无需复制代码来完成组合
关键词泛型,作为目前正在探索的东西,而不是支持效果组合的效果系统
这些修复措施将使异步 Rust 变得不那么令人烦恼,但这是一项缓慢且困难的工作。
Go 没有函数着色功能。Greenlet、Lua 和 libco 通过添加一个栈切换原语来解决这个问题,而无需函数着色。Zig 通过让编译器根据函数是否最终能够挂起到来单一化函数,从而在不强制所有函数消费者进行函数着色的情况下解决这个问题。
更准确的描述是,Go 只有一种函数颜色,即绿色。这一区别很重要,因为例如 C 也没有函数着色问题,只是因为它不关心轻量级线程,即其函数颜色始终为红色。只有 Zig 的方法,以及如果被采纳的 Rust 的关键字泛型,可以被视为没有函数颜色。
上次我检查时,Zig 仍然对函数指针有微妙的函数颜色区分。例如,参见 https://github.com/ziglang/zig/issues/8907
我不明白“单一颜色”和“无颜色”的区别,你能解释一下吗?是什么让 Zig 的方法没有颜色?
虽然“函数颜色”的原始用途纯粹是语法上的 [1],但它们可以轻松映射到协作式与抢占式多任务处理。这种映射很重要,因为它会改变程序员的思维模式。
例如,
await
调用的常见形式暗示了协作式多任务处理,人们有充分理由相信在两次await
调用之间,其他任务无法影响你的代码。这在一般情况下并不成立(例如Rust),但确实适用于某些语言如JS。现在考虑两种 JS 变体,两者均移除了await
,但一种保留了协作式多任务处理,另一种允许抢占式任务。它们必然需要不同的思维模型,尽管在语法上已无法区分。我认为这种区别足够重要,以至于它们仍需被视为具有函数颜色,而这种颜色仅在单一语言内部保持一致。相比之下,Zig 的方法常被称为“颜色盲”,因为尽管它提供了
async
和await
,但这些关键字仅将返回类型更改为承诺(Zig 术语:异步帧),并不保证会做任何不同的事情。相反,用户被赋予了开关,因此大多数库预计在不考虑该开关的情况下都能同样良好地工作。你也可以这样理解:所有 Zig 模块都通过隐式 `io_mode` 参数进行隐式参数化,该参数影响 `async` 和 `await` 的含义,并传播到嵌套依赖项。这里确实存在颜色,但它不再是函数颜色,因为函数不再能为自己上色。因此,我认为称其为“无函数颜色”是合理的。[1] https://journal.stuffwithstuff.com/2015/02/01/what-color-is-…
有趣的思路,感谢详细说明!我需要研究一下Zig的异步模式,它看起来不错,但我想知道这种方法的缺点是什么(特别是,为什么会将io_mode设置为“sync”)
这篇帖子有点混乱。io_mode 是标准库的一个特性,它会将标准库中的一些阻塞操作改为使用标准库的事件循环。对于大多数库来说,并不存在这样的特性。
对于典型库,会提供函数和结构体。这些函数在调用用户提供的函数时,对这些函数的异步性是泛型的。这就是语言级异步功能的工作方式,适用于未明确标记为异步且未指定使用特定非异步调用约定来调用用户回调的库代码。
对于 Go,我认为只有一个 同步 的运行时内置。人们说 Go 是异步的,因为 goroutines 的实现内部是异步的,但每个操作系统上的线程实现内部也是异步的。就同步/异步而言,goroutines 和线程之间的唯一实质性区别† 是 Go 的 goroutines 实现位于用户空间,而操作系统线程的实现位于内核空间。两者在底层都是同样的异步。
† 当然,goroutines 和典型操作系统线程之间还有其他差异,例如栈大小,但这里我只讨论 I/O 差异。
Go 语言中没有函数着色。异步函数没有特殊标记。
我认为说一种语言没有函数着色会忽略很多细节。
在 Go 语言中没有函数着色,因为只有异步函数。这就是为什么它们没有特殊颜色,它们就是唯一的颜色。在 Go 语言中无法使用同步函数,这会带来问题,例如在需要使用 FFI 时,因为 C ABI 恰恰相反,它没有函数着色,因为它只允许使用同步函数。
Zig 和 Rust 的异步泛型倡议有所不同,它们希望允许函数同时具备同步和异步特性。最终仍然存在颜色区分,但你在编写函数时无需选择其中一种。然而,我认为要实现这一结果需要解决许多非 trivial 的问题。
最终,Go 的方法足够有效,通常比其他方法更好,直到你需要进行 FFI 或需要生成一个没有运行时的二进制文件(例如,如果你需要编程一个微控制器)
> 我认为说一种语言没有函数着色忽略了很多细节。
这些细节本就该被忽略。当你在各种语言中使用异步/等待构造时,你并不关心它们在底层被解析为回调链的事实。你只是在语言中使用异步/等待,或者不使用。这就是“你的函数有颜色”这个概念的含义。如果你想改变这个含义,那没问题,但你就是在谈论其他东西。
Go 语言在 C 语言外置接口(FFI)方面运作良好,其所有问题均源于动态栈大小和垃圾回收器的创新设计。我宁愿编写多线程 Go FFI,也不愿再次处理 JNI。Go 语言中并不存在与 JavaScript 的
await
、Java 的 Futures 或 Rust 的 async 相当的语言关键字级别的异步概念。这有点荒谬。据我所知,Go 中的所有函数都是同步的。它们在运行时通过用户空间调度器实现异步的事实无关紧要(否则你可以说根本不存在同步函数)。
如果我们将 Go 的编程模型称为异步,那么这个词已经完全失去了原有的含义。
那么,对你来说,同步函数和异步函数有什么区别?
异步函数采用 CPS 形式,并通过返回延续返回结果。通常当从非 CPS 函数调用时,它也会分叉执行线程。
如今异步函数通常通过部分评估进行懒惰求值,且返回延续不一定在调用点提供。
同步函数通过正常返回路径提供结果。
我曾就这个主题做过两次演讲:
* 术语概述,以及各种语言如何在设计空间的不同部分中定位 https://www.infoq.com/presentations/rust-2019/
* 对Rust实现机制的深入解析 https://www.infoq.com/presentations/rust-async-await/
我认为最大的根本区别在于Rust没有语言运行时,而你列出的其他三种语言都有。由于语言运行时可以在任何时候中断你的代码,因此实现异步操作变得更加容易——但代价是现在更容易引发数据竞争。
我不会假装自己是专家,但很乐意有人能进一步展开说明。
在 Rust 的早期阶段,曾有过是否支持“绿色线程”的讨论,而这需要运行时支持。该功能曾被实现并短暂包含在语言中,但它在开发库或嵌入式代码时会引发问题。例如 Go 语言当时选择了这条路线,它既优雅(goroutines 易于编写且支持完善),又代价高昂(实际上需要垃圾回收等机制)。我不记得具体细节,但 Rust 有一份关于移除绿色线程的 RFC:
https://github.com/rust-lang/rfcs/blob/0806be4f282144cfcd55b…
JS 之所以更容易,是因为它一直是单线程的,而且从一开始就没有任何同步 I/O。
这意味着,在异步出现之前,任何进行 I/O 的库都必须基于回调。然后出现了 Promise,本质上是高级回调,接着出现了异步,可以视为 Promise 的语法糖。
因此,你永远不会看到依赖异步结果的同步代码。在 JavaScript 中,同步代码等待某物的概念根本不存在。相反,你通过 Promise.then() 回调唤醒同步函数,而同一机制也用于桥接异步函数。
在 JavaScript 中,计算密集型同步代码非常罕见,因此几乎没有必要将其多线程化。
> 同步代码等待某物的概念在 JavaScript 中根本不存在。
你忘了 prompt() 和它的朋友们吗?
要避免复制粘贴和搜索替换,这可是一条漫长的路。
那这样还有什么乐趣可言呢?
nimlang 通过多同步功能解决了这个问题
你能简要解释一下它是如何工作的吗?它和 Zig 类似吗?
这是一个类型安全的宏,允许你透明地使用异步或同步系统调用:https://nim-lang.org/blog/2016/09/30/version-0150-released.h…
我希望有一天,我们能拥有一种类似Rust的语言,但不需要异步。
如果它还具备用户定义静态分析(如借用检查)的能力,那就更好了。
>我希望有一天,我们能拥有一种类似Rust的语言,但不需要异步。
这样的语言已经存在,它叫Rust。你不需要使用异步。
直到你想要使用一个需要异步的库。现在你必须使用。
标准库的哪一部分迫使我使用异步?还是说抱怨的是你无法强迫其他随机开发者按照你喜欢的方式编程?
你能试着换个角度思考吗?
我不是他们,但我认为不存在任何通用编程语言会阻止开发者实现异步运行时并在库中使用它们。
所以,如果你整个推理是“其他人可能会使用异步,然后我就无法使用他们的代码”,那么你将永远在等待那种既能满足你工作需求又没有生态系统部分使用异步代码的魔法编程语言。
> 直到你想要使用一个需要异步的库。现在你需要了。
现在你需要了……就有动力去编写自己的非异步版本。
这很公平。Rust 社区确实喜欢重写。
异步编程很美妙。它是单线程代码中同时处理多项任务最简单自然的方式。
异步编程让同步编程难以实现甚至不可能实现的事情成为可能。
这对 Python 来说尤其是一个真正的游戏规则改变者。无法评论 Rust,希望它的实现顺畅。
真的吗?就我个人而言,当谈到“同时处理多项任务”时,我更关心Rust的范围线程API:创建N个线程,让它们执行一些计算,然后在范围结束时将它们合并。
这难道不比创建各种状态机并传递各种奇怪的高级抽象Future对象更自然吗?
对于你描述的这种数据并行计算的特殊情况,其实不需要异步。
然而,许多现实世界的程序本质上是复杂的状态机,其中每个状态都需要等待一个或多个嵌套的状态机完成才能继续或结束——异步往往是表达这种逻辑最合理的方式。
我写了一篇详细介绍异步在实际生产环境中非 trivial 应用的博文,可能对你感兴趣,涉及我编写并维护的 cargo-nextest 测试运行器:https://sunshowers.io/posts/nextest-and-tokio/。
Nextest 并非“Web 规模”(c10k),因为同时运行的测试数量通常受限于处理器超线程的数量。因此,异步任务比操作系统线程更轻量级这一事实在此情况下影响不大。但即便如此,能够通过异步方式表达状态机仍让开发变得简单得多。
在将 Nextest 切换为使用异步任务一年多后,我毫无悔意。自那以后,状态机复杂度大约增加了两倍——例如,Nextest 现在会谨慎处理 SIGTSTP 和 SIGCONT 信号——而异步任务对此应对得相当出色。
(目前 Rust 的异步任务存在一些较为严重的问题,例如取消机制非常混乱。但这些通常不是在HN等地方讨论的重点。)
这不是你回复的对象,但这是很好的背景信息,我想感谢你包含它。作为另一个重度异步用户(为一个处理大量请求并进行大量数据库读写操作的网络服务),我也是Rust异步在规模化应用中的忠实拥趸。我们目前正在探索如何在1.75版本中移除async_trait,尽管在许多情况下它并不完全兼容,但这仍然令人兴奋。
无论如何,我一直想尝试在工作中使用nextest来管理我们庞大的单仓库工作区。Cargo测试运行器一直能满足我们的需求,但加快CI中的测试执行速度对我们来说可能是一个巨大的优势。
我很想听听进展如何!
感谢分享,这篇博客文章非常有见地!
没问题!在我亲手构建了一个在许多方面比大多数网络服务更复杂的系统之前,我对异步编程的价值也并不完全信服。
线程比异步编程消耗更多资源。
异步代码能够充分利用其运行的线程的潜力。
> 线程比异步操作要复杂得多
这听起来像是常见操作系统中线程和调度实现的问题,我看不出来在每个足够大的编程语言中复制所有这些功能是如何被认真对待的。
但你也没有回应我之前的说法。你声称异步是“优雅且自然的”。我不同意。你……退而求其次,提出了一个无可争议但与我所说无关的性能主张。
在阅读代码时,能够判断某行代码是否阻塞是非常重要的。除此之外,
Thread.new(_ => doIO()).join()
和await doIO()
基本上是一样的。异步编程没有并发问题。
没有竞争条件,没有线程间协调,没有关于多线程时发生的所有奇怪事情的担忧。
使用异步编程的主要原因之一是将工作分配到所有 CPU 核心。因此,在这种情况下,你仍然会遇到异步编程的并发问题。
只有当你将异步编程限制为仅在一个核心上的一个线程(为什么你会这样做?)时,才能避免这些问题。
>> 使用异步的主要原因之一是将工作分配到你所有的 CPU 核心
嗯……不,这不是正确的。你的陈述是完全错误的。异步是单线程的。
你真的不理解异步。
异步是单线程的。如果你想充分利用所有核心,可以使用 systemd 或类似工具运行单线程进程的多实例,或者让应用程序启动多个异步线程。
在 Python 和可能的 Rust 中,你可以运行一个执行器,它本质上是在线程中运行一个同步进程,以使某些同步工作与异步兼容,但这并非理想方案,也绝非异步的初衷。
`async` 默认既不是多线程也不是单线程。这完全取决于底层运行时。
例如,Rust 的 Tokio 运行时默认是多线程的,它通过使用多个线程同时处理多个任务来实现进度。
我回复你是为了帮助你。我从 2007 年就开始使用 async。
我经常为客户使用 async,并且我 100% 确定 Rust 中常见的 async 执行器是多线程的。我刚刚再次使用 gdb 分析了一个 async 程序,果然,Tokio async 执行器目前有 16 个线程(这只是在一部拥有 16 个核心的笔记本电脑上)。
试试看。
另外,想想看。使用异步来加快 I/O 速度,然后将异步执行器绑定到服务器上 200 个核心中的一个核心,这并不是一个明智的策略。
>执行器,本质上是在线程中运行一个同步过程,以使一些同步工作与异步兼容
那不是执行器的作用。
此外,上述情况属于并行处理,比并发处理更糟糕。即使使用单线程异步执行器,仍可能遇到并发问题。
>若想充分利用所有核心,可通过 systemd 等工具运行单线程进程的多实例,或让应用程序启动多个异步线程。
这不是1995年。你的想法会让调度变得比现在更困难,并且会增加巨大的内存开销。如果你要这么做,大多数情况下,一开始就使用同步进程——不需要异步。
这是一个常见的误解,但并不完全正确。在异步系统中,你可能会遇到竞争条件和奇怪的问题。
通常发生的情况是,你在异步函数中编写了一些不暂停的直线代码,然后后来有人在其中添加了一个 await。await 返回事件循环,此时一个你未预期的事件到达。现在控制流跳转到完全不同的位置,可能以你未预料的方式改变状态。当原始函数恢复时,它会看到一些并发变化。
你希望同时保持多少个与Spotify的连接?
这与Spotify无关,但管理HTTP/2连接时,异步代码比同步代码要容易得多,而HTTP/3也将大致相同。当然,你可以创建一个线程来处理这些连接并使用通道,但这样做并不特别愉快。
在使用 Spotify API 时,我曾希望进行并发 API 调用。一次 API 调用会返回播放列表中的歌曲 ID 列表,然后你需要获取每个 ID 的歌曲信息。HTTP2 多路复用比创建 100 个不同的 HTTP1 连接要好得多
异步/等待是一种 _并发_ 机制,而并发当然具有天然的实用性。然而,异步/等待绝非并发性的最佳实现方式,除非在Rust选择的狭窄设计空间内。如果语言具备运行时环境,绿线程/纤维/协程,并搭配选择机制,才是理想方案。它以部分性能为代价换取了更优的易用性。Go、Java、Racket均采用此方案。遗憾的是Python选择了异步/等待。
我基本上同意,但你仍然可以像往常一样通过 pip 安装 gevent 并使用 greenlets——只是它们不再是语言的“原语”。
我个人一直不太喜欢函数调用上的语法糖。如果某个东西是承诺(promise)或其他什么,直接返回承诺给我,我可以自行决定如何处理它。
> 对于 Python 来说,这确实是一个重大变革
Python 运行速度太慢,使用 async 并不会带来任何好处。我有很多有趣的案例,因为产品采用了 async 设计,导致所有连接负载直接进入数据库,而不是通过 nginx/后端进行处理。
你很少需要长时间连接,如果你选择 Python 的异步因为它很快,那是个错误的选择,因为它并不快。
顺便说一句,asyncio 是一个臃肿的实现,任务管理周围有巨大的开销。
>> Python 运行速度太慢,使用异步编程也无济于事。
这并不一定与速度有关,尽管上述陈述完全错误。
Python 的异步编程允许你构建不同类型的应用程序。例如,你可以将一个任务附加到另一个进程的标准输出,并读取和处理它。
> 例如,你可以将一个任务附加到另一个进程的标准输出,并读取和处理它。
你最多可以处理多少个进程?关键在于:如果你需要处理成千上万个进程,那么 asyncio 的开销太大,你需要手动使用 epoll。如果线程较少,使用起来会容易得多,性能也更可接受。
> 另一个可能的解决方法是,正如功能文档所建议的那样,创建单独的 crates。我们将有 rspotify-sync 和 rspotify-async,用户只需选择他们想要的 crate 作为依赖项即可。
一个小建议,但请注意,如果你为你的库这样做,请将同步版本命名为原名,并在另一个版本的名称中添加“-async”,即“rspotify”和“rspotify-async”,而不是“rspotify-sync”和“rspotify-async”。这与函数定义关键字“fn”和“async fn”的命名方式一致,而非“sync fn”和“async fn”。大多数简单用例不需要异步功能,额外的输入和阅读会让人感到烦躁。
我认为显式地使用 -sync/-async 很好。
抱歉特别指出这条评论,但它在帖子中获得最高票数,似乎很好地说明了“自行车棚效应”。
是的,一个字母的差异很容易被忽视。
如果立即调用 await,async 的优势在哪里?
如果你是I/O瓶颈,它允许通过在等待I/O时让其他任务运行,来实现资源的轻松高效利用。
我给你一个现实世界的例子。我写了一些代码,监听来自数千个Reddit帖子的WebSockets URL——具体来说,是发送新评论时的新消息——这样我就可以看到任何给定子版块的Reddit评论流。
使用 Tungstenite(同步)实现后,它创建了数千个线程用于监听,并为每个 WS 流的栈空间和内存读取分配了巨大的内存块(数 GB)。
使用 Tokio_tungstenite(异步替代方案)实现后,它仅使用了少量 MB 的内存和几乎不占用 CPU 资源即可监听数千个 WS 服务器。
但在何时调用 await?
如果我使用作者的库,我会调用 `.some_endpoint(…)`,它会返回一个 `SpotifyResult<String>`,所以我难以理解为什么 `some_endpoint` 是异步的。如果两个不同线程调用
some_endpoint
,那么await
可以让它们同时使用资源,但如果运行两个线程,不就已经实现了同样的效果吗?我对并发还不太了解。异步的一个常见用例是实现带有 API 的网络服务。假设你的 API 使用 Spotify 的 API。你的服务器可以同时处理多个请求。如果所有请求都能同时调用 Spotify 的 API 而不占用各自的线程,那将非常理想。Tokio 任务的开销远低于操作系统线程。你的 N 个请求可以同时等待,而无需占用 N 个线程。
如果你每次只做一件事,那么异步对你没有帮助,这就是为什么人们在无法利用异步时喜欢使用同步库,也是为什么库可能希望提供同步和异步版本。
异步在您希望在单个线程上同时处理多项任务时非常有用。
使用异步,您可以同时等待两个不同的 SpotifyResults,而无需多线程。当每个任务就绪时,运行时将执行之前等待的函数剩余部分。这意味着实际的 HTTP 请求可以同时进行。
我只是困惑于它被标记为异步(且确实支持异步),但实际调用时却是阻塞的(或至少在 await 完成前不会返回)。
如果我同时等待两个不同的结果,我必须以某种方式并行调用它们,对吧?这种机制是什么,为什么它没有提供异步性?比如,如果该方法是同步的,难道我不能以某种方式异步运行它吗?
> 我只是困惑于它被标记为异步(并且确实支持异步操作),但实际调用时却是阻塞的(或者至少在 await 完成前不会返回)。
从 `async` 块/函数的角度来看,它是阻塞的,但从执行该 `async` 块/函数的线程角度来看,它不是阻塞的。
> 如果我需要等待两个不同的结果,我必须以某种方式并行调用它们,对吧?
不,`async` 的整个点在于实现并发(即多个任务同时运行并交错执行),而无需必然使用并行主义来实现。以 `futures` crate 中的 `join` 宏为例,它允许你 `.await` 两个未来值,同时允许它们两个都继续运行(两个连续的 `.await` 会强制第一个结束,然后第二个才能开始),而无需为它们生成专门的线程。
这是并发概念的核心。如果你搜索“并发与并行”,网上有很多很好的解释。
核心思想是:当你等待异步函数的结果时,你会将控制权交给执行器,执行器随后可以处理其他任务,直到你等待的结果完成。
由执行器管理的任务组是由多个异步函数组成,这些函数在等待外部资源以继续执行时,会在不同时间点将控制权交还给执行器,从而允许其他任务在等待期间继续执行。
这就是为什么人们说它适合IO密集型工作负载,这类工作负载大部分时间都在等待外部系统(如磁盘、网络等)的响应。
你可能只在等待一个结果,但调用你的那个任务可能同时在等待你和另外100个你不知道的任务。
我正在调用 await ws_stream.next() – 等待有新的消息。
代码:https://gist.github.com/sigaloid/d0e2a7eb42fed8c2397fbf84239…
在您给出的示例中,是的,它只是顺序执行的,一切都依赖于前一个操作。但假设您正在开发一个 Spotify 前端——您需要渲染播放列表列表、个人资料信息等——您可以对所有操作调用 await,它们可以同时完成。
如果你没有使用多个任务,就没有优势。如果你正在运行多个任务,它会将控制权交回给执行器。
从库的角度来看,它们将控制权交给了运行时。这意味着用户可以随心所欲地处理未来。
用Erlang重写
有这样一个:
https://lunatic.solutions/
但是我的火箭船表情符号
你能为同步和异步的用户界面 API 创建独立的模块吗?
我最常使用 Rust 的时期是 Rust 的 async/await 特性尚未稳定的几年。我参与了一个现在已成为大型代码库的项目,该项目需要快速处理大量数据。
当时是多线程的,但没有 async/await。当我尝试开始转换代码时,过程非常痛苦。我不知道最终结果如何,但当时我离开时心想,我宁愿不处理当前状态下的异步代码。
我现在主要写Go语言,无需再考虑这个问题。听起来这仍然很痛苦。
同步/异步不匹配是现代开发中的诅咒。在多元生态系统中,无数小时都花在确保兼容性上。
我维护一个简单的抽象中间件缓存库,用于 Python,如何支持同步/异步应用程序代码与同步/异步实际缓存后端的组合,在 Python 中引入异步后成了一个棘手的问题。最终的解决方案是针对所有情况进行自动运行时 AST 转换。唉。我也在验证库中使用相同方法来解析不同 Web 框架的请求。但这是一个特殊案例,无法泛化。
这绝非令人愉快的局面。
我不知道与Rust相比有何不同,但在JavaScript中支持异步和同步就像瑞士手表般顺滑。
在Python中则需要更多思考和结构,因为异步机制并未像JavaScript那样深度内置。
即便如此,异步与同步在两者中都不算噩梦。
不过,这完全取决于你对异步编程的经验多少。
异步编程是一种令人头疼的挑战,会让你彻底混乱,直到它变得自然。同步编程感觉自然且合乎逻辑,而异步则需要完全不同的思维模式。
我能理解为何被迫使用异步的人会讨厌它。这是你必须主动选择去做的事情。
> 在 JavaScript 中支持异步和同步操作,就像瑞士手表一样顺畅。
哈哈!集成异步 I/O 源(即 OPFS API)是 sqlite.org 的 JS/WASM 版本中对开发时间和运行时性能影响最大的部分。
一旦某个方法是异步的,就无法通过同步接口隐藏它,因为异步属性具有“传染性”,要求其上层整个 API 都是异步的(而 SQLite 并非如此)。我们不得不将所有异步 I/O 移至独立的 worker 中,并通过基于 SharedArrayBuffer 和 Atomics API 构建的复杂代理来“模拟”对它的同步访问。这是一种糟糕的解决方案,但它是让异步函数完全同步行为的唯一(?)方法,无需依赖第三方库如 Asyncify。
附注:相反的情况——将同步内容隐藏在异步接口背后——非常简单。然而,在 JavaScript 中将异步内容隐藏在同步接口背后,则是一项极其繁琐的任务。
> 虽然这是一种令人不快的做法,但它是让异步函数完全同步运行的唯一(?)方法,且无需依赖第三方库如Asyncify。
附注:这种方法还受限于参数和结果类型,这些类型必须能够序列化为字节数组。也就是说,它的实用性比直接使用异步 API 更有限,因为后者可以接受任何参数类型并解析为任何类型的值。这对 SQLite 来说没问题,但这不是一个通用的解决方案。
> 在 JavaScript 中支持异步和同步就像瑞士手表一样顺滑
如何用 JavaScript 解决文章中描述的问题?我知晓
XMLHttpRequest
理论上可用于同步请求,但其行为已被废弃且正被浏览器主动限制。[已删除]
从 Go 切换到 Rust 后,我仍非常欣赏 Go。
Rust中的一切都比Go难得多且复杂得多。然而,正是这种额外的复杂性让你能够构建出坚如磐石的软件。我多次在Go中遇到问题,其中某个库深处对指针返回nil,导致我的程序以意想不到的方式崩溃。
我在另一个帖子中因与您持相反意见而被标记。但我还是要再强调一次。我认为还存在一种尚未被发明的语言,它兼具Go的简洁性和Rust的速度与安全性。目前这只是权衡取舍,因为尚无替代方案。
如本帖另一条评论所言,似乎存在根本性的限制。
启发式分析表明,这可能是Lisp类语言。
这难道不是因为你没有检查空指针吗?
我认为更糟糕的情况是,某个库在底层决定引发 panic。
我的意思是,Rust 库可以使用 `unsafe`…
你可能会(完全正确地!)反驳说,你可以排除使用
unsafe
的依赖项,但这样做只是将问题转移到了“现在我需要寻找新的库”上。关键在于,无论如何这都将是一项繁琐的工作。这种繁琐的工作总是存在的。你愿意处理哪些繁琐的工作,以及如何处理它们,这就是不同编程语言所定位的权衡空间。
如我在另一条评论中所说,Rust很棒,但它并非万能的。原因并非人们无知、愚蠢或缺乏避免麻烦的动力。而是因为有时,处理Rust比处理其他问题更困难。
在我看来,当 Rust 不适用时,Go 是一个不错的解决方案。我现在有两支箭在箭袋里。两者都不是我的最爱。我认为这是正确的态度,个人而言。
我是一个相对较新的 Rust 用户,是的,这与我的经验一致。我知道“Go 很简单”这个梗被过度使用了,但它确实是一个非常小的语言。尽管Rust投入了大量卓越的工程设计,但你为此付出的代价是必须思考和跟踪大量细节。
我多次在Go中遇到问题,即某个库深处的指针返回nil,导致我的程序以意想不到的方式崩溃。
这难道不正是意味着Rust比Go更容易吗?找到这些 bug 一定是一场噩梦,更不用说它们在生产环境中发生的影响了。
通常人们说 X 比 Rust 更容易,因为用 X 编写程序比用 Rust 编写程序更容易。事实上,他们应该比较的是(编写程序 + 调试)在 X 中的情况与(编写程序 + 调试)在 Rust 中的情况。
>这难道不正是意味着Rust比Go更容易吗?
我认为这取决于你更倾向于应对Rust的独特特性,还是追踪运行时错误/ panic。在许多情况下,我更愿意应对Rust的独特特性。在某些情况下(而且这些情况并不罕见),我更愿意处理 Go 的运行时错误。
让一些 Rust 开发者难以忍受(当然不是你,朋友!)的是,他们认为一切都与语言和工具链有关,而忽视了应用程序的巨大相关性。
人们没有普遍转向 Rust 是有原因的,而这并非因为他们无知或愚蠢。
从个人经验来看,我使用过两者。Go比Rust更容易。当然,这只是基于感觉,因为没有定量指标能将每个维度汇总成一个数字。
这100%考虑了编程和调试。
从轶事角度来看,这是因为人类大脑更容易调试Go语言的错误,而不是编写并完全理解Rust的所有语义。这与Python比C++更容易的原因相同,尽管反直觉的是,C++有严格的类型检查,而Python没有。
在我看来,Rust最终比C++更好,但它不幸地像C++一样,保留了一定程度的复杂性,超过了合理水平,这阻止了它实现普遍应用。因此它不是答案。未来将会出现某种语言,它将同样灵活/安全且复杂性更低。
或者……某些问题更适合某些语言,即使考虑到调试,Rust 也不总是赢家?
但复杂性难道不是导致 bug、意外行为和其他错误类型的主要因素吗?
>我多么希望Rust能有一个预先调度的绿色线程调度器。
他们在Rust的早期版本中尝试过这个功能,但最终移除了它。
Rust比Go有更严格的底层约束,他们不希望妥协这些约束。例如,FFI C互操作的性能约束,需要可预测的固定栈空间。这些原因在各种子线程中都有提及:
– https://lobste.rs/s/bfsxsl/ocaml_4_03_will_if_all_goes_well_…
– https://lobste.rs/s/y3fsrm/what_is_zig_s_colorblind_async_aw…
– https://lobste.rs/s/eppfav/why_i_rewrote_my_rust_keyboard_fi…
– https://news.ycombinator.com/item?id=21475154
作为对比,Rust 的原始设计者 Graydon Hoare 更倾向于“绿色线程”。在最近的一篇博客文章(2023 年 6 月)中,他提到他理解 Rust 团队出于性能原因放弃它的原因,但他对最终的权衡并不完全信服:
_-> 异步/等待。我想要一个标准的绿色线程运行时,具有可扩展的栈——本质上就是“当你需要时可以逃逸的协程”。可能需要一个可嵌入的外层事件循环/I/O管理库,但这始终会有些棘手。Go 从这里起步并一直沿用,但为了让 FFI 正常工作,他们不得不做出许多妥协,这导致性能上留有很大提升空间,并破坏了许多嵌入机会。Rust 也从这里起步,但经过多次重写后最终被弃用,原因虽多但没有一个能完全消除需求(如 Async/Await 和 Tokio 的重新兴起所证明的)。我对这一问题的立场有所软化,并对 Rust 的最终结果抱有勉强的敬意(尤其是在实现异构选择方面,我认为这使其与 Concurrent ML 处于类似且令人羡慕的表达力级别)。但如果我坦白说,如果我是 BDFL,我绝不会同意走这条路。——我从未想过这甚至可行——而且我仍然不确定结果是否物有所值。
— 来自 https://graydon2.dreamwidth.org/307291.html
首先,感谢您整理的这些链接。相信我,我一直在业余时间研究这个话题,其中有一些有趣的讨论是我之前错过的!
>我对这个问题的立场有所软化,并对Rust最终的发展方向抱有勉强的敬意
>我从未想过这甚至可行——而且我仍然不确定结果是否物有所值。
我同意。至少有两个理由让人喜爱Rust:类型检查和低开销。我恰好从事的领域不需要后者,但前者对我来说可能很有帮助。
(而且,我对预emptive并发高度依赖,这不仅意味着它必须运行良好,还意味着我在这个领域对易用性非常敏感。)
我可能是特例,但同步和异步都是工具箱中的工具,忽视其中任何一个都大多是愚蠢的?
这个故事就是关于不忽视同步或异步……
很高兴这项工作最终实现了对两者的支持。我内心的怀疑论者在想,它是否也对异步运行时无感知,而不仅仅是Tokio。如果这是可能的。
这与文章有什么关系?问题在于库:它们希望同时支持同步和异步用例,而无需考虑这些,这样库的用户可以决定什么适合他们。
朋友,你是不是下结论太快了? 😉
我的意思是,每次你在异步和同步之间做出选择时,你都在做错误的选择!
制作两个 crates?rspotify-sync、rspotify-async?
你不能制作一个不是异步的、只是阻塞的异步运行时吗?这样你就可以只保留异步接口了。
文章中有整整一节内容是关于他们使用两个 crates 时遇到的问题:
https://nullderef.com/blog/rust-async-sync/#_duplicating_the…
很明显你没有阅读这篇文章。这部分内容已经明确提及。
这解决了整个问题,除了库的消费者需要处理异步部分。