普通视图

发现新文章,点击刷新页面。
昨天以前首页

从参与 Rust 标准库开发看开源贡献的源动力

作者 tison
2024年8月9日 08:00

首先介绍一下我在 Rust 标准库当中做的两个微小的工作。

第一个是从去年 8 月 14 日发起,今年 4 月 6 日合并,历时约 8 个月,目前仍在等待 stabilize 的为 OnceCellOnceLock 增加新接口的提案:

第一次提交贡献

第一次贡献成功合并后,马上第二个工作是从今年 4 月 8 号开始,7 月 6 号合并,历时约 3 个月,同样还在等待 stabilize 的为 PathBufPath 增加拼接扩展名新接口的提案:

第二次提交贡献

可以看到,这两次贡献的内容可算是同种类型的,第二次提交从发起到合并的时间比第一次缩短了一半以上。本文先介绍 Rust 标准库提案的基本工作流程,然后介绍这两个贡献背后的故事,最终讨论开源贡献的源动力从何而来。

Rust 标准库提案的工作流程

标准库的贡献有很多种,上面我所做的两个贡献都是扩充现有数据结构的接口,也就是 API Change 类型的提案。

Rust 社群为 API Change Proposal (ACP) 设计了专门的流程,流程文档记录在 std-dev-guide 手册中。

简单来说,一共分为以下三个步骤:

  1. rust-lang/libs-team 仓库中创建一个新的 ACP 类型的 Issue 开始讨论;
  2. 讨论形成初步共识以后,修改 rust-lang/rust 仓库中标准库的代码并提交 PR 开始评审;
  3. 针对实现达成共识以后,在 rust-lang/rust 仓库中创建对应的 Tracking Issue 并由此获得标注 unstable feature 需要的参数(Issue 编码);PR 合并后,由 Tracking Issue 记录后续的 stabilize 流程

不过,其实我所做的两个简单改动并没有严格遵循这个流程。因为我首先不知道这个流程,加上实际改动的代码非常少,我是直接一个 PR 打上去,然后问一句:“老板们,这里流程是啥?我这有个代码补丁还不错,你看咋进去合适。”

开源贡献的源动力从何而来

为什么要给 OnceCell 和 OnceLock 加接口?

真要说起来,给 OnceCell 和 OnceLock 加接口的起点,还真不是直接一个 PR 怼脸。上面 PR 截图里也能看到,首先是我在原先加入 once_cell 功能的 Tracking Issue 提出为何没有加这两个接口的问题:


进一步地,之所以会提出这个问题,是我当时在实现 Apache Kafka API 当中 RecordBatch 接口,其中涉及到一个解码后缓存结果的优化。因为 Rust 对所有权和可变性的各种检查和契约,如果想用 OnceCell 来实现仅解码(初始化)一次的效果,又想要在特定上下文里取得一个可变引用,那就需要一个 get_mut_or_init 语义的接口。

实际上,这个接口的实现跟现有的 get_or_init 逻辑几乎一致,除了在接收者参数和结果都会加上 &mut 修饰符以外,没有区别。所以我倾向于认为是一开始加的时候没有具体的需求,就没立刻加上,而不是设计上有什么特殊的考量。

于是,开始讨论后的下一周,我就提交了实现功能的代码补丁。很快有一位志愿者告诉我应该到 rust-lang/libs-team 仓库走 ACP 的流程,我读了一下文档,尝试跟提出这个要求的志愿者 confirm 一下具体的动作。不过马上就是两个多月的毫无响应,我也就把这件事情暂时搁置了。

经典开始等待……

11 月,两位 Rust 语言的团队成员过问这个补丁。尤其是我看到 Rust 社群著名的大魔法师 @dtolnay 表示这个补丁确实有用,应该合进去以后,我感觉信心倍增,当周就把 ACP 的流程走起来了。

随后就是又两个月的杳无音讯……

今年 1 月,@dtolnay 的指令流水线恰巧把这个 PR 调度上来,给我留了一个 Request Changes 让我修一下编译。发现我凭直觉写出来的合理代码遇到了另一个 Rust 编译器的 BUG 导致编译不过:

解决以后,社群志愿者 @tgross35 指导我给新的功能添加 unstable feature 的属性注解,也就是:

1
#[unstable(feature = "once_cell_get_mut", issue = "121641")]

另一位社群志愿者 @programmerjake 帮我指出几个文档注释的问题,为标准库添加新接口不得不写好接口文档,摆烂不了。

在此过程中,我给 OnceLock 也同样加了 get_mut_or_initget_mut_or_try_init 接口,因为它和 OnceCell 本来就是同期进来的,接口设计也一模一样。我想那就一起加上这个接口,免得 OnceLock 后面有同样的需要还要另走一次 ACP 流程。

这悄然间又是两个月过去了……

终于,在 @programmerjake Review 完之后一周,@dtolnay 出现并 Appove 后合并了代码。

第一次 Rust 主仓库参与贡献至此告一段落,唯一剩下的就是等 libs-team 主观认为合适之后启动功能 stabilize 的流程。

上个月,有人发现之前写的文档注释还是有错误,于是提交 PR 修复。这就是某种开源协同让代码质量向完美收敛的过程。

为什么会想到给 Path 加接口?

这个动机要追溯到我用 Rust 重写 License Header 检测工具 HawkEye 的时候了。

Rust 重写 HawkEye 的动机,则是为了支持调用 Git 库跟 gitignore 的机制做集成。虽然原先 Java 的实现可以有 JGit 来做,但是 JGit 的接口很脏,而且加上 JGit 以后就不能 Native Image 了导致分发产物体积显著变大。虽然都不是什么大事,但是工具类软件本来就是强调细节处的开发者体验,正好我逐渐掌握了 Rust 编码的技巧,就拿上来练练手。

今年 3 月重写 HawkEye 的时候,我发现了 Rust 标准库里 Path 结构缺一个接口:

1
2
3
4
let mut extension = doc.filepath.extension().unwrap_or_default().to_os_string();
extension.push(".formatted");
let copied = doc.filepath.with_extension(extension);
doc.save(Some(&copied))

我在写上面这段代码的时候,目的是给文件名加一个 .formatted 后缀。但是,Path 的 with_extension 接口是直接替换掉扩展名,也就是说,原本我想要的是 file.rs 变成 file.rs.formatted 的效果,如果用 with_extension 接口,就会变成 file.formatted 这样不符合预期的结果。

最终,只能用上面这样的写法手动绕过一下。显然代码变得啰嗦,而且这段代码是依赖 extension 的底层实现细节,某种程度上说是 brittle 的。

遇到这个问题的时候,我就想给标准库直接提一个新的 PR 了。不过当时 OnceCell 和 OnceLock 的 PR 还没合并,我拿不准 Rust 社群的调性,也不想开两个 PR 都长期 pending 下去。

好在上一个 PR 在几周后顺利合并了,我于是同时创建了 ACP 和 PR 以供社群其他成员评审。因为改动面很小,比起口述如何实现,不如直接在 ACP 里链接到 PR 讲起来清楚。

这次,提案很快得到 Rust 团队成员,也是我的前同事 @kennytm 的回应。不到两周,我就按照上次的动作把 PR 推到了一个可以合并的状态。

然后又是两个月的杳无音讯……

最后,我根据其他社群成员的指导,跑到 Rust 社群的 Zulip 平台上,在 libs-team 的频道里几次三番的问:“这个 ACP 啥时候上日程啊?”

终于,在今年 7 月初,libs-team 在会议上 appove 了 ACP 的提议,并在一周内再次由 @dtolnay 完成合并。

开发者的需求是开源贡献的源动力

可以看到,上面两个贡献的源动力,其实都是我在开发自己的软件的时候,遇到的 Rust 标准库缺失的接口,为了填补易用接口的缺失,我完成了代码开发,并了解社群流程以最终合并和发布。在最新的 Rust Nightly 版本上,你应该已经能够使用上这两个我开发的接口了。

在开源共同体当中,一个开发者应该能够发现所有的代码都是可以更改的。这是软件自由和开源定义都保证的事情,即开发者应该能够自由的更改代码,并且自由地使用自己修改后的版本。

那么,开发者在什么时候会需要修改代码呢?其实就是上面这样,自己的需求被 block 住的时候。小到 Rust 标准库里接口的缺失,大到我之前在 Apache Flink 社群里参与的三家公司合作实现 Application Mode 部署,甚至完全重新启动一个开源项目,说到底核心的源动力还是开发者真的对这样的软件有需要。

因此,开发者的需求是开源贡献的源动力。一旦一个开源软件不再产生新需求,那么它就会自然进入仅维护状态;而一旦一个开源软件不再能够满足开发者实际的需求,甚至开发者连直接修改代码的意愿也没有,那么它的生命也就到此为止了。

Logforth: 从需求中来的 Rust 日志功能实现

由此,我想介绍一个最近在开发 Rust 应用的时候,由自己需求出发所开发的基础软件库:

Logforth: A versatile and extensible logging implementation

Rust 生态的日志组件,由定义 Logging 接口的 log 库和实现 Logging 接口的各个实现库组成。其中,log 库可以类比 Java 日志生态里的 SLF4J 库,而日志实现则是 Logback 或 Apache Log4j 这样的库。实际上,Logforth 的名字就来自于对 Logback 的致敬。

编写 Logforth 的动机,是我在选择 Rust log 实现的时候,现有所有实现都不满足我的灵活定制需求。生态当中实际定位在完整 log 实现的库,只有 fern 和 log4rs 这两个。其中,fern 已经一年多没有新的发布了,一些显而易见的问题挂在那里,内部设计也有些叠床架屋。而 log4rs 则是对 Log4j 的直接翻译,也有许多非常诡异的接口设计(底层问题是 Java 的生态和设计模式到 Rust 不能直接照搬)。

于是,我花了大概两个小时的时间,梳理出 Rust 和 Java 日志生态里日志实现库的主要抽象:

  • Appender 定义日志写出到目标端的逻辑;
  • Filter 定义日志是否要打印的逻辑;
  • Layout 定义日志文本化的格式。

然后花了一天的时间,把 log4rs 的主要 Appender 即标准输出和(滚动)文件输出给实现了,同时完成了基本的基于日志级别的 Filter 和纯文本以及 JSON 格式的 Layout 实现,就此发布的 Logforth 库。

经过两周的完善,目前 Logforth 库已经具备了全部日志实现所需的基本能力,且可以自由扩展。接口都非常干净,文档也都补齐了。除我以外,一开始一起讨论需求的 @andylokandy 也极大地帮助了 API 的设计跟多种常用 Appender 的实现和优化。应该说,目前的 Logforth 已经是超越 fern 和 log4rs 的库了。

而回到本文的主题,之所以我能在一天时间内就写出第一个版本,我跟 @andylokandy 能目标明确、充满动力地实现 Logforth 的功能,就是因为看到了自己和整个 Rust 生态的需求,并且我们清楚应该怎么实现一个足够好的日志库。

Rust 生态的诸多潜在机会

最后,为坚持到这里的读者分享几个我看到的 Rust 生态潜在的机会。

总的来说,目前整个 Rust 生态接近 Java 1.5 到 1.7 时期的生态,即语言已经流行开来,核心语言特性和标准库有一些能用的东西,整体的调性也已经确定。但是,距离 Java 1.8 这样一个全面 API 革新的版本还有明显的距离,核心语言特性的易用性有很大的提升空间,标准库的 API 能用但算不上好用,开源生态里开始出现一些看起来不错的基础库,但是还远远没有达到 battle-tested 的状态。

第一个巨大的缺失点就是 Async Rust 的实用工具。我找一个 CountdownLatch 的实现,找了半天才发现去年底发布的 latches 库符合我的期待。要是去年我有这个需求,就真没人能搞定了。后来我要找一个 Async 版 WaitGroup 的实现,找来找去没有一个合适的,只能自己 fork waitgroup-rs 改改来用。

Crossbeam 是个很不错的高质量库,可惜它提供的是同步版本的并发原语,不能在 Async Rust 里使用。上面 latches 和 waitgroup-rs 单看实现得还可以。但是这种单文件库,真的很难长期维护,而且像 latches 这样一个结构硬整出各种 feature flags 的做法,其实是反模式的,没必要。

所以一个 async-crossbeam 可能是目前我最想看到的社群库,或许它可以是 futures-util 的扩展和优化。这些东西不进标准库或者事实标准库,各家整一个,真的有 C++ 人手一个 HashMap 实现的味道了。

第二个缺失点,顺着说下来就是 Async Runtime 的实现。Tokio 虽然够用,但是它出现的时间真的太早了,很多接口设计没有跟 Async Rust 同步走,带来了很多问题。前段时间 Rust Async Working Group 试图跟 Tokio 协商怎么设计标准库的 Async Runtime API 最终无疾而终,也是 Tokio 设计顽疾和社群摆烂的一个佐证。

async-std 基本已经似了,glommio 的作者跑路了,其他 xxx-io 的实现也有种说不出的违和感。这个真没办法,只能希望天降猛男搞一个类似 Java ExecutorService 这样的体系了。而且最好还要对 Send + Sync + 'static trait bound 做一些参数化,不用全部都强行要求……

IO 和 Schedule 虽然有关系,但还是不完全一样的概念。总的来说 Rust 生态里暂时没有出现跟 Netty 一样 battle-tested 的网络库,也没有能赶上 ExecutorService 生态的 battle-tested 的异步调度库。不过好的调度原语库还是有一些的,比如 crossbeam-deque 和 soml-rs 里的相关库,等待一个能合并起来做高质量 Runtime 的大佬!

第三个缺失点还是跟 Tokio 有关。应该说 Tokio 确实做得早而且够用,但是很多设计到今天看就不是很合适了,然而社群里用得还是很重,就变得更不好了。比如上面的 log 库,Tokio 生态里搞了个叫 Tracing 的跟官方 log 库不兼容的接口和实现,也是一大堆槽点。

这里想说的是 bytes 库。很多 Rust 软件闭眼睛就用 bytes 处理字节数组,这其实有很大的性能风险。例如,基于 bytes 搞的 PROST 就有一些莫名其妙的的多余拷贝,可参考《Rust 解码 Protobuf 数据比 Go 慢五倍?》。值得一提的是,PROST 也是 Tokio 生态的东西。

Apache OpenDAL 内部实现了一个 Buffer 抽象,用来支持不连续的字节缓冲区。这个其实也是 Netty 的 ByteBuf 天生支持的能力。如果现在 Rust 有一个 Netty 质量的网络库,有一个 ExecutorService 的接口跟一些基本可用而不像 tokio 一样全家桶的实现,再把文件系统操作搞好点,我想整个 Rust 生态的生产力还能再上一个台阶。

Rust 的 Async FileSystem API 实现现在没有一个能真正 Async 的,这个其实 Java 也半斤八两。不过 Rust 的 io::copy 不能充分利用 sendfile syscall 这个,就被 Java 完爆了。这点是 Apache Kafka 实现网络零拷贝的重要工程支撑。

最后一个,给国人项目做个宣传。Rust 的 Web 库也是一言难尽,同样是 Tokio 生态的 Axum 设计让人瞠目结舌,整个 Rust 生态对 Axum 的依赖和分发导致了一系列痛苦的下游升级体验,可参考 GreptimeDB 升级 Axum 0.7 的经历,至今仍然未能完成。

国内开发者油条哥搞的 Poem 是更符合 Web 开发习惯的一个框架,用起来非常舒服,也没有奇怪的类型挑战要突破。Poem 的最新版本号是 3.0.4 而不是像 Axum 的 0.x 系列,这非常好。

不过,Poem 的接口文档、回归测试跟一些细节的易用性问题,还需要更多开发者使用慢慢磨出问题跟修复。希望 Poem 能成为一个类似 Spring 的坚固 Web 框架,这样我写 Rust 应用的时候,也能省点心……

举个例子,我的一个 Poem 应用里有 query handle panic 的情况,就踩到了 Poem 一个并发对齐的设计没有处理 panic 的缺陷。当然,我顺手就给修了,所以新版本里应该没这个问题。

至于文档不全的例子,主要是错误处理的部分,应该需要更多最佳实践。

无论如何,目前 Rust 生态整体的生产力和生产热情,还是比 Java 要高出太多。长期我是看好 Rust 的发展的,短期我只能安慰自己 Netty 是一个在 Java 1.6 才出现,1.8 才开始逐渐为人熟知使用,5.x 胎死腹中,发展时间超过 15 年的项目,生态要发展并不容易了。

Rust 程序库生态合作的例子

作者 tison
2024年5月31日 08:00

近期主要时间都在适应产品市场(Product Marketing)的新角色,不少想法还在酝酿和斟酌当中,于是文章输出没有太多时间来推敲和选题,只能保持每月发布相关的进展或一些零碎的思考。或许我可以恢复最早的模式,多做更新但是文章内容可能不会太过完整。

原本这一期想讨论的是 ASF 开源项目代码的所有权,以及开源软件变更协议的具体含义与操作方式。但是这个话题稍显枯燥,而且要把相关细节讲清楚,还需要继续斟酌。所以我改为采取把最近开始全职投入 Rust 开发,并接触相关生态发展和合作的经历做一点梳理,分享个人在其中的所见所闻。

我会从 Rust HTTP 库的生态切入,从最近一个大事件出发,即 Rust 采用范围最广的 HTTP 库 hyper 和 http 前后发布 1.0 版本,讲述其导致的整个 Rust 应用开发上下游牵一发而动全身的变化。

起因是我在整蛊 GreptimeDB HTTP 相关代码的时候,发现项目依赖的 axum 库是 0.6 版本,而上游是 0.7 版本。这天降的升级闲手,不升有点对不起自己了。通过解决升级过程中的问题,也能帮助摸清 GreptimeDB HTTP 模块的逻辑。

不过,我显然是小看了 Rust 生态荼毒甚广的 ZeroVer 文化的威力。

ZeroVer 是一个揶揄的说法,即在采用语义化版本(SemVer)的前提下,因为各种原因,项目迟迟不愿发布 1.0 版本。

0ver 的魅力时刻

在语义化版本中,0.x 版本是在项目正式发布或说进入稳定期前,一个相对动荡的快速迭代阶段。语义化版本的核心价值是告诉用户升级版本可能面临什么变化:

  • 升级补丁版本(Patch Version):应当只有缺陷修复和性能提升等,不会破坏用户程序的改动。
  • 升级小版本(Minor Version):可能包含新功能,应当向后兼容,用户应用应当可以顺滑升级。
  • 升级大版本(Major Version):可能包含破坏性变更,用户需要做好应对逻辑甚至数据迁移的准备。

当然,在实践当中,语义化版本并不那么严格执行。尤其对于大型项目的实验性功能,是可能有一个独立的可靠机制的。但是无论如何,进入 1.0 之后就意味着项目对用户做了一个向后兼容的保证,除非升级大版本,否则用户会假设软件升级是可以非常激进的。

ZeroVer 方案的反面极端是 Apache Arrow 和 Apache DataFusion 每次发版都升级大版本的做法,很难评价。

话说回来,axum 0.6 到 0.7 的版本升级是一个巨大的变更,基本把核心的类型设计做了一个颠覆,即 Body 不再是泛型了。

这其实也是一个槽点。国内开发者油条哥做的 Poem 就不搞这些花里胡哨的泛型,直接用胖指针抹掉底下的差别,提供更好的开发体验。你说我都 HTTP 了,搞应用层接口开发了,我是跟你抠这点性能的人吗?

一个屏幕都写不完的 breaking changes 列表

Rust HTTP 生态泛型的魅力时刻

于是我着手升级 axum 的版本,一上来就是好几个屏幕的编译错误。没事,Rust 开发者的日常而已。

第一个小问题,我们依赖了 axum-test-helper 这个库,它没跟上 axum 0.7 的版本。我先试着给上游提 PR 升级:

未果。

自己维护比较头疼,其实只有一个文件,最后我在 GreptimeDB 里 vendor 掉了:

这里我又要吐槽了。axum 是不是哪里有问题,居然不提供测试套件,还要下游自己 embedded 然后去掉 (crate) 修饰词,好玩吗?虽然其实也是可以用 reqwest 套一个 TestClient 解决,按照上游的说法:“这只是很薄的一层”,但是这么简单提升使用体验的事情,为何不做呢?

反观 Poem 就提供了 TestClient 工具,开发起来舒服多了。不管是不是我一个文件就能解决,这不是下游应该解决的事情。

紧接着,发现 axum 自己的 TestClient 有落后,以及一个 Rust Nightly toolchain 的兼容问题,提 PR 解决:

Rust Nightly toolchain 的兼容问题是一个很奇妙的问题。因为 Nightly 顾名思义就是最新的 Rust 开发版本,不提供语义化版本保证,只是在 Rust 1.x 的时间线上大体向后兼容。但是结合上 Rust Stabilize 的流程,以及打开 feature gate 如果找不到 feature flag 就会编译失败等等细碎的问题,经常会导致生态在跟进 Nightly 之前有一个无法编译的窗口。

这不算是绝对的坏事,甚至推着生态跟新版本是合理的。但某种程度上其实是 Rust Stabilize 太慢,导致系统开发比如 GreptimeDB 不得不用 Nightly 版本,而出现的新问题。Rust 开发很多用 Nightly 版本,跟 0ver 可能也有某种互相呼应的巧合。在这种环境下,开发公共库并保持多平台多版本兼容,其实是一件非常困难的事情,怪不得都 0ver 了。

然后我就遇到了本次升级最大的大魔王。Rust 生态的 gRPC 库 tonic 闪亮登场!

这里的依赖关系大概是这样的。

首先,axum 0.7 除了接口变化,还有一个关键的依赖变化是把 hyper 和 http 给升到了 1.0 上。因为 Rust HTTP 生态都依赖 hyper 和 http 这两个库,这就导致如果你的接口开始交互,那么所有的结构都要同步到同一个版本。

这个问题并不那么致命。因为如果你合理的 re-export 了依赖的接口,那么同一个 crate 的多个版本是可以共存的,就像我最近在升级 Apache OpenDAL 的时候连带需要升级 reqwest 到 0.12 版本,它依赖了 hyper 1.0 和 http 1.0 但是跟 axum 的 server 端代码关系较小,所以我可以切割开:

注意以上 PR 里修改 use 语句以选择正确的 re-export 符号的变更。

不过,tonic 的情况就比较幽默了。tonic 依赖了 axum 0.6 版本,而且 tonic 和 axum 都用了 tower 作为中间件的第三方库,而且都没有 re-export 而是标榜自己能无缝接入 tower 丰富的中间件生态。

由于 tonic 尚未完成 axum 0.7 和 hyper 1.0 的升级,这下就连环爆炸了:你找不到一个合适的 tower 版本,或者说你找不到一个合适的 axum 版本,来作为 GreptimeDB 被传递关联起来的版本约束。

于是,直到今天,GreptimeDB 的升级还是未完成的状态:

不过,在三月底,tonic 上游就出现了一位大英雄开了一个升级的 PR 完成了主要的工作:

这个 PR 我看到的时候还需要两个 hyper-util 的改动,我给帮忙推着合并了:

这里又岔开一下。虽然 tonic 在 hyperium 组织下,跟 hyper 和 http 一样,但是 hyper 和 http 的作者,Rust 生态真正负责人的英雄 @seanmonstar 并不怎么看 tonic 这个库。tonic 主要是 tokio 的作者 @LucioFranco 和另外两位志愿者 @tottoto 和 @djc 维护的。他们都有自己的本职工作要做,所以并没有太多时间 Review tonic 的变动。实际上,很多项目就用着 tonic 0.11 和 axum 0.6 万年不动也还行的(有没有发现 tonic 也是 ZeroVer 流派)。

不过,经过两个月当中的空闲时间累积,这个 tonic 的升级 PR 终于看着要走进尾声,希望能顺利。开源项目能有这个效率,小几个月跟进一个大型重构,虽然比不上 @seanmonstar 和其他活跃维护项目的响应速度,也绝对算是能及时更新的了。

这个 tonic PR-3610 有很多经典的开源贡献者跟维护者之间的交流和争论,我这里就不展开了。但是我非常建议各位关心开源的人去看看,了解真实的开源世界,未来或许参与进去做出自己的贡献,而不是自己想象或者冷眼旁观。

最后,贴两张图说明 Rust 生态 ZeroVer 的严重程度:

GreptimeDB 的依赖 ZeroVer 有 782 个

GreptimeDB 的依赖非 ZeroVer 有 278 个

在 GreptimeDB 的依赖里,ZeroVer 的占比约莫七成。

其实,GreptimeDB 很多升级都很有工程上的说法,欢迎各位关注发现。我可能也会在今年的某些 Rust 会议上分享相关的经验。

文末放两个我在 Reddit 上跟这次 HTTP 生态大更新相关的讨论链接,在 Rust channel 上还算有一些有趣的讨论。

再谈 Rust 项目社群治理

作者 tison
2023年11月18日 08:00

上一篇文章《Rust 社群何以走到今天?》发布之后,得益于 Rust 社群的繁荣和成员的分享惯例,我收到了不少关于 Rust 现状的评论。

大部分评论集中在 Rust 项目社群的治理上,即 BDFL 和基金会会对 Rust 项目社群产生什么影响,以及 Rust 项目社群是否需要 Leadership 组织。

我在上一篇文章中引用了 Graydon Hoare 和 @withoutboats 等人的博文,展示了“遗老”们对 Rust 项目社群目前的一些看法,这不免让人理解成赞同他们的观点,希望 Rust 社群出现一个强有力的领导人,甚至希望 Rust 语言像 Graydon 设计的那样演进。

其实,上一篇文章主要想说明的只有两个观点:

  1. Rust 项目社群治理的核心特点是没有一个人(BDFL)或一个一致行动的团体(Apache Group)说了算。
  2. Rust 项目社群未来的发展会极大受到各大公司组成的基金会的影响。

我并不认为有 BDFL 就一定是好事或是必须,至少我所在的每个 Apache 项目社群中都不存在 BDFL 角色,它们也发展得很好。

而且,以 Rust 项目社群如今的状况而言,几乎不可能出现一个新的 BDFL 或者 Graydon “复辟”的情况。因为拥有《大教堂与集市》中提到的,由“开垦”新项目带来的自然所有权的人,绝大多数已经长期淡出 Rust 项目社群,其他少数几个人也无意于社群管理。对于新人来说,由于没有明确地所有权转让程序,想要赢得 BDFL 的地位,就需要依靠自己的贡献。而以 Rust 项目社群目前的领域范畴来说,这对个人几乎是一个不可能完成的任务。所以,Rust 没有 BDFL 将会是一个长期的现状。

进一步地,作为 Rust 项目最初的赞助者,Mozilla 甚至不在 Rust 基金会的白金或黄金赞助商名单上。这就意味着在公司影响力上,哪怕是 Rust 基金会的创始会员也没有天生的主导权。

从机制上来说,Rust 基金会目前并不参与 Rust 项目的开发,基金会的目的是提供 Rust 项目存续的资金支持,以及相应的人才培养和品牌宣传。

上一篇文章中提到的 Leadership Council 并不隶属于基金会,而是 Rust 项目社群的一个治理机构,其成员由各个 Rust 项目顶级团队组成,基本职责是协调跨团队合作以及针对 Rust 项目社群治理本身的一些问题。

在这之前,Rust 项目社群在基金会和 Leadership Council 的领域内发生过几起争议事件:

  1. Ashley Williams 曾经是 Rust Core Team 的成员,同时是 Rust 基金会筹建时的执行董事,但是后来前后从这两个组织中出局
  2. Rust Moderation Team 因为跟 Core Team 的冲突,以集体辞职表达不满。如今,Core Team 本身被解散,而 Moderation Team 则由两位深度参与 Rust 核心开发的成员支撑。

Rust 社群的这些故事,除去缺少 Linus 这样的 BDFL 维持项目本身的有序运转之外,跟 Rust 社群总是期待一个两全的解法,以及过度期望所谓“专业人士”来解决问题的惯例是有关系的。

前者在 @withoutboats 的博文中有所提及:

我担心 Rust 项目从这个经验中得出了错误的教训。正如 Graydon 在这篇文章中提到的,Rust 开发团队坚持只要有足够的构思和头脑风暴,就能找到每个争议的双赢解决方案,而不是接受有时必须做出艰难决策的事实。Rust 开发团队提出的解决这种无休止的争议所带来的 burn out 的方式,是转向内部讨论。设计决策现在主要以 Zulip 线程和 HackMD 文档等未索引的格式进行记录。在公开表达设计方面,主要是在相关开发者的个人博客上发布。如果你是一个开发团队以外的人,那么你几乎不可能理解 Rust 开发团队认为什么是优先事项,以及这些事项的当前状态如何。

而后者则是一种社会高度分工以后常见的认识误区:其他人比我更专业,只要相信他们就好了;有其他人承担这项分工,我只要给予信任或者进一步给予金钱等利益,就可以完全把这些工作委托出去。

实际上,其他人未必有身临其境的人了解事情的原委沿革,不一定站在项目的立场考虑问题,甚至未必专业。对项目负责的人只能是深度参与的核心成员自己,这也是 Leadership Council 在成立动机中所提到的:

根据核心团队(Core Team)的经验,发现问题和开展实际工作本身,对于一个团队来说都太过繁重。这导致了不理想的结果,在某些情况下,还导致了倦怠。虽然有少量工作需要立即采取紧急行动,但绝大多数工作都需要由专门的管理机构进行跟踪和处理。

此前的 Core Team 和 Moderation Team 不仅相互之间缺乏沟通,甚至本身成员也相对独立于其他顶级团队,这导致本应作为协同社群反馈和各个顶级团队之间合作的两个独立团队,跟其他顶级团队之间也可能缺乏互信基础和合作机制。新的 Leadership Council 成立并以九个顶级团队代表为成员,期望在维持先前 Core Team 所要解决的问题的基础上,提高团队之间的协同效率。

对于基金会,可以明显的看到在机制上它相当于 Linux Foundation 之于 Linux 或是 Python Foundation 之于 Python 的形式,前两者在运行过程中间并未出现如同 Rust 社群这样的戏剧性发展。其原因部分在于它们有一锤定音的 BDFL 主导技术开发和项目团队合作,另一方面也是在这样长期的运作方式下,项目团队成员发展出一套自己解决问题的办法,而不是被不满意的现状困扰,转而祈求外部力量奇迹般的解决问题。

Rust Foundation 的性质是 501(c)6 行业联盟,这跟 Linux Foundation 是一致的,它们存在的目的除了写下来的维持 Rust 的存续发展,从性质上说是要为商业联盟中的成员企业服务的。相反,Python Foundation 和 Perl Foundation 的性质是与 Apache Software Foundation 相同的 501(c)3 慈善组织,纯粹是为项目本身的发展和公众利益服务。与此相匹配地,就是慈善机构型基金会往往在募集资金上不如商业联盟型基金会。

如前所述,由于 Mozilla 无法或不愿入局,如今 Rust Foundation 的四个创始会员分别是 AWS、谷歌、华为和微软。它们并没有像 Oracle 在 JCP 执行委员会中那样继承自 Sum 并再次发展得到的垄断权,甚至 Rust Foundation 本身也没有 JCP 那样直接指导语言项目技术发展方向的权限。但是,既然 Core Team 可以改组,Rust 社群整体对于基金会干涉项目发展存有期望,那么在未来社群治理的发展或者至少每次讨论当中,基金会就有可能发挥出自己的影响力。

当然,现在的主要问题恐怕不是担心基金会如何“窃取” Rust 项目的领导权,而是期望基金会的创始会员和其他赞助商先加大投入,哪怕就是奔着主导权来的,至少先把 Rust 的产业应用和语言发展,投入资源搞起来。但这是另一个话题了。

在基金会之外的另一个角度,也有人在呼唤唯一有可能成为 BDFL 的 Graydon 加入战局“拯救” Rust 项目社群。如前所述,由于 Graydon 本人已经淡出 Rust 社群很久,这实际上是几乎不可能的。

Graydon 本人对此的认识倒是比较清晰,他先后写下两篇博文对此做出回应:

其中第二篇已经在我的上一篇文章里展开介绍过,下面我会翻译第一篇。

译文之前说一点题外话。

Graydon 在第二篇博文里提出的对 Rust 的期待,在我上一篇文章发布后收到了推特上网友的讽刺。从 Graydon 本人列举出来的设计方向看,除了错误处理他更喜欢 Swift 的做法,其他的诸如绿色线程、语言级别集成的容器和迭代器、结构化类型等等,确实都是 Golang 的招牌特性。可以说,Graydon “在位”的话,Rust 恐怕就会发展成弱化的 Golang 而不是今天的 Rust 了。

另外,虽然试图“拥立” Graydon 重回 Rust 项目社群的呼声是不理智的,但是这些呼声的存在,侧面印证了前文提到的 Rust 项目社群总是期待两全解法,并总是寄希望于未知的专业人士神奇的解决问题的惯例。

Rust 项目如何走出当前的境况,我参与得不多无法给出意见。不过,我倒是乐意从开源社群发展的研究角度,来分析 Graydon 这样在卸任之后还持续思考和发生的例子:这确实是不可多得的考察对象。

在不能持续维护项目的作者里,Graydon 的案例非常有参考性。他思考了很多,也分享了很多。但是他没有“成功”。这比那些被“成功”掩盖了问题的例子,还有简单一句 burn out 解释所有问题的例子要有意思的多。

以下 Batten Down Fix Later 译文,“我”指的是 Graydon Hoare 本人。

在社交网站上,有人问我:“你是否希望自己是 Rust 项目的 BDFL?如果 Rust 项目有一个 BDFL,现在这些戏剧性事件会不会少一些?”

这是一个很难回答的问题。如果只看它表面的提问,我的答案是“不希望”和“不会”。但我认为它还间接地问了另一个问题,即 Rust 项目社群治理中是否存在某种原罪,或是致命缺陷,例如缺少 BDFL 的角色,而这正是其频繁发生内部政治问题的罪魁祸首?

关于这个问题,我可以分享一些我的观点,希望能简明扼要,或者至少具体一点。我认为有几个根本原因和一个主要的模式。

主要模式

我已经有十年没有参与这个项目了,所以我认为我说的任何话都需要谨慎对待。但我确实时不时地关注 Rust 项目的进展:这些年来,有不少人离开了这个项目,而且其中许多人的离开是不愉快的。很多人对参与这个项目感到后悔和怨恨。这令人悲伤。

我认为这些事情通常是这样发展的:

  1. 项目内部存在冲突。
  2. 这个冲突没有得到很好的管理或解决,至少没有以专业或健康的方式处理。
  3. 冲突并没有消失;相反,它以某种不专业或不健康的方式被表现出来:有时是一次性爆发,但更常见的是长期存在。
  4. 可能在经历了第三阶段的一段时间后,冲突的一方就离开了项目,而所有人都试图假装冲突从未发生过。

这里所谓的不专业或不健康的处理方式,我至少见过四种具体的形式:

  1. 利用非常规的力量:压力、社交影响力、小圈子。
  2. 利用外部正式权力:找某人的上司。
  3. 利用内部正式权力:与管理员进行交涉。
  4. 通过花费时间争论消耗对手的精力。

离开阶段常常显得有点突然,因为其原因不透明,并且也有几种不同的形式,通常对应到上述处理的模式:

  1. 因厌恶甚至为表抗议而辞职。
  2. 上司处理或解雇。
  3. 管理员处理或封禁。
  4. 由于精疲力竭或倦怠而退出。

理解模式

所有这些阶段都有可以理解的缘由。我不是说它们是“好”的,但可以理解。我理解它们是如何发生的,甚至可能对形成这些模式起过推波助澜的作用。

以下是一些背景事实,有助于理解为什么会出现上面这些模式:

第一,很多人只是习惯性地避免冲突。这可能是由于成长背景、个人创伤经历、固有的天性或其他原因。我自己就是避免冲突的人!应对冲突是困难且疲惫的,尤其对于没有报酬的志愿者来说。避免冲突通常看起来更容易。冲突回避是一种为了当下舒服一点,将问题推迟至未来应对的方式。

第二,Rust 如今形成了一种非常高风险的品牌。它似乎非常不可思议,经常吸引大量的互联网关注。它一度似乎被普遍崇拜,又被普遍憎恨。它感觉随时可能失败,但又感觉总是处于胜利的边缘。这在许多人中产生了一种自卫心理,我相信助长了这种心态的蔓延(我是一个焦虑的偏执狂),我确信这种心态是让许多人全身心地投入其中的原因之一。这可能是有趣和令人满足的,但对于许多人来说,它变得更重要。这些人为了参与 Rust 相关的事业付出了全部,将自己的职业或目标感、身份投入其中。自己的工作和未来往往岌岌可危!而且很多人也因过度工作、过度努力而真正精疲力竭。那些没有精疲力竭的人往往保留着一种内在的承诺,这使得缓和冲突变得困难,妥协变得困难,甚至公开承认问题也变得困难。

第三,Rust 最奇怪的文化规范之一(我可能有助于形成它,如果是这样,我向你道歉),是许多人都相信所有的冲突都是“虚假的权衡需求”,即冲突显示需要权衡的地方,都可以像 Rust 声称解决速度与安全之间的权衡问题那样,不用权衡地取得双赢。所有事情都可以实现双赢,如果你找不到这样的解决方案,那就是你没有努力尝试。我的意思是,如果真的能双赢,那当然很好,但是有时事情只能在合理的权衡甚至一定的冲突中形成决定!有些事情是零和博弈的。

第四,Rust 项目社群在管理或解决冲突方面没什么正式机构,考虑到 Rust 项目社群的规模,这个问题尤为突出。Rust 项目社群是互联网上的志愿者构建的,并在一个自身没有强大的正式架构的组织中孵化(Mozilla)。我推荐你阅读《无架构的暴政》这篇文章。Rust 项目社群中只存在着非正式的架构,或者执行严格的最终手段的正式架构,例如 moderators 或者外部干涉。

最后,这些非正式架构当中,有一种普遍存在于 Rust 和许多开源项目当中,这就是时间承诺的规范。《无架构的暴政》中也提到了这一点。热情的贡献者通常愿意并且有能力在项目上投入大量时间。然而,并不是所有人都能够遵守这种时间承诺。付出时间往往被明确激励:“真正做事的人有权做决策”。但是,这些决策往往会影响到许多其他的利益相关者,而“拥有最多时间的人”可能无法很好地代表这些利益相关者,或者可能缺乏完成这项任务所需的技能或知识。而“比其他人花更多时间”的做法也是一种相当不健康的处理冲突的方式:与其直接面对冲突,不如跟对方比拼时间投入,互相消耗精力。即使在正式架构内部,如果有“每单位时间贡献多少输入”的余地,这种做法也可以被采用。有些人会出现在每个会议上,推动相同的议程。这种做法行之有效,你能够按照自己的方式推进事情,但从社群角度上看,这并不是最好的方案。

修复问题

我真的不知道如何解决这些问题。如果我知道,我肯定会提出建议。我之前说过,我对助长一些模式感到相当负责。不过当然,过去的已经过去了。对项目来说,最重要的是未来。

我想我的主要建议是一个“不要听我的建议”的建议:“雇佣并听取那些在相关领域接受过培训的专业人士”的建议,其中“相关领域”涵盖了“一群编译器极客们”通常不擅长的一切。从项目管理到政治科学、金融、传媒、调解、人事管理。Rust 项目现在是一个相当大,而且非常分散的组织,人们长期以来一直在研究如何运行这类组织,甚至不同的专家研究其中不同的细分领域。应当听取他们的意见,而不是试图从某个第一性原则开始解决每个问题,也不要假装因为你们是一群互联网上的编译器极客就可以回避一个正常组织的所有机制。

我不知道新的治理体系在多大程度上具有这些专业人士的痕迹,也不知道它是否会在很大程度上解决上面提到的问题模式,但我可能会对结果感到惊讶。我在这些领域没有专业技能!如果一定要说,我喜欢“权力明确划分”、“任期限制”和“角色轮换以避免倦怠”这样的概念,以及透明决策;我喜欢设置某种利益相关者的代表和限制时间投入的机制。

我不知道社群是否承认现实中存在冲突,以及是否必须明确解决冲突。我不知道当前的治理模式和治理机构,在使项目中的权力职位,包括非正式的权力职位,对其行为负责方面,或在公开宣传以树立信心方面,是否做得足够。最主要的是:我不知道现在的治理模式,是否能够让那些不想全身心投入项目的人过上舒适的生活。

就我个人而言,我根本没有足够的精力,这一切对我来说都太沉重了。对于那些参与其中的人,我祝你们一切顺利。祝好运。

追加讨论:基金会

我想明确一点:据我所知,Rust 项目社群中的治理问题并不是 Rust 基金会导致的。每当社群中发生一些争议,总有一群人会把矛头指向基金会,这通常是错误的。

再次强调,我只是一个从旁观者的角度观察和倾听的外人。基金会通常似乎是房间里的成年人,当它做出一些表面上看起来奇怪的举动时,通常是因为它试图解决项目交给它的一些不可能解决的困境。

我认为基金会实际上只是想支持这个项目的存续,而 Rust 项目一直不是一个很容易支持的对象。

追加讨论:企业赞助商

此外,虽然 Rust 项目在一定程度上是由志愿者推动的,但它也在一定程度上是由企业赞助的,并且不仅是基金会成员的赞助。尽管有时我认为企业赞助会对维护者产生不良影响,导致维护者没有动力做那些琐碎的维护工作,但我认为 Rust 不会出现人们最担心的情况:企业在冲突或不受欢迎的决策中“购买影响力”,或者以其他方式操纵这门语言的发展。

这是一个可能存在的问题,但基金会的架构在设计之初,就花费了很大的精力来最小化相关风险。到目前为止,我认为我们没有看到这种情况。

追加讨论:最初的问题(BDFL)

为了更加具体地解释本文开头的“不希望”和“不会”的回答:我不喜欢受到关注或压力,当我在 2009 年至 2013 年担任项目技术负责人时,我接近极限运作,我离开的一部分原因是达到了这些极限并且有些崩溃了(以及公司对我燃尽的事实的反应并不好:参见“每个人都是人”)。我在本文中描述的人性缺陷,在我身上同样存在!

此外,我没有理由相信我会建立起强大或健康的正式决策机制、冲突管理机制或委派和扩展机制。我没有受过有关这些主题的任何培训,我完全是在 Mozilla 的角色中凭直觉行事。Mozilla 本身似乎对这些主题也没什么经验。我曾经试图在 Rust 的设计上进行一次“正式决策”,我试图用投票结果确定关键词命名的选择,但是结果非常糟糕:每个人都讨厌结果。

追加讨论:Moderation

我不了解 Rust Moderation Team 的故事。我从 2013 年其就不在 Rust 项目社群中了,因此,我不希望对他们过去的行为做出任何的评价或暗示,尤其是今天的 Moderation Team 已经不是过去的那一个了。在我看来,这是一个需要知晓内幕的人在其他地方讨论的主题。我想说两件事:

  1. 我写了最初的行为准则,且当时的行为准则更简短、更简单。我同意明确的社群规范和强制执行的能力通常是互联网社群所必须的。我不认为有管理员是一件坏事,也不认为管理员在谨慎和受监督的前提下行使权力是一件坏事。
  2. 我承认版主也是人。他们可能自己违反行为准则,也可能在处理其他人的违反准则行为是不够诚实。我认为这通常很少见,抱怨这种可能性的人往往没有站得住脚的理由,不过这些情况确实可能存在。要求对管理者的行为进行更严格的审查来避免这种监守自盗的可能性也算合理。根据我的经验,大多数好的版主愿意记录他们的决策过程,并解释决策的理由。

追加讨论:小团体和缺乏透明度

拥有志同道合的朋友是很棒的!关起门来做事情,不必向网络上的陌生人解释你所思考或说的每一件小事也是很棒的!这些事情本身并没有问题。实际上,这通常是许多人感到安全、舒适和愿意参与的先决条件。成为一个被审视的公众人物往往令人筋疲力尽,并且可能会排除那些被天性或环境所限的人。但一定程度的透明度是做出对他人产生影响的负责任决策的必要部分,也是权力行使的一部分。

Rust 社群何以走到今天?

作者 tison
2023年11月14日 08:00

本文有些标题党,实际想讲的内容,是我从部分 Rust 曾经的核心开发者的自述当中,所发现的 Rust 项目社群开源协同模式发展至今的一些特点。

我会从这些自述发言的内容切入和展开,对比其他社群遇到相似挑战的状况和应对方式,讨论 Rust 项目社群在协同方式维度上走到今天的沿革。

Graydon Hoare 的博文

说起 Rust 语言的核心成员,你会先想起谁?

不像 C# 之父 Anders Hejlsberg 和 Python 之父 Guido van Rossum 这样在很长一段时间里可以代表语言本身的人物,也不像 Brian Behlendorf 早期经常代表 Apache Web Server 项目发言,Rust 项目社群的一大特色就是不仅没有所谓的终身的仁慈独裁者(BDFL),甚至很难找到一个有足够权威拍板的人。

这就是 Rust 社区协同模式发展成今天这样的一个核心影响因素。

Graydon Hoare 是 Rust 的第一作者,但是如果你不对 Rust 的历史感兴趣,在日常讨论 Rust 的对话中,你很难听到这个人的名字或者他的观点。这是为什么呢?

通过查看 Graydon 在 Rust 仓库的提交记录,我们可以看到自 2014 年起,他就可以说不再参与 Rust 核心的开发了。

2014 年起,Graydon 的主要精力就投入在某区块链项目和 Swift 语言上:

apple/swift

apple/swift-source-compat-suite

不过,有趣的是,随着 Rust 在区块链领域越来越火,近一段时间 Graydon 又开始写起了 Rust 代码

Rust 1.0 的发布要追溯到 2015 年,而此时 Graydon 早已从 Rust 的核心开发中离开。这其中的原因可以从 Garydon 今年发布的一篇博文《我想要的 Rust 语言没有未来》中窥得端倪。

文章讨论了 Garydon 在设计 Rust 之初和早期开发过程中对 Rust 的定位,以及他对 Rust 语言当前某些特性的锐评,其中包括他不喜欢的一系列语言特性:

  • 跨 Crate 的内联和单态化;Garydon 引用了 Rust 另一位早期开发者 @brson 关于 Rust 编译时间的评论
  • 以库形式定义的容器、迭代器和智能指针;Garydon 希望这些都作为语法特性从而可以在编译器层面做更加极致的优化,这也是 Golang 的做法;
  • 外部迭代器,Async/Await 语法,& 作为类型,以及显式的生命周期;这些都是如今 Rust 语言基石级别的设计,Garydon 有不同的看法。
  • Lambda 表达式的环境捕获。
  • Traits 的设计,不完全的 Existentials 实现;后者简单地说就是跟 dyn Trait 相关的一系列设计和实现的问题。
  • 复杂的类型推导;这个评论可以参考《对 Rust 语言的分析》中的“类型推导”一节。
  • Nominal 类型;Garydon 喜欢 Golang 的 Structral 类型,这个问题跟 Traits 和 dyn Trait 的使用体验是相关的。
  • 缺少反射的支持。
  • 缺少错误处理系统;Garydon 喜欢 Swift 的错误处理方案。
  • 缺少 quasiquotes 功能,缺少语言级别的大整数支持,缺少语言级别的十进制浮点数支持;这些功能现在部分由生态中的第三方库实现,例如 @dtolnay 的 quote 库实现了 quasiquotes 的能力,但是 Garydon 认为它应该是语言的一部分。
  • 复杂的语法。
  • 缺少尾调用的保证。

可以看到,Garydon 设计和期望中的 Rust 语言跟如今实际成长出来的 Rust 语言大相径庭:关于引用和生命周期的设计,关于 Traits 和类型系统的设计,关于性能和编程效率之间的取舍,Garydon 的思路都不同于 Rust 如今的主流思路。

Garydon 对上面这些异议都分享了他 argue 的历史,实际上,他在博文一开始就对他在 argue 中失败的情形做了分类:

  1. 我计划或初步实现了 X 方案,其他人以高度的热情和坚持支持 Y 方案,包括利用舆论的影响力。最终,他们得到了自己想要的结果,而我失败了。
  2. 我初步实现了 X 方案,其他人更喜欢 Y 方案并做出了原型。Y 方案很吸引人,于是大家转而投入 Y 方案的开发,而我们时间紧迫,没有重新审视 Y 方案的所有缺点。
  3. 我初步实现了 X 方案,但是实际情况(通常是 LLVM 的限制)要求我们采用 Y 方案快速实现。我们暂时开发除了 Y 方案,然后同样由于时间紧迫,我们就一直将错就错的在 Y 方案上持续发展。
  4. 我初步实现了 X 方案,但是实现得明显有问题。于是在正式发布前我们把它从语言核心中移出,免得开发者在错误的基础上构建软件。于是一个语言级别的功能没有实现,后来生态系统或许填补了这一空白,甚至或许有多个竞争者。

对号入座,上面的众多 Garydon 不满意的特性都能分类进这四项中来。Garydon 在介绍这些他不喜欢的特性时多次提到了“我输了”,甚至在关于尾调用的讨论中,他写到:

由于实现尾调用的计划和其他成员对“性能上胜过 C++ 语言”的目标有冲突,我最终被说服不要使用尾调用。于是,我写了一篇悲伤的帖子来表达我对这个结果的不满,这是这个话题中最令人悲伤的事情之一……如果我是“终身的仁慈独裁者”,我可能会让语言朝着保留尾调用的方向发展。早期的 Rust 有尾调用,但主要是 LLVM 的原因让我们放弃了这个功能,而对跟 C++ 在性能上比拼的执著,使得这个结论几乎永远不会被推翻。

这就是 Rust 项目社群不同于其他编程语言项目社群的一个重要特点:它的作者不喜欢语言实际采用的许多核心设计,他没有妥协,也没有说服其他人,最终只能选择离开。

开源世界里有没有作者后来离开的例子,或者朝着不同于最初目标发展的案例呢?其实是有的。

Python 的作者 Guido van Rossum 在前几年也开始输掉不少关于 Python 新语法的争论。终于在 2018 年,他精疲力竭地离开了。但是至少 Python 的几乎所有核心功能都是在他的领导下实现的,同时他也能够以 BDFL 的身份强推社群从 Python 2 迁移到 Python 3 上来,尽管他事后表示这样的事情再来一次他或许从一开始就不会做。而在 Rust 项目社群当中,你很难想象有人能够做出这种级别的 breaking changes 并让社群接受。

C# 的作者 Anders Hejlsberg 在开始搞 TypeScript 以后实际也不怎么参与 C# 的发展了,但是他奠定了 C# 的基础和核心设计,且 C# 总体由思路一致的微软研发团队主导开发,所以这没有带来什么重大的影响。

Apache Flink 的作者最近也去创业搞别的事情了,不过由于社群人丁兴旺,社群发展过程一直是开放讨论达成共识,在早期开发者离开后,现在的开发者持续维护没有什么问题。如果熟悉 Flink 的历史,你会发现起初 Flink 是一个跟 Spark 正面竞争的批处理引擎,是在 2014 年中 Gyula Fora 带着他的实习生在 Flink Runtime 的基础上把整个项目改造成了流计算框架。不同于 Rust 的是,Flink 的作者们愉快地接受了这个改造,并把 Flink 重新定位成带状态的流计算框架全力发展,最终走出了一条不同于 Spark 的竞争之路。

当然,除了作者离开的,还有坚守在原地且不断整合不同人的意见的。在这一点上,做得最出色的毫无疑问是 Linux 的 BDFL 林纳斯·托瓦兹。

《时代周刊》曾经评论到:

有些人生来就注定能领导几百万人,有些人生来就注定能写出翻天覆地的软件。但只有一个人两样都能做到,这就是林纳斯·托瓦兹。

其实,这样的例子在开源世界中实在是少之又少。跟 Graydon 一样离开项目的有没有呢?Redis 的作者或许可以算一个。即使 Redis 的基石是他奠定的,但是后来 RedisLabs 的很多发展明显跟他对 Redis 的期望是有很大出入的,他于是在 2020 年公开声明不在担任 Redis 的维护者。

@withoutboats 的博文

Garydon Hoare 的博文分享了上古时期 Rust 团队的争论,而 @withoutboats 最近的几篇博文补充了许多 Rust “中世纪”的故事。

例如,上一节中 Garydon Hoare 不喜欢的外部迭代器,Async/Await 语法,& 作为类型,以及显式的生命周期这些特性,在 Why Async Rust 一文里 @withoutboats 是高度正面评价的。

那么,@withoutboats 是谁?

他是 Async Rust 的主要实现者之一,主导确定了 Async/Await 的语法,并实现了 Pin 和 Waker 等关键接口。

可惜的是,由于一些 GitHub Commit 和账号关联的问题,我们并不能简单地列出他的所有贡献。不过,就算是所有的 Commits 都能正确显示,@withoutboats 从提交数上看仍然不能排到 Rust 哪怕前 100 的贡献者名单里,从 2020 年开始,@withoutboats 就没有在 Rust 语言相关仓库的新提交了。

如果我们看看 @withoutboats 文章中提到的另外两位 Async Rust 的核心开发者,情况会更加有趣。

aturon-contribution

Aaron Turon 从 2017 年开始就没有任何参与了。

alexcrichton-contribution

Alex Crichton 是非常重要的 Rust 核心开发者。除了 Async Rust 的开发以外,他是 Cargo 项目的核心作者,且以 rust-lang-nursery 为阵地,打造了一批 Rust 早期的关键生态。

然而,他从 2022 年起也慢慢淡出了 Rust 项目社群,投入到 wasmtime 项目的开发中去了。

当然,wasmtime 项目的核心也是以 Rust 语言编写的。Alex Crichton 的这一决定,其实有点像从 2018 年开始淡出了 Rust 项目社群,2019 年加入 PingCAP 开发 TiKV 项目的 Brian Anderson

brson-contribution

2021 年,Brian Anderson 从 PingCAP 离开,关注起区块链公司 Solana Labs 的项目,甚至最近还跟 Graydon Hoare 主持的 Rust 项目 stellar/rs-soroban-env 有些合作,也是一种循环。

这个时候,我们再来看看 @withoutboats 对 Rust 语言演化建议的几篇博文,可能就会有不一样的感受:

《大教堂与集市》第三篇《开垦心智层》里讨论了关于开源软件话语权的问题,里面提到话语权的两种来源:

  1. 代码是你写的,于是你拥有代码的“所有权”,根据“责任背后是权力”的规则,你能够对如何演进这部分代码做定夺。
  2. 争议的双方并没有明确的所有权,但是一方在整个项目中投入更多,也就是在整个项目中拥有更多的领土权,所以他作为资深者胜出。

书中提到,如果这两条规则不能解决,那么“则由项目领导人来决断”。

很显然,Rust 项目不怎么符合这些条件。如前所述,拥有 Async Rust 的代码“所有权”的人都已经离开,且很难说有什么明确的传承。“项目领导人”在 Rust 社群中可以认为并不存在,第一作者 Garydon Hoare 伤心地离开了项目,堪称继父的 Brian Anderson 也从 2017 年起投入到 Rust 写成的项目而非 Rust 语言本身。

如此,Rust 项目社群就进入了 @withoutboats 所观察到的现状:尽管用户对 Async Rust 的后续进展很不满意,尽管 Rust 语言对 Immoveable / Unforgetable / Undroppable 这些能够与编译器深度协作的基础类型的需求是清楚的,但是放眼整个 Rust 社群,对于已经确定要做的事情如何做,争论几乎总是无法收敛,而对于不确定要不要做的提案,尤其是核心假设的进化与兼容方案,更是在未来几年内都看不到达成一致的希望。

@withoutboats 在《关于 Async Rust 的四年计划》的最后一段无不担忧地分享了一个过往的失败经历:

对于那些不了解的人来说,有一个关于 Rust 中 await 运算符应该是前缀运算符(就像其他语言一样)还是后缀运算符(最终的选择)的大辩论。这引起了极大的争议,产生了超过 1000 条评论。事情的发展是,几乎所有语言团队的人都达成了运算符应该是后缀的共识,但我是唯一的反对者。此时,明显已经没有新的论点出现的可能性,也没有人会改变主意。我允许这种状态持续了几个月。我对自己的这个决定感到后悔。显然,除了我屈服于大多数人的意见外,没有其他的出路,然而我却没有第一时间这样做。我让情况不断恶化,越来越多的“社群反馈”重复着已经提出的相同观点,让每个人都筋疲力尽,尤其是我自己。

我从这个经历中学到的教训是:区分真正关键的因素和无关紧要的因素。如果你对某个问题固执己见,最好能够清楚地阐述为什么它很重要,并且它的重要性应该超过语法选择之间微小的差异。自那以后,我试图将这一点铭记在心,以改变我在技术问题上的参与方式。

我担心 Rust 项目从这个经验中得出了错误的教训。正如 Graydon 在这篇文章中提到的,Rust 开发团队坚持只要有足够的构思和头脑风暴,就能找到每个争议的双赢解决方案,而不是接受有时必须做出艰难决策的事实。Rust 开发团队提出的解决这种无休止的争议所带来的 burn out 的方式,是转向内部讨论。设计决策现在主要以 Zulip 线程和 HackMD 文档等未索引的格式进行记录。在公开表达设计方面,主要是在相关开发者的个人博客上发布。如果你是一个开发团队以外的人,那么你几乎不可能理解 Rust 开发团队认为什么是优先事项,以及这些事项的当前状态如何。

我从未见过 Rust 项目与其社群的关系如此糟糕。社群当中存在着无价的知识,封闭自己不是解决方案。我希望看到项目成员重建与社群成员之间的相互信任和尊重的关系,而不是无视目前的敌对和不满的现状。对此,我要感谢那些在过去几个月中与我就设计问题进行交流的项目成员。

Nick Cameron 的博文

对于 @withoutboats 在上一节的最后提出的问题,Nick Cameron 在今年早些时候有几篇文章做出了讨论,直接相关的是这篇《关于开放式协作的一些想法》

非常有趣,在我介绍 Rust 的历史沿革的时候,我大量引用了其核心参与者的博客,这可以看做是 @withoutboats 所提到的“在公开表达设计方面,主要是在相关开发者的个人博客上发布”的习俗对整个 Rust 项目社群工作方式的一个更深远的影响。

那么,Nick Cameron 又是谁?

nrc-contribution

他是 Rust 语言团队曾经的成员,Rust 曾经的核心开发者。跟 Brian Anderson 一样,他在 2019 年加入了 PingCAP 公司,并于 2021 年离开 PingCAP 加入 VS Code 团队做 Rust 语言集成。于是,他在 Rust 主仓库的参与也从 2019 年开始消失,只在做回 Rust 语言层面相关的工作后做了一点工作。可以说,他所写的代码,如今也进入到上一节里提到的困境中:他是作者,但是已经离开太久,且他并未找到明确的继任者,于是这些代码如今是无主的。他对相关代码的演进没有说一不二的话语权,其他人也没有。

其实,在 Rust 项目社群中对编译器或标准库还能有很强势话语权的人,我能想到的大概就是:

  • 语言团队的领袖 Niko Matsakis
  • 编译器团队的成员 Oli Scherer
  • 发布团队的领袖和 Leadership Concil 的成员 Mark Rousskov

其他人就算是所谓 Leadership Concil 的成员或者是某个团队的领袖,从《开垦心智层》里提到的朴素的代码“所有权”的理解方式来看,也不一定是有说服力的。

说回 Nick Cameron 的博文,《关于开放式协作的一些想法》的核心是重申了 Rust RFC 流程的价值,并希望 Rust 在代码以外的治理流程里也坚持相同的开放原则和勇敢做出决定而不是征求所有人意见,这跟 @withoutboats 博文的观点是一致的。

实际上,Nick Cameron 在前两年年强力推进 Async Rust 的部分工作和 GAT 进入稳定版本。后者引起了巨大的争论,这同样让他精疲力竭,写下这两篇文章表达自己对这些争论的看法:

在具体项目的开放式协作以外,Nick Cameron 的其他几篇博文揭示了 Rust 项目社群今日协同格局的另一个重要影响因素:Rust 基金会。

评论

Rust 项目社群发展成今天的样子,其最核心影响因素,就是开发层面没有一个说一不二的领导人,或者一个团结的核心团队。

相信很多人还记得前两年 Rust Mod Team 集体辞职的事情,作为某种后续,实际上 Mod Team 批评的 Core Team 成员包括 Core Team 本身也都从 Rust 社群中消失了。

取代 Core Team 的是所谓的 Leadership Council 组织.该组织于今年六月份成立,起初每周有一次会议,现在减少到每个月有一次会议。讨论的内容主要关注治理、流程和标准问题。

这种情况是否经常发生呢?实际上它也不算罕见。

几乎可以称作 1:1 复刻的例子是 Perl 社群 2021 年的一个故事:致力于激进推动 Perl 发展的项目领袖 Sawyer X 想要推动 Perl 7 版本的发布,结果被其他项目成员联手弹劾。最终项目夭折,他也失望地离开了 Perl 语言团队。Perl 如今也是由一个叫 Perl Steering Council 组织管理。不过不同点是 PSC 尽管在语言发展上相对保守,但确实是领导语言开发的,且工作内容全面公开

作为补充,有心的读者可能已经发现上面列举的三位撰写跟 Rust 发展相关的博文的前核心开发者,如今并不在 Rust 的任何治理机构上。所以尽管没有那么激烈,但是他们的处境和 Sawyer X 是有某种相似之处的。

最后,Rust 项目社群的未来会如何发展呢?我想最有可能的结局,就是像 C 或者 C++ 那样,演化成由标准化委员会主导的语言项目。

实际上,这个新的 Leadership Council 做的一件重要的事情就是开始搞所谓的 Rust 标准

Rust 不像 C# 和 Golang 那样,语言本身就是某家公司的独占软件;也不像 Java 那样,虽然有 JCP 和委员会,但是 Oracle 以模块化提案为契机,奠定了自己几乎说一不二的地位;更不像 Ruby 或者 Elixir 这样的个人作者可以作为 BDFL 拍板。

Rust 基金会的成员就像 C++ 标准化委员会里的成员那样,哪个不是行业大鳄,哪个不是已经有或者打算有海量 Rust 生产代码。为了保护自己生产代码不被 Rust 演进制造出庞大的维护迁移成本,这些厂商势必要尽己所能的向 Rust 项目社群发挥自己的影响力。

由于 Rust 的第一作者和绝大多数早期核心作者已经长期离开项目社群,即使现在回来也不可能再建影响力。唯一有足够长时间和技术经验的 Niko Matsakis 又只关心语言技术发展,甚至 Leadership Council 的语言团队代表也让其他成员参与。这种情况下,Rust 项目社群的个人开发者,是不可能跟基金会里的企业有对等的话语权的。

实际上,如果 Rust 真能发展到 C++ 那样的状况,即使 C++ 有公认的第一作者 Bjarne Stroustrup 存在,他也无法在 C++ 委员会中强力推行自己的主张。

如果你对这样的未来感到好奇,推荐阅读 Bjarne Stroustrup 的论文 Thriving in a Crowded and Changing World: C++ 2006–2020 的第三节 C++ 标准委员会。我预计是已有之事,后必再有。

Why Async Rust

作者 tison
2023年11月6日 08:00

昨天的文章里,我介绍了 Async Rust 当前的实现以及一个实现 Async Runtime 需要了解的概念和现有的一些实践。

文章发出后,有评论称 Async Rust 创造性工作,没有可借鉴的经验。诚然,Rust 系统编程语言的定位就决定了它与其他有运行时的语言在设计时存在巨大的不同,同类语言 C 和 C++ 在异步编程方面,受限于语言的历史包袱,相关的支持往往以三方库而不是语言级的支持出现。

不过,Rust 至少可以借鉴自己一路走来的经验。Async Rust 的主要开发者之一 @withoutboats 在今年十月份的时候写了一篇 Why Async Rust 的博文,介绍了 Async Rust 一路发展的沿革和背后的设计决策。也是这一篇文章,帮助我理解了大量 Async Rust 设计的理由,从而融会贯通地写出昨天的文章。

随着软件系统越来越复杂,一个完整的复杂软件动辄几十上百万、甚至上千万行代码,提交记录少说几千,动辄几万甚至几十万。个人再也很难有足够的时间,单纯依靠读源代码和提交历史来理解系统运行的机制。这个时候,设计文档和作者现身说法讨论设计过程,尤其是失败的设计过程,就显得尤为重要。

实际上,我认为国内软件行业前沿的水平已经能跟世界前沿竞争,差的是时间积累的底蕴和人才的绝对数量。例如,全球第一波做数据库的人,到现在几乎都是三五十年经验起步,他们通过师父带徒弟甚至带出徒孙的形式,培养出了一大批领域专业人才。这一壁垒是很难走旁门左道超越的。

所幸开源运动的发展使得我们能够无国界的接触最前沿的软件设计思想和技术实践,我们于是可以对照一个完全开放的软件系统,根据核心开源开发者的介绍,理解这些智慧和经验的结晶。

以下原文,文中的“我”是 @withoutboats 的自称。

Rust 语言首次发布 async/await 语法时引起了很大的轰动。引用当时 Hacker News 的评论

这将打开新世界的大门!我确信很多人都在等待这一特性,我自己就是其中之一。

此外,这个新趋势具备一切优点:开源、高质量的工程代码、公开设计,许多贡献者为这个复杂的软件做出了巨大贡献。真是鼓舞人心!

最近,Rust 语言中的 async/await 语法变得有些褒贬不一。再次引用一条在 Hacker News 上的评论

我真的无法理解那些认为 Rust 的 async 设计很好的人。这简直是一团糟,更不用说 Rust 语言早已有着广为人知的陡峭学习曲线。

我竭尽全力尝试理解它,真的。但是,那真是一团巨大的混乱。而且它还会污染它接触到的一切。我真的很喜欢 Rust 语言,现在大部分时间都在用它编码,但每次遇到大量异步逻辑的 Rust 代码时,我都会全身紧张,视力模糊。

当然,这些评论都不能完全代表全部观点:早在四年前就有人提出了一些担忧。在同一条评论中,有人抱怨异步代码让自己全身紧张、视力模糊,但也有很多人同样热情地为异步 Rust 进行辩护。但我认为,可以说反对者越来越多,他们的口气也越来越强硬。在某种程度上,这只是炒作周期的自然进展。但是,我也认为随着时间推移,很多人未必记得或者了解异步 Rust 最初的设计过程,一些背景信息已经丢失了。

2017 年到 2019 年间,我与他人合作,在前人的工作基础上推动了 async/await 语法的设计。如果有人说,真不知道怎么会有人认为这团乱麻是个好的设计,我可能会有点不高兴。因此,请原谅我在这里用这篇不完善且过长的文章,解释异步 Rust 的诞生过程,异步 Rust 的目的,以及为什么在我看来,Rust 没有其他可行的选择。希望在这个过程中,我能够在某种程度上以更广泛、更深入的方式阐明 Rust 的设计,而不仅是重复过去的辩解。

关于术语的一些背景

在这场关于异步 Rust 的辩论中,争议的焦点之一是 Rust 决定使用所谓无栈协程(Stackless coroutine)的技术来实现用户空间并发。相关讨论充斥着大量术语,不熟悉所有术语是可以理解的。

我们首先需要明确的概念是这个特性的目的:用户空间并发。主流操作系统提供了一组相似的接口来实现并发:你可以创建线程,并在这些线程上依赖系统调用进行 IO 操作,这会阻塞线程直到操作完成。这些接口的问题在于它们存在一些不可避免的开销,如果你想要达到某些性能目标,这些开销可能成为限制因素。这些开销主要有两个方面:

  1. 在内核和用户空间之间进行上下文切换会消耗可观的 CPU 运行周期。
  2. 操作系统线程具有不可忽视的预分配线程栈,这增加了每个线程的内存开销。

这些限制在一定程度上是可以接受的。但是对于大规模并发的程序来说,它们并不适用。解决方案是使用非阻塞的 IO 接口,并在单个操作系统线程上调度海量并发操作。

开发者当然可以手动编写调度代码,但是现代编程语言通常提供现成的语法和标准库支持来简化这个过程。从抽象的角度来看,这意味着编程语言设计了一种把工作划分为多个任务,并将这些任务调度到线程上的方式。对应到 Rust 语言的设计,这就是 async/await 语法。

在这个问题的设计空间中,第一个选择的维度,是选择协作式调度还是抢占式调度,即任务是“协作地”将控制权交还给调度子系统,还是在运行过程中在某个时刻被“抢占”,而任务本身并不知道这一点?

关于这个主题的讨论中,经常被提及的一个术语是协程(coroutine),它的使用方式有些矛盾。协程是一种可以被暂停,并在稍后恢复的函数。其中一个重要的模糊之处,是有些人使用“协程”一词来指代具有显式语法以暂停和恢复的函数(这对应于协作调度的任务),而有些人则使用它来指代任何可以暂停的函数,即使暂停是由语言运行时隐式执行的(这也包括抢占式调度的任务)。我更倾向于第一个定义,因为这个定义做了一些有意义的区分。

另一方面,Goroutines 是 Go 语言的一项特性,它实现了抢占式调度的并发任务。Goroutines 具有与线程相同的接口,但是它是作为语言的一部分而不是作为操作系统原语来实现的,在其他语言中,这种特性通常被称为虚拟线程或者绿色线程。所以按照我的定义,Goroutines 不是协程。但是其他人使用更广泛的定义说 Goroutines 是一种协程,而我将这种特性称为绿色线程,因为在 Rust 中一直使用这个术语。

第二个选择的维度,是选择有栈协程还是无栈协程

有栈协程和操作系统线程一样有程序栈:当函数作为协程的一部分被调用时,它们的栈帧会被推入栈;当协程暂停时,栈的状态会被保存,以便从同一位置恢复。

无栈协程采用不同的方式存储需要恢复的状态。它通常将状态存储在一个延续(continuation)或状态机中。当协程暂停时,它所使用的栈被接替协程的操作使用;当协程恢复执行时,协程重新持有当前栈,并使用先前存储的延续或状态机,从上一次暂停的位点恢复协程。

在 Rust 和其他编程语言中,关于 async/await 语法,经常提到的一个问题是“函数着色问题”。这个问题说的是,为了获得异步函数的结果,开发者需要使用不同的操作(例如使用 .await 语法)而不是正常调用它。绿色线程和有堆协程没有这个问题,因为只有无栈协程才需要特殊语法来管理无栈协程的状态,具体特殊语法表示什么行为则取决于语言的设计。

Rust 的 async/await 语法是无栈协程机制的一个例子。异步函数被编译为返回 Future 的函数,而该 Future 用于存储协程在暂停时的状态。关于异步 Rust 的辩论的基本问题是,Rust 是否正确采用了这种方法,或者它是否应该采用更类似 Go 的有栈协程或绿色线程方法。理想情况下,开发者希望不需要显式语法来“着色”函数。

异步 Rust 的开发历程

绿色线程

Hacker News 的另一个评论很好的展示了这场辩论中我经常看到的一种言论:

开发者期望的并发模型是基于有栈协程和管道实现的结构化并发,底层实现由工作窃取执行器来优化负载。

除非有人实现这个模型的一个原型,并将之与当前的 async/await 语法加 Future 的实现相比较,否则我认为不会有任何建设性的讨论。

抛开上面提到的结构化并发、通道和工作窃取执行器(因为这是完全是不相关的问题),这种言论令人困惑的地方在于,最初的 Rust 确实实现了有栈协程,即绿色线程。但在 2014 年底,也就是 1.0 版本发布即将之前,它被移除了。了解其中的原因将有助于我们弄清楚为什么 Rust 推出了 async/await 语法。

对于任何绿色线程系统(无论是 Rust、Go 还是其他语言),一个重要问题是如何处理这些线程的程序栈。请记住,用户空间并发机制的一个目标是减少操作系统线程使用的大型预分配栈的内存开销。因此,绿色线程库往往会尝试以较小的栈启动线程,并仅在需要时进行扩展。

实现这一目标的一种方法是所谓的分段栈,其中程序栈是一系列小栈段的链表;当栈增长超出段的边界时,会向列表中添加一个新段,而当栈缩小时,该段将被移除。

这种技术的问题在于,它引入了高度不可控的将栈帧推入栈的成本。如果帧适合当前段,这基本上没什么开销。但是一旦不适合,程序需要分配一个新段。最恶劣的情况是在热循环中的函数调用需要分配一个新段,这时,该循环的每次迭代都需要进行一次分配和释放,从而对性能产生重大影响。而且,这些开销对用户来说完全不透明,因为用户不知道在调用函数时栈的深度。Rust 和 Go 都开始使用分段栈,然后因为这些原因放弃了这种方法。

另一种方法称为栈复制。在这种情况下,栈更像是一个数组而不是链表:当栈达到限制大小时,它会重新分配一个更大的栈。这样可以使初始栈保持尽量小,只根据需要进行扩展。这种技术的问题在于,重新分配栈意味着对其进行复制,且新分配的栈位于内存中的新位置。同时,任何指向栈的指针在栈重新分配后就无效了,程序需要一些额外机制来更新这些指针。

Go 使用栈复制技术。这主要得益于以下事实:Go 中指向栈的指针只能存在于同一个栈中。因此,Go 运行时只需要扫描该栈以重写指针。然而,做到这点至少需要运行时类型信息,而 Rust 并不保留这些信息。此外,Rust 也允许指向栈外的栈指针,这些指针可能在堆中的某个位置,或者在另一个线程的栈中。跟踪这些指针的问题最终与垃圾回收的问题相同,只不过这次的目标不是释放内存,而是移动它。显然,Rust 没有垃圾回收器,也不打算内置某种垃圾回收器。上面两个理由的任一个都导致 Rust 无法采用栈复制的方法。

早期 Rust 采用分段栈的方案,并通过增加绿色线程的大小来缓解频繁分配分段栈的问题,这也是操作系统线程的做法。当然,这种方案失去了绿色线程的一个关键优势:比操作系统线程更小的栈帧。

即使 Go 使用了可调整大小的栈来解决开销问题,当 Go 程序尝试与其他语言编写的库集成时,绿色线程仍会带来一定的无法避免的成本。C ABI 和操作系统栈是任何编程语言共享的最低要求。把代码从绿色线程切换到运行在操作系统线程栈上,可能会导致无法承受的 FFI 成本。Go 只是接受了这种 FFI 成本,这也成为 Cgo 饱受诟病的一个问题。最近,C# 因为这个局限性中止了绿色线程的实验

这个问题对 Rust 同样是致命的。因为 Rust 的设计目标之一,是支持将 Rust 库嵌入到用其他语言编写的二进制文件中;另一个设计目标,是能够在资源有限的嵌入式设备上运行,即使这些设备没有足够的时钟周期或内存运行虚拟线程运行时。

早期 Rust 为了解决这个问题,曾经将绿色线程运行时设置成可选的:Rust 可以在编译时选择将绿色线程编译成在原生线程上使用使用阻塞式 IO 运行的形式。因此,有一段时间,Rust 有两种变体:一种使用阻塞式 IO 和原生线程,另一种使用非阻塞式 IO 和绿色线程,并且所有代码都打算与两种变体兼容。

然而,实际结果并不理想,出于 RFC 230 的原因,从 Rust 移除了绿色线程的支持:

  1. 在绿色线程和原生线程之间的抽象并不是零成本的。执行 IO 操作时,会有无法避免的虚拟调用和内存分配,这对于特别是原生代码来说是不可接受的。
  2. 这一设计强制原生线程和绿色线程支持相同的接口,即使在某些情况下这并没有意义。
  3. 绿色线程并不完全支持互操作,因为仍然可以通过 FFI 原生的 IO 接口,即使是在绿色线程的上下文里。

绿色线程被移除了,但高性能用户空间并发的问题仍然存在。为了解决这个问题,Rust 团队开发了 Future trait 和后来的 async/await 语法。但要理解这条道路,我们需要先介绍 Rust 对另一个问题的解决方案。

迭代器

我认为 Rust 开始目前的异步功能设计,其真正起点可以追溯到 2013 年一位名叫 Daniel Micay 的开发者在邮件列表中发布的一篇帖子。这篇帖子与 async/await 语法、Future trait 或非阻塞 IO 没有任何关系,它是关于迭代器的。Micay 提议 Rust 转向使用所谓的外部迭代器,正是这种转变以及它与 Rust 的所有权和借用模型的有效结合,不可避免地将 Rust 引向了 async/await 的道路。当然,当时没有人知道这一点。

Rust 一直禁止使用变量的别名绑定来修改状态。这个常被称为 mutable XOR aliased 的约束在早期 Rust 中和今天一样重要。但是,最初它是通过不同于生命周期分析的机制来实施的。当时,引用只是某种参数修饰符,类似于 Swift 中的 inout 修饰符。2012 年,Niko Matsakis 提出并实现了 Rust 生命周期分析的第一个版本,将引用提升为真正的类型,并使其能够嵌入到结构体中。

尽管人们公认采用生命周期分析对塑造现代 Rust 的巨大影响,但是这一功能与外部迭代器的共生发展,以及这一功能对于 Rust 定位到当前领域的支柱型作用,并没有得到足够的关注。

在采用外部迭代器之前,Rust 使用了一种基于回调的方法来定义迭代器,这在现代 Rust 中大致可以表示为:

1
2
3
4
5
6
7
8
9
enum ControlFlow {
Break,
Continue,
}

trait Iterator {
type Item;
fn iterate(self, f: impl FnMut(Self::Item) -> ControlFlow) -> ControlFlow;
}

以这种方式定义的迭代器,将在集合的每个元素上调用 iterate 方法传入的回调函数。在回调函数返回 ControlFlow::Break 之前,迭代会不断产生下一个结果。for 循环的主体被编译为传递给正在循环遍历的迭代器的闭包。这种迭代器比外部迭代器更容易编写,但是这种方法存在两个关键问题:

  1. 语言无法保证当循环中断时迭代实际上停止运行。因此,语言无法依赖此功能来确保内存安全。这意味着无法从循环中返回引用,因为循环实际上可能会继续执行。
  2. 无法实现交错多个迭代器的通用组合子,例如 zip 方法,因为这种传递回调函数的设计不支持交替迭代两个不同的迭代器。

相反,Daniel Micay 建议 Rust 改用外部迭代器模式,从而完全解决了这些问题。外部迭代器的方式对应了 Rust 用户今天所熟悉的接口:

1
2
3
4
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}

原注:深入了解底层细节的读者可能会知道,Rust 的 Iterator 提供了 try_fold 的方法,该方法在功能上与内部迭代器的 API 非常相似,并且用在其他一些迭代器组合子中,因为它可以生成更好的代码。但 try_fold 不是定义迭代器的核心底层方法。

外部迭代器与 Rust 的所有权和借用系统完美地集成在一起,因为它们本质上编译为一个结构体,该结构体在自身内部保存迭代的状态,并且因此可以像任何其他结构体一样包含对正在迭代的数据结构的引用。而且由于单态化(monomorphization)的存在,通过结合多个组合子构建的复杂迭代器也编译成单个结构体,从而使优化器可以透明地优化这些结构。唯一的问题是,手动编写这些结构体更困难,因为开发者现在需要定义将用于迭代的状态机。

当时,Daniel Micay 当时写下这样一段话预示未来的发展:

在未来,Rust 可以使用像 C# 那样的 yield 语句来实现生成器。生成器被编译成一个快速的状态机,而无需进行上下文切换或依赖虚拟函数,甚至不需要闭包。这将消除使用外部迭代器模式时,手动编码递归遍历的难题。

生成器的进展不算快。不过,最近发布了一份关于 gen 语法的 RFC 提案,或许我们可能很快就会看到这个功能。

即使没有生成器,外部迭代器仍然被证明是非常成功的,人们普遍认识到了这项技术的价值。例如,Aria Beingessner 在设计字典的 Entry API 时就使用了类似的方法。值得注意的是,在 Entry API 的 RFC 中,她说这种 API 的设计“类似于迭代器”。她的意思是,Entry API 通过一系列组合子构建了一个状态机,这使得该 API 对编译器高度可读,从而能够进行深度优化。这项技术很有潜力。

Futures

在 Rust 团队寻找绿色线程的替代的过程里,Aaron Turon 和 Alex Crichton 首先复制了许多其他语言中使用的 Future/Promise 接口。这类接口基于所谓的传递延续风格(continuation passing style)设计。开发者可以向以这种方式实现的 Future 注册一个回调。这个回调被称为延续,它会在 Future 完成时被调用。

这就是大多数语言中定义 Future/Promise 的方式。这些语言会将 async/await 语法编译成传递延续风格的代码。

在 Rust 中,这类 API 大致会被定义成:

1
2
3
4
trait Future {
type Output;
fn schedule(self, continuation: impl FnOnce(Self::Output));
}

Aaron Turon 和 Alex Crichton 尝试了这种方法,但正如 Aaron Turon 在一篇具有启发性的博文中写的,他们很快遇到了一个问题,即使用传递延续风格需要为回调分配空间。Turon 举了一个 join 的例子:

1
2
fn join<F, G>(f: F, g: G) -> impl Future<Item = (F::Item, G::Item)>
where F: Future, G: Future

在这个 join 函数的定义中,注册到返回值 Future 上的回调需要被两个子 Future 拥有,因为只有后完成的子 Future 后才应该执行它。这最终需要实现某种引用计数,以及依赖内存分配来实现。而对于 Rust 来说,这些开销都是不可接受的。

于是,他们研究了 C 开发者实现异步编程的常见做法。在 C 语言的世界里,开发者通过构建状态机来处理非阻塞 IO 操作。由此,Aaron Turon 和 Alex Crichton 希望能够设计出一种可以将 Future 编译成 C 开发者手动编写的状态机的接口。经过几番尝试,他们最终采用了一种称为基于就绪状态(readiness-based)的方法:

1
2
3
4
5
6
7
8
9
10
enum Poll<T> {
Ready(T),
Pending,
}

trait Future {
type Output;

fn poll(&mut self) -> Poll<Self::Output>;
}

不同于存储延续的方案,在这个新方案中,Future 由某个外部执行器进行轮询。当一个 Future 处于挂起状态时,它会保存一种唤醒执行器的方式。当 Future 准备好被再次轮询时,执行该方式以通知执行器再次进行轮询。

译注:这里说得有点抽象。最终 Rust 的实现是 poll 方法接收一个 Context 参数,Context 持有一个 Waker 实例,如果 poll 返回 Pending 结果,那么返回前它需要以某种方式保存 Waker 实例。异步运行时需要实现一个监听状态变化的逻辑,在挂起的计算可以继续执行时,取出对应的 Waker 实例,调用 wake 或 wake_by_ref 方法通知执行器再次轮询这个 Future 计算。

通过以这种方式反转控制,Rust 不再需要为 Future 存储完成时需要调用的回调,而只需要将 Future 表示为一个单独的状态机。他们在这个接口之上构建了一系列组合子库,所有这些组合子都会被编译成一个单一的状态机。

从基于回调的方法切换到外部驱动程序,将一组组合子编译成单一的状态机,甚至是这两个 API 的确切规范:如果你是按顺序读到这一段的,所有这些都应该非常熟悉。从传递延续到外部轮询的转变,与 2013 年迭代器的转变完全相同!

再一次地,正是由于 Rust 能够处理具有生命周期的结构体,才使得它能够处理从外部借用状态的无栈协程,进而能够在保证内存安全的前提下,以最佳方式将 Future 表示为状态机。无论是应用于迭代器还是 Future 接口,将较小的组件组合成单个对象状态机的模式,是 Rust 的经典设计模式。这一模式几乎自然地融入到了语言当中。

我稍微停顿一下,强调迭代器和 Future 之间的一个区别:像 zip 这样交错两个迭代器的组合子,在基于回调的方法中甚至是不可能的,除非编程语言另外构建了某种协程的原生支持。另一方面,如果你想交错两个 Future 实例,比如实现 join 方法,基于延续的方法可以支持,只是会带来一些运行时成本。这就解释了为什么外部迭代器在其他语言中很常见,但 Rust 将这种转换应用于 Future 的做法却是独一无二的。

在最初的版本中,futures 库的设计原则是,尽可能使用户以类似构造迭代器的方式来构造 Future 实例:原语级别的 Future 库的作者将直接实现 Future trait 来定义原语级的 Future 结构,而编写应用程序的用户将使用 futures 库提供的一系列组合子,将简单的 futures 组合构建成更复杂的 Future 实例。不幸的是,当用户尝试遵循这种方法时,他们立即遇到了令人沮丧的编译器错误。问题在于,当 futures 被生成时,它们需要逃离(escape)当前上下文,因此无法从该上下文中借用状态。相反,任务必须拥有它的所有状态。

这对于 futures 组合子来说是个棘手的问题。因为通常情况下,任务状态需要在构成 Future 的一系列组合子中都能被访问。例如,用户通常会先调用一个 async 方法,然后调用另一个:

1
foo.bar().and_then(|result| foo.baz(result))

问题在于,foo 在 bar 方法中被借用,然后在传递给 and_then 的闭包中再次被借用。实际上,用户想要做的是在 await 点之间存储状态,这个 await 点是由 Future 组合子的链接形成的,而这通常会导致令人困惑和费解的借用检查错误。最容易理解的解决方案是将该状态存储在 Arc 和 Mutex 中。但使用 Arc 和 Mutex 不是零成本的,而且,随着系统变得越来越复杂,这种方法变得非常笨重和不灵活。例如:

1
2
3
let foo = Arc::new(Mutex::new(foo));
foo.clone().lock().bar()
.and_then(move |result| foo.lock().baz(result))

尽管最初的实验中 futures 展示了出色的基准测试结果,但由于这个限制,用户无法使用它们来构建复杂的系统。这个时候,我加入到 futures 的开发当中,着力改进这个状况。

async/await

在 2017 年末,由于上节末尾提到的用户体验不佳的原因,futures 生态系统并未得到好的反响。futures 项目的最终目标始终是实现所谓的无栈协程转换,即使用 async 和 await 语法的函数,可以转换为求值为 Future 的函数,避免用户手动构造 Future 实例及调用其方法。Alex Crichton 曾经开发了一个基于宏的 async/await 实现,但几乎没有引起任何关注。

是时候做出一些改变了。

Alex Crichton 实现的宏的核心问题之一,是一旦用户尝试在 await 点上保持对 Future 状态的引用,编译器就会报错。这实际上与用户在 futures 组合子中遇到的借用问题是相同的:在等待期间,Future 无法持有对自身状态的引用。因为这要求把 Future 编译成一个自引用的结构体,而 Rust 不支持这种实现。

如果把这个问题跟前文提到的绿色线程的问题进行比较,我们会得到一些有趣的结论。例如,我们说把 Future 编译成状态机的优势之一,是编译出来的状态机就是一个完美大小的栈。无论是手动实现、使用组合子或使用 async 函数构造出 Future 实例,编译后的 Future 大小都是它所需的最小大小。反观绿色线程,绿色线程的栈必须不断增长,以容纳任何线程栈可能具有的未知大小的状态。Future 状态机的大小在编译时确定后不再需要变化,因此,我们不会在运行时遇到栈增长的问题。

可以说,绿色线程的栈现在成了 Future 状态机结构保存的状态。由于 Rust 的语言约束要求移动结构体必须总是安全的,因此即使在执行期间,我们不会移动 Future 状态机,但是 Rust 的语言约束会要求它是能被移动的。这样,我们在状态机的方案里又遇到了绿色线程方案中遇到的栈指针问题。不过,这一次,我们有一个新的优势:我们不需要真的能够移动 Future 状态机,我们只需要表达 Future 状态机是不可移动的。

为了实现这一点,最初的尝试是引入 Move trait 的定义,这个 trait 用于将协程从可以移动它们的 API 中排除。不过,引入一个新的语言行为层面的 trait 遇到了我之前记录过的后向兼容性问题。关于 async/await 语法,当时有三个核心需求:

  1. Rust 语言层面就要实现 async/await 语法,从而支持用户使用类似协程的函数构建复杂的 Future 实例。
  2. async/await 语法需要支持将这些函数编译为自引用的结构体,从而支持用户在协程中使用引用。
  3. 这个功能需要尽快发布。

这三个需求的结合促使我寻找一种不对语言进行任何重大破坏性改变的替代方案。

我最初的计划比我们最终实现的方案要糟糕得多。我提议将 poll 方法标记为不安全的(unsafe),然后增加一个不变式,即一旦开始对一个 Future 进行轮询,就不能再次移动它。这个方案非常简单,可以立即实现,但是同时它也非常粗暴:它会使每个手写的 Future 都不安全,并且会施加一个难以验证的要求。更糟糕的是,编译器无法不安全的代码提供任何帮助。它很可能最终会遇到一些正确性问题,并且肯定会引起极大的争议。

所以,幸运的是,Eddy Burtescu 的几句话引导我思考另一个接口设计方向,这个 API 可以以更精细的方式强制执行所需的不变条件,也就是现在你所看到的 Pin 类型。Pin 类型本身有着一定的学习曲线,但我认为它还是显著优于当时我们考虑的其他选项,因为它是有针对性的、可强制执行的,而且,按照这个方案,我能按时发布 async/await 功能。

现在回顾起来,Pin 方案存在两类问题:

  1. 后向兼容性:一些已经存在的接口(特别是 Iterator 和 Drop 等)本应该支持不可移动类型。由于后向兼容性的要求,现在其实没有支持,这限制了语言进一步发展的可选项。
  2. 向终端用户暴露:我们的设计意图是,编写普通的异步 Rust 代码的开发者不需要处理 Pin 类型。在大多数情况下,这是正确的,但有一些典型的例外。几乎所有这些例外都可以通过一些语法改进来解决。唯一真正糟糕的问题是当前实现必须 Future 实例 Pin 住才能使用 .await 语法,这是一个不必要的错误,现在要修复它将是一个破坏性的改变(这着实让我感到尴尬)。

现在,关于 async/await 的设计决策只剩下语法的确定,我不会在这篇已经过长的文章中进行详细讨论。

组织上的考虑

我探索这段历史的原因是要证明一系列关于 Rust 的事实不可避免地将我们引入了特定的设计领域。

首先,Rust 没有运行时,所以绿色线程不是一个可行的解决方案。这不仅因为 Rust 需要支持嵌入到其他应用程序,或在嵌入式系统上运行,也因为 Rust 无法执行绿色线程所需的内存管理。

其次,Rust 的设计能够自然地把协程编译为高度可优化状态机,同时仍然保持内存安全性。我们不仅在 futures 和迭代器的设计中都利用了这一点。

但是,这段历史还有另一面:为什么我们要追求用户空间并发的运行时系统?为什么要引入 futures 和 async/await 语法?

提出这些问题的人通常有两种观点:

  1. 有些人习惯于手动管理用户空间并发,直接使用像 epoll 这样的接口;这些人有时会嘲笑 async/await 语法为“网络废物”。
  2. 另一些人只是说“你不需要它”,并提议使用更简单的操作系统并发机制,如线程和阻塞 IO 等。

在没有用户空间并发支持的语言,比如 C 语言中,实现高性能网络服务的人通常会使用手写的状态机。这正是 Future 抽象的设计目标,它可以自动编译成状态机,而无需手动编写状态机。实现无栈协程转换的目的,就是用户可以用顺序编程的方式编写代码,而编译器会在需要时生成状态转换以暂停执行。这样做有很大的好处。

例如,最近的一个 curl CVE 的底层原因就是代码在状态转换期间未能识别需要保存的状态。手动实现的状态机很容易出现这种逻辑错误,而 Rust 语言的 async/await 语法能在不损失性能的前提下避免这类逻辑错误。

2018 年初,Rust 团队决定在当年发布一个新的版本(edition),以解决一些在 1.0 版本中出现的语法问题。同时,Rust 团队计划以这个版本作为 Rust 进入主流市场的第一印象。Mozilla 团队主要由编译器黑客和类型理论学家组成,但我们对市场营销有一些基本的想法,并认识到这一版本可以成为产品广受关注的契机。我向 Aaron Turon 提议,我们应该专注于四个基本的用户故事,这些故事似乎是 Rust 的增长机会。这些故事包括:

  • 嵌入式系统
  • WebAssembly
  • 命令行接口(CLI)
  • 网络服务

这个建议成为建立领域工作组(Domain Working Groups)的起点,这些工作组旨在成为专注于特定使用领域的跨职能团队,区别于关注在具体技术或组织工作的团队(teams)。Rust 项目中工作组的概念后来有了很多改变,基本不再是最初设计的定义了,但我偏离了主题。

async/await 的工作开始于网络服务工作组,后来简称为 async 工作组并延续至今。然而,我们也非常清楚,由于 Rust 没有运行时依赖,Async Rust 也可以在其他领域发挥重要作用,尤其是嵌入式系统。我们设计这个功能时考虑到了这两种用例。

然而,尽管很少有人明说,Rust 要想成功,明显需要得到行业的采用。这样,即使 Mozilla 不再愿意资助一个实验性的新语言,Rust 也还能生存下去。而短期内行业采用的最有可能的途径,是在网络服务领域,特别是那些在当时被迫使用 C/C++ 编写的性能要求很高的系统。这个用例完全适合 Rust 的定位:这些系统需要细致的底层控制来满足性能要求,但是避免内存错误同样至关重要,因为它们面向网络。

网络服务的另一个优势是,这个软件行业领域具有快速采用新技术的灵活性和需求。其他领域当然也是 Rust 的长期发展的机会,但是它们不太愿意迅速采用新技术(嵌入式)、依赖于尚未广泛采用的新平台(WebAssembly)或者不是特别有利可图的工业应用,无法为 Rust 语言的发展带来资金支持(命令行界面)。

我以坚定的热情推动 async/await 的采用,因为我认为 Rust 的生存取决于这个功能。

在这方面,async/await 取得了巨大的成功。许多 Rust Foundation 最重要的赞助商,特别是那些雇佣开发者编写 Rust 代码的赞助商,重度依赖 async/await 编写高性能网络服务,这是他们愿意出资金支持的主要用例之一。在嵌入式系统或内核编程中使用 async/await 也是一个具有光明未来的领域。

async/await 在生态方面取得的巨大成功,甚至让开发者抱怨 Rust 生态系统滥用 async/await 而不是尽可能写“正常”的 Rust 代码。

我无法评价那些更愿意使用线程和阻塞 IO 的用户。当然,我认为有很多系统可以合理地采用这种方法,而且 Rust 语言本身并不阻止他们这样做。他们的反对意见似乎是,crates.io 上的生态系统,特别是用于编写网络服务的库,滥用 async/await 语法。我偶尔会看到一些使用 async/await 近乎船货崇拜(cargo cult)的库,但大多数情况下,可以合理地假设库作者实际上想要执行非阻塞 IO 以获得用户空间并发的性能优势。

我们无法控制每个人决定从事的工作。事实就是,大多数在 crates.io 上发布网络库的人都希望使用 async Rust 的能力,无论是出于商业原因还是出于兴趣。

当然,我希望在异步上下文以外也能方便的使用 async Rust 写成的库。这或许可以通过将类似于 pollster 的 API 引入标准库来实现。

不过,对于那些抱怨其他人发布的开源软件库不能恰好解决自己问题的人,我无言以对。

待续

虽然我认为 Rust 没有更好的 Async 解决方案,但是我并不认为 async/await 就是所有编程语言的最优解。特别是,我认为有可能有一种语言,它提供与 Rust 同等的可靠性保证,但是提供更少的对值的运行时表示的控制,同时使用有栈协程。我甚至认为,如果这种语言以一种可以同时用于迭代和并发的方式支持这些协程,那么该语言完全可以在不使用生命周期的情况下消除由于别名可变性而引起的错误。如果您阅读 Graydon Hoare 的笔记,您会发现这种语言正是他最初追求的目标。

我认为如果这种语言存在的话,有些 Rust 用户会非常愿意使用它,并且我理解他们为什么不喜欢处理底层细节的固有复杂性。以前,这些用户抱怨 Rust 语言有一大堆字符串类型,现在他们更有可能抱怨异步编程。我希望有一种提供与 Rust 同等保证的用于这些场景的语言存在,但是这不是 Rust 的问题。

尽管我认为 async/await 是 Rust 的正确演进路径,但我也同意对于当前的异步生态系统状况感到不满是合理的。我们在 2019 年发布了一个 MVP 实现,tokio 在 2020 年发布了 1.0 版本,自那时以来,Async Rust 的发展有些停滞不前。在后续的博文中,我希望讨论一下当前异步生态系统的状况,以及我认为项目可以做些什么来改善用户体验。但这已经是我发布过的最长的博客文章了,所以现在我只能到此为止了。

Async Rust 的实现

作者 tison
2023年11月5日 08:00

绝大多数第一次接触 Async Rust 的开发者所写的 Hello World 程序是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
async fn say_world() {
println!("world");
}

#[tokio::main]
async fn main() {
// Calling `say_world()` does not execute the body of `say_world()`.
let op = say_world();
// This println! comes first
println!("hello");
// Calling `.await` on `op` starts executing `say_world`.
op.await;
}

目前最流行的 Rust Async Runtime tokio 告诉你,只要在 main 函数前加上 #[tokio::main] 这个咒语,再把 main 函数定义成一个 async fn 函数,就可以在程序的任何地方用 async fn.await 语法享受到并发编程的免费午餐了。

但是,这样的解释显然不足以满足相信 Rust 尽可能显式表达所有开销的哲学的人;也就是说,到底 async fn 里的逻辑是怎么被执行的,代码中出现 .await 的时候,Rust 程序实际在执行什么动作。

本文基于具体的异步运行时和并发库代码,介绍支持 Rust 并发模型工作的核心概念。

Async Runtime

Rust 语言是一门系统级编程语言,这意味着 Rust 程序不像 Node.js 程序那样自带运行时。这不仅意味着没有全局管理内存的垃圾回收器,也意味着没有一个全局默认的异步任务运行时。

例如,以下 Node.js 代码会打印出 Hello world 字串:

1
(async function() { console.log('Hello world'); })()

而相似的 Rust 代码却不会有任何输出:

1
async { println!("Hello world") };

Rust 编译器提示:

1
note: futures do nothing unless you `.await` or poll them

如果在上述代码后加入 .await 调用:

1
async { println!("42") }.await;

编译会直接报错:

1
`await` is only allowed inside `async` functions and blocks

编译器提示 .await 只能在 async 代码块里使用,我们尝试把 main 函数改成 async 函数:

1
2
3
async fn main() {
async { println!("Hello world") }.await
}

编译器同样报错:

1
`main` function is not allowed to be `async`

我们回过头看 tokio 的示例,再添加了 #[tokio::main] 以后,似乎 main 函数就可以写成 async fn main 了,也能用 .await 触发 Future 计算了。

查看 tokio 的文档,可以知道 #[tokio:main] 实际上是一个过程宏,它会把 async fn main 展开成:

1
2
3
4
5
6
7
8
9
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
println!("Hello world");
});
}

由此,我们知道,在 Rust 程序里要想执行 async 块里的并发代码,我们需要先构造一个 Async Runtime 的实例,再调用类似 block_on 的方法来驱动并发计算执行。

Simplest Runtime

tokio 为我们提供了一个功能完备的异步运行时。然而,tokio 的实现也是极其复杂的。要想理解 Rust 异步运行时的执行逻辑,我们可以先实现一个最简单的异步运行时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct ThreadWaker(Thread);
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}

fn block_on<F: Future>(fut: F) -> F::Output {
let mut fut = pin!(fut);
let t = thread::current();
let waker = Arc::new(ThreadWaker(t)).into();
let mut cx = Context::from_waker(&waker);
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(res) => return res,
Poll::Pending => thread::park(),
}
}
}

应用代码如下:

1
2
3
fn main() {
block_on(async { println!("Hello world") });
}

这时,我们编译运行代码,可以看到屏幕正常输出 Hello world 字串。

我们来看一看上述代码实际执行了什么动作:

  1. async 代码块构造了一个并发计算闭包,返回一个 Future trait 的实例。
  2. 这个 Future 被传到 block_on 函数里,用 pin! 宏构造了符合 poll 方法要求的 Pin 实例。
  3. block_on 函数内,构造了一个 ThreadWaker 实例,持有当前线程的引用。
  4. 从 ThreadWaker 示例调用一系列构造器构造出 poll 方法需要的 Context 实例。
  5. 调用 poll 方法,在本示例中,首次调用就会执行 println 动作并返回 Poll::Ready(()) 结果,于是 block_on 函数返回。

我们把这个例子稍微写复杂一点,把异步代码块从简单打印一个字符串,改为一个类似 Timer 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
struct MyFuture {
id: u32,
start: Instant,
duration: Duration,
}

impl Future for MyFuture {
type Output = ();

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
println!("Polling {} {}", self.id, chrono::Utc::now());
let now = Instant::now();
let expect = self.start + self.duration;
let id = self.id;
if expect > now {
let duration = expect - now;
let waker = cx.waker().clone();
thread::spawn(move || {
sleep(duration);
println!("Wake up {id} {}", chrono::Utc::now());
waker.wake();
});
println!("Pending {id} {}", chrono::Utc::now());
Poll::Pending
} else {
println!("Ready {id} {}", chrono::Utc::now());
Poll::Ready(())
}
}
}

fn main() {
println!("Start {}", chrono::Utc::now());
block_on(MyFuture {
id: 1,
start: Instant::now(),
duration: Duration::from_secs(10),
});
println!("Finish {}", chrono::Utc::now());
}

输出内容如下:

1
2
3
4
5
6
7
Start 2023-11-05 09:53:03.202626 UTC
Polling 1 2023-11-05 09:53:03.203152 UTC
Pending 1 2023-11-05 09:53:03.203222 UTC
Wake up 1 2023-11-05 09:53:13.206258 UTC
Polling 1 2023-11-05 09:53:13.206370 UTC
Ready 1 2023-11-05 09:53:13.206396 UTC
Finish 2023-11-05 09:53:13.206407 UTC

可以看到,两次 Polling 之间间隔了十秒钟。实际执行的内容如下:

  1. 同上 block_on 函数第一次调用 poll 方法,这次 poll 返回了 Poll::Pending 结果,于是 block_on 阻塞在 thread::park() 上。该方法用同步原语信号量阻塞当前线程,并等待其他线程调用 unpark 唤醒内部信号量。
  2. poll 方法返回 Pending 前,从 Context 里取得 Waker 实例,并启动一个新线程,在十秒后调用 waker.wake() 方法。
  3. wake 方法调用即调用 self.0.unpark() 方法,于是第一步里阻塞的线程恢复执行,在 loop 中再次调用 MyFuture 的 poll 方法,此时时间已经超过预计等待时间,方法返回 Poll::Ready(()) 结果,block_on 方法也返回。

Simplest Runtime 在 crates.io 上有一个完整的实现 pollster 运行时。这个运行时的核心代码同样极其简短,不超过一百行。

Waker API

上面这个例子解释了在 Rust 的 Future API 中神秘的 Context 参数和它唯一传递的 Waker 实例的作用:Waker 实际上就是一个回调闭包,使用 wakewake_by_ref 执行通知异步运行时可以再次调度当前 Future 计算。

waker-fn 库提供了闭包到 Waker API 的适配器,这直观地展示了 Waker 就是一个回调闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pub fn waker_fn<F: Fn() + Send + Sync + 'static>(f: F) -> Waker {
Waker::from(Arc::new(Helper(f)))
}

struct Helper<F>(F);

impl<F: Fn() + Send + Sync + 'static> Wake for Helper<F> {
fn wake(self: Arc<Self>) {
(self.0)();
}

fn wake_by_ref(self: &Arc<Self>) {
(self.0)();
}
}

那么,为什么 Rust 要在 FnOnce 和 Fn 之外设计 Waker API 呢?我们看到 Waker 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pub struct Waker {
waker: RawWaker,
}

pub struct RawWaker {
data: *const (),
vtable: &'static RawWakerVTable,
}

pub struct RawWakerVTable {
clone: unsafe fn(*const ()) -> RawWaker,
wake: unsafe fn(*const ()),
wake_by_ref: unsafe fn(*const ()),
drop: unsafe fn(*const ()),
}

impl Waker {
pub fn wake(self) {
let wake = self.waker.vtable.wake;
let data = self.waker.data;
crate::mem::forget(self);
unsafe { (wake)(data) };
}

pub fn wake_by_ref(&self) {
unsafe { (self.waker.vtable.wake_by_ref)(self.waker.data) }
}
}

RawWaker 中的 data 字段,内容就是 Waker 本身。这跟我们常见的 Safe Rust 有很大的区别。它使用了等价于 void**const () 以及一系列方法引用构成 VTable 来组成 RawWaker 进而构成 Waker 实例,从而在类型签名中把类型参数或生命周期标记给抹掉。

这样,Rust 的 Async API 可以直接传递 struct Waker 的实例,而不是 Box<dyn Fn()>Box<dyn FnOnce()> 等。但是,后者编译时会被 Rust 编译器在生命周期和是否 Send 等类型约束上不停折磨,甚至有些代码表达不出来。

由于我们此时知道 Waker 对应的回调一定是可用的,所以 Rust 的 Async API 选择使用 VTable 和 unsafe 来实现躲过编译器检查的 Box<dyn Fn()> 的类型。

Simplest Parallel Runtime

上述异步运行时实现里,我们观察到了异步代码被 poll 的过程,但是这跟常见的通过并发实现并行,同时执行多个任务还是有所出入。

我们对 block_on 的代码稍作改动,把阻塞在当前线程的行为改为每次传入一个新的 Future 实例,就启动一个新线程来驱动执行,同时,将函数名改为 spawn 以体现出这种行为的改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn spawn<F>(fut: F) -> Receiver<F::Output>
where F: Future + Send + 'static,
F::Output: Send,
{
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut fut = pin!(fut);
let t = thread::current();
let waker = Arc::new(ThreadWaker(t)).into();
let mut cx = Context::from_waker(&waker);
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(res) => {
let _ = tx.send(res);
break;
}
Poll::Pending => thread::park(),
}
}
});
rx
}

这时,我们再编写一个同时启动两个任务的应用程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn main() {
println!("Start {}", chrono::Utc::now());
let r1 = spawn(MyFuture {
id: 1,
start: Instant::now(),
duration: Duration::from_secs(10),
});
let r2 = spawn(MyFuture {
id: 2,
start: Instant::now(),
duration: Duration::from_secs(10),
});
let h1 = thread::spawn(move || {
r1.recv().unwrap();
println!("Finish 1 {}", chrono::Utc::now());
});
let h2 = thread::spawn(move || {
r2.recv().unwrap();
println!("Finish 2 {}", chrono::Utc::now());
});
h1.join().unwrap();
h2.join().unwrap();
}

可以看到以下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
Start 2023-11-05 10:46:31.880514 UTC
Polling 1 2023-11-05 10:46:31.880631 UTC
Polling 2 2023-11-05 10:46:31.880650 UTC
Pending 1 2023-11-05 10:46:31.880659 UTC
Pending 2 2023-11-05 10:46:31.880672 UTC
Wake up 2 2023-11-05 10:46:41.881091 UTC
Wake up 1 2023-11-05 10:46:41.881091 UTC
Polling 2 2023-11-05 10:46:41.881206 UTC
Ready 2 2023-11-05 10:46:41.881232 UTC
Polling 1 2023-11-05 10:46:41.881261 UTC
Ready 1 2023-11-05 10:46:41.881276 UTC
Finish 2 2023-11-05 10:46:41.881330 UTC
Finish 1 2023-11-05 10:46:41.881377 UTC

这样,我们就实现了一个最简单的 thread per task 并发模型。

Send + ‘static

上面 thread per task 的例子里,spawn 函数里多了 Send'static 约束,这是因为我们在不同线程间传递 Future 实例,同时可能在线程间传递 Future 计算的结果。

这并不是 Rust Async Runtime 自身约束。如前所述,如果 Future 在当前线程构造,随后使用 block_on 在当前线程阻塞等待,Future 没有跨线程传递过,也就不需要满足 Send 约束。同样,异步运行时在能够保证计算一定不会跨线程进行时,异步计算的代码块即 Future 也不必满足 Send 约束。

在 tokio 的实现里,这种同一线程的计算是用 LocalSet 来抽象的:

1
2
3
4
5
6
7
8
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let local = tokio::task::LocalSet::new();
let rc = Rc::new(42);
local.run_until(async move {
println!("rc={rc}");
}).await;
});

如果把 local.run_until 换成 tokio::spawn 那么 Rust 编译器就会抱怨被 move 进去的 Rc 实例不是 Send 的。

LocalSet 的底层实现是持有一个 LocalState 实例,其中指示了持有该 LocalSet 的线程的 ThreadId 是什么。tokio 运行时调度过程里,如果拿出来的作业对应的 ThreadId 跟当前 ThreadId 不同,则不执行该作业,而是放回 LocalSet 的任务队列里。

glommio 是一个 thread per code 的异步运行时,它只提供对应到单个线程的 LocalExecutorBuilder 构造器:

1
let handle = LocalExecutorBuilder::default().spawn(|| async move { ... })?;

虽然顶层 async 块内部,可以使用 executor().create_task_queue(...) 创建任务队列,再调用 glommio::spawn_local_into(fut, tq) 把任务提交到任务队列内并发调度,但是同一个 spawn 内的所有任务,都将在同一个线程内执行。因此,所有 spawn 到 glommio 调度器上的 Future 都不必是 Send 的。

上述 LocalExecutorBuilder 的 spawn 方法最终会调用以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn spawn<G, F, T>(self, fut_gen: G) -> ...
where
G: FnOnce() -> F + Send + 'static,
F: Future<Output = T> + 'static,
T: Send + 'static,
{
std::thread::Builder::new()
.spawn(move || {
let mut le = LocalExecutor::new(...);
le.init();
le.run(async move { Ok(fut_gen().await) })
})
}

其中 LocalExecutor 的 run 方法定义如下:

1
pub fn run<T>(&self, future: impl Future<Output = T>) -> T { ... }

可以看到,这跟其他运行时的 block_on 函数非常相似。实际上,它们确实都是在当前线程里不停 Poll 来驱动异步计算进行。

RawTask

block_on 函数驱动的运行时,提交上来的 Future 实例以 local 变量的方式存储在当前线程的栈上,我们在当前线程里循环阻塞的 poll 这个 Future 实例。这种情况下,我们没有把 Future 提交到什么任务队列中,也就不需要额外的存储逻辑。

上面介绍的 thread per task 运行时只是把 block_on 放到多个 thread 里,每个 thread 拿住唯一的 Future 实例不停地 poll 获取结果。可以说,thread per task 运行时针对每个 task spawn 出来的线程的栈,就是存储该 Future 实例的地方。

但是,对于 tokio 这样有任务队列的运行时来说,情况就有所不同。这时调度器需要考虑如何存储未执行或执行后返回 Pending 的作业,以等待空闲线程取出执行。

实际上,tokio 仿照标准库用 RawWaker 封装 Waker 的方法,设计了一个 RawTask 结构用以存储待执行的 Future 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#[derive(Clone)]
pub(crate) struct RawTask {
ptr: NonNull<Header>,
}

#[repr(C)]
pub(crate) struct Header {
pub(super) state: State,
pub(super) queue_next: UnsafeCell<Option<NonNull<Header>>>,
pub(super) vtable: &'static Vtable,
pub(super) owner_id: UnsafeCell<Option<NonZeroU64>>,
}

pub(super) struct Vtable {
pub(super) poll: unsafe fn(NonNull<Header>),
pub(super) schedule: unsafe fn(NonNull<Header>),
pub(super) dealloc: unsafe fn(NonNull<Header>),
pub(super) try_read_output: unsafe fn(NonNull<Header>, *mut (), &Waker),
pub(super) drop_join_handle_slow: unsafe fn(NonNull<Header>),
pub(super) drop_abort_handle: unsafe fn(NonNull<Header>),
pub(super) shutdown: unsafe fn(NonNull<Header>),
pub(super) trailer_offset: usize,
pub(super) scheduler_offset: usize,
pub(super) id_offset: usize,
}

在 tokio multi-thread 运行时的 spawn 方法调用时,底层会依次调用方法构造 RawTask 实例:

  • src/runtime/scheduler/multi_thread/handle.rs/Handle::spawn
  • src/runtime/scheduler/multi_thread/handle.rs/Handle::bind_new_task
  • src/runtime/task/list.rs/OwnedTasks::bind
  • src/runtime/task/mod.rs/new_task
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// task::new_task
let raw = RawTask::new::<T, S>(fut, scheduler, id);

// RawTask::new
let ptr = Box::into_raw(Cell::<_, S>::new(fut, scheduler, State::new(), id));
let ptr = unsafe { NonNull::new_unchecked(ptr as *mut Header) };
RawTask { ptr }

// Cell::new
let vtable = raw::vtable::<T, S>();
let ptr = Box::new(Cell {
header: new_header(
state,
vtable,
),
core: Core {
scheduler,
stage: CoreStage {
stage: UnsafeCell::new(Stage::Running(fut)),
},
task_id,
},
trailer: Trailer::new(),
});

概括地说,就是把 Future 计算作业和调度器 scheduler 都塞到 state 里,存一个 void* 指针指向到这一整坨内容,连同取回这个状态时可以如何 poll/schedule 的 VTable 也在里面。

最后,这个 RawTask 被塞到任务队列里等待被取出后 poll 运行:

1
2
// OwnedTasks::bind
lock.list.push_front(task);

其他运行时,包括 smolyatp 乃至以上的 glommio 在向 local 任务队列 spawn 作业时,都有类似的逻辑。在相应代码库里搜索 RawTask 结构就可以了解相应的逻辑。

Future 执行

如果你分别用过 tokio 的 multi-thread 运行时和 current-thread 运行时,你就会发现前者 spawn 作业后,不用 block_on 以某种形式被调用就会自动运行,而后者 spawn 出来的作业,如果你不用 block_on 驱动,那么作业永远也不会执行。

这是因为,单纯的 spawn 部分只完成了把 RawTask 塞入到任务队列中的动作,从任务队列中取出任务执行还需要另一端逻辑来完成。

显式的 block_on 肯定是一种办法,而 tokio 的 multi-thread 运行时之所以能够“自动”运行,是因为它启动了一个多线程的线程池,线程池里的线程默认的工作就是不断从任务队列里取出待运行作业执行。

其中,Launch::launch 是初始化代码的关键节点。它会逐步调用到 Spawner::spawn_taskSpawner::spawn_thread 方法,最终启动一个新线程执行 move || run(worker) 闭包的逻辑。

run 函数在新线程里开始执行后,会逐步执行到一个 while !core.is_shutdown 的循环。这个循环的工作就是在 Runtime 被 shutdown 前不断获取任务队列中的作业驱动运行。

RawTask 重新调度

再次以 tokio 为例,如果第一次 poll Future 返回 Pending 结果,那么 Future 需要保存传入 poll 的 Context 实例,从而在可以继续运行作业时将 RawTask 再次提交到调度器上。

前面我们写到 RawTask 的 VTable 里有 poll 和 schedule 方法,这两个方法就是为了这个目的设计的。

其中,RawTask 的 poll 是由上一节提到的 run 函数驱动的:

  • src/runtime/scheduler/multi_thread/worker.rs/run
  • src/runtime/scheduler/multi_thread/worker.rs/Context::run
  • src/runtime/scheduler/multi_thread/worker.rs/Context::run_task
  • src/runtime/task/mod.rs/LocalNotified::run
1
2
3
4
5
6
7
8
9
10
11
12
// LocalNotified::run
let raw: RawTask = self.task.raw;
raw.poll();

// Harness::poll
self.poll_inner()

// Harness::poll_inner
let header_ptr = self.header_ptr();
let waker_ref = waker_ref::<S>(&header_ptr);
let cx = Context::from_waker(&waker_ref);
let res = poll_future(self.core(), cx);

raw.poll 调用的是 VTable 里的 poll 函数指针,实际就是 Harness::poll 方法。该方法从 RawTask 的 Header 里构造出 Context 并以此为参数调用 Future 的 poll 方法。这个 Context 当中的 Waker 实现是:

1
2
3
4
5
6
7
8
9
10
unsafe fn wake_by_ref(ptr: *const ()) {
let ptr = NonNull::new_unchecked(ptr as *mut Header);
let raw = RawTask::from_raw(ptr);
raw.wake_by_ref();
}

// RawTask::wake_by_ref
pub(super) fn wake_by_ref(&self) {
self.schedule();
}

即将当前 RawTask 重新提交到调度器的任务队列上。

Async IO

上面几节基本把 Rust Async Runtime 设计当中调度器部分的逻辑介绍完了。但是在这些讨论中缺少一个关键的角色,即真正把被调度作业挂起的行为。

在 Simplest Runtime 的案例里,我们简单的用 thread 的 park 方法把当前线程挂起,并在另一个线程上调用 unpark 恢复线程执行,这只是为了演示作业挂起和取回的过程。现实世界里,真正需要挂起计算的通常是两种情况:

  1. 资源暂时不可用,典型的是 IO 操作需要等待。
  2. 定时器逻辑尚未到达触发时间点。

其中 Async IO 是异步并发实际产生性能提升的的情况。无怪乎 Rust 的 Async 工作组成立的目的就是“关注 Async IO 的设计和实现”。

This working group is focused around implementation/design of the “foundations” for Async I/O.

本节以 smol 运行时的 async-io 子模块为例,介绍 Async IO 的挂起和唤醒逻辑。值得注意的是,Async IO 并不是每个运行时都必须实现的内容,例如 smol 的 async-executor 和 tikv 的 yatp 都可以调度 Future 计算,但是它们本身并不提供 Async IO 的支持。

不过,如果在此基础上,把 async-io 库的 Async IO 结构跟这两个运行时结合起来,在传给运行时 spawn 的 async 块中使用 async-io 的结构并调用 .await 读写数据,实际上就能实现 Async IO 的能力。但是 tokio 的 Async IO 实现上并不支持这种结合,我们稍后会讨论其原因。

async-io 库的代码非常简单,它的异步运行时核心是 Reactor 结构。这个名字对于熟悉 Netty 等网络库的读者应该不陌生,中文通常翻译成“反应堆”。async-io 的反应堆保存了所有等待 IO 回应的任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
pub(crate) struct Reactor {
pub(crate) poller: Poller,
/// Registered sources.
sources: Mutex<Slab<Arc<Source>>>,
events: Mutex<Events>,
}

/// A registered source of I/O events.
pub(crate) struct Source {
/// This source's registration into the reactor.
registration: Registration,
/// The key of this source obtained during registration.
key: usize,
/// Inner state with registered wakers.
state: Mutex<[Direction; 2]>,
}

pub enum Registration {
Fd(RawFd),
Signal(Signal),
Process(Child),
}

/// A read or write direction.
struct Direction {
waker: Option<Waker>,
wakers: Slab<Option<Waker>>,
}

impl Direction {
fn drain_into(&mut self, dst: &mut Vec<Waker>) {
if let Some(w) = self.waker.take() {
dst.push(w);
}
for (_, opt) in self.wakers.iter_mut() {
if let Some(w) = opt.take() {
dst.push(w);
}
}
}
}

然后,一旦你使用了 async-io 库,它就会全局启动一个 “async-io” 线程,不断地遍历反应堆,处理其上等待 IO 事件的任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
pub(crate) fn init() {
let _ = unparker();
}

fn unparker() -> &'static parking::Unparker {
thread::Builder::new()
.name("async-io".to_string())
.spawn(move || main_loop(parker))
.expect("cannot spawn async-io thread");
}

fn main_loop(parker: parking::Parker) {
loop {
reactor_lock.react(None).ok();
}
}

impl ReactorLock<'_> {
pub(crate) fn react(&mut self, timeout: Option<Duration>) -> io::Result<()> {
let mut wakers = Vec::new();
self.events.clear();
let res = match self.reactor.poller.wait(&mut self.events, timeout) {
Ok(_) => {
let sources = self.reactor.sources.lock().unwrap();
for ev in self.events.iter() {
if let Some(source) = sources.get(ev.key) {
let mut state = source.state.lock().unwrap();
for &(dir, emitted) in &[(WRITE, ev.writable), (READ, ev.readable)] {
if emitted {
state[dir].tick = tick;
state[dir].drain_into(&mut wakers);
}
}
}
}
}
}
for waker in wakers {
panic::catch_unwind(|| waker.wake()).ok();
}
res
}
}

只要有等待 IO 事件的任务加入到了 reactor.sources 上,出现 IO 事件时,其 Waker 就会被唤醒。

至于 IO 任务的挂起,则是在 poll_read 遇见 WouldBlock 错误时,调用 poll_readable 方法,注册在当前的 source 上。而当前 IO 的 source 则是在创建时就已经注册在 Reactor 的 sources 集合里:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Async<T>
pub fn new_nonblocking(io: T) -> io::Result<Async<T>> {
let registration = unsafe { Registration::new(io.as_fd()) };
Ok(Async {
source: Reactor::get().insert_io(registration)?,
io: Some(io),
})
}

// Source
fn poll_ready(&self, dir: usize, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
state[dir].waker = Some(cx.waker().clone());
}

顺带一提,async-io 库也定义了一个 block_on 函数,且这就是 smol 运行时实际用到 block_on 函数。它的实现同样是用 thread park 和 unpark 阻塞当前线程,并把 unpark 闭包作为 Context Waker 传到 poll 方法里。

接下来简单说明 tokio Async IO 的实现,以及为什么它不能直接和其他运行时结合使用。

tokio 的 Async IO 跟 async-io 的设计有两个关键不同:

第一,不同于 async-io 把 Waker 存在反应堆上,tokio 定义了一个 ScheduledIo 结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub(crate) struct ScheduledIo {
pub(super) linked_list_pointers: UnsafeCell<linked_list::Pointers<Self>>,
readiness: AtomicUsize,
waiters: Mutex<Waiters>,
}

type WaitList = LinkedList<Waiter, <Waiter as linked_list::Link>::Target>;
#[derive(Debug, Default)]
struct Waiters {
/// List of all current waiters.
list: WaitList,
/// Waker used for AsyncRead.
reader: Option<Waker>,
/// Waker used for AsyncWrite.
writer: Option<Waker>,
}

第二,它把这个 ScheduledIO 结构,仿照 RawTask 的方法,只存一个指针,且这个指针是以 u64 的形式作为 mio 中 IO 事件的 Poller 需要的 token 注册的:

1
2
3
4
5
6
7
8
9
10
pub(crate) fn token(&self) -> mio::Token {
mio::Token(self as *const _ as usize)
}

pub(super) fn add_source(...) -> io::Result<Arc<ScheduledIo>> {
let scheduled_io = self.registrations.allocate(&mut self.synced.lock())?;
let token = scheduled_io.token();
self.registry.register(source, token, interest.to_mio())?;
Ok(scheduled_io)
}

这个指针在事件循环驱动时被强转回来:

1
2
3
4
5
6
7
8
9
10
11
12
fn turn(...) {
let events = &mut self.events;
self.poll.poll(events, max_wait);
for event in events.iter() {
let token = event.token();
let ready = Ready::from_mio(event);
let ptr: *const ScheduledIo = token.0 as *const _;
let io: &ScheduledIo = unsafe { &*ptr };
io.set_readiness(Tick::Set(self.tick), |curr| curr | ready);
io.wake(ready);
}
}

此外,不同于 async-io 会启动一个 “async-io” 线程拉取 IO 事件,tokio 的 Async IO 使用的 ScheduledIO 依赖于 tokio Runtime 当中的调度器。且 IO Driver 的主逻辑 turn 函数依赖 tokio 的 Launch::launchblock_on 调用 IO Driver 的 park 系列方法驱动。这就是 tokio 提供的 IO 原语无法应用于其他异步运行时的原因。

相反,类似 tokio::sync::mpsc 这样只是使用传入的 Context 而不依赖 tokio Runtime 制造 Context 的库,就可以在其他运行时里正常使用。

Timer

最后,快速介绍 Async 运行时里 Timer 的实现。理解了 Async IO 的实现,Timer 的实现就非常自然。

同样,Timer 被创建时会被存在某个地方。async-io 实际上也支持 Timer 接口,创造出来的 Timer 同样被存在 Reactor 上。对于 tokio 运行时,则是构造一个 TimerShared 结构存到 Wheel 里。

对于 async-io 来说,驱动的逻辑是在每次反应堆的 react 接口被调用时,调用 process_timers 方法把已到时间的 Timer 的 Waker 加入到待处理 Waker 集合中。除了 IO 事件发生可以触发 react 调用,如果 “async-io” 线程发现还有注册的 Timer 待触发,那么它会在最近一个 Timer 触发的时间中断阻塞等待 IO 事件,强行再次处理一次 Timer 集合:

1
2
3
4
5
6
7
8
9
pub(crate) fn react(&mut self, timeout: Option<Duration>) -> io::Result<()> {
let next_timer = self.reactor.process_timers(&mut wakers);
let timeout = match (next_timer, timeout) {
(None, None) => None,
(Some(t), None) | (None, Some(t)) => Some(t),
(Some(a), Some(b)) => Some(a.min(b)),
};
let res = match self.reactor.poller.wait(&mut self.events, timeout) { ... }
}

这样,只要有 Timer 待触发,那么它一定会在预计时间点后的某个时间被唤醒,而不会永远等待。

对于 tokio 的情形,情况要稍微复杂一些。以 Sleep 为例,构造 Sleep 时会尝试从 tokio Runtime 当前的 Context 里取得调度器:

1
2
3
4
5
6
pub(crate) fn new_timeout(...) -> Sleep {
let handle = scheduler::Handle::current();
let entry = TimerEntry::new(&handle, deadline);
let inner = Inner {};
Sleep { inner, entry }
}

这里的 TimeEntry 对应到 Async IO 里的 ScheduledIO 结构。需要挂起任务时,调用 self.driver().reregister(...) 方法注册到 Timer 的 Wheel 里。跟 Async IO 依赖 Launch::launchblock_on 调用 IO Driver 的 park 方法驱动一样,Timer Wheel 也有一个 Driver 会在 IO Driver park 前被调用。

具体细节不再展开,可以按照这里提到的代码块和接口自行查找阅读理解。唯一值得一提的是,通常 Timer 的存储是用优先级队列或者叫小顶堆来构造,这样从数据结构里取出来的第一个 Timer 就是应当最早被触发的那一个。

结语

以上就是 Rust Async Runtime 实现的核心内容。概括地说,Async Runtime 包括两个核心角色,加上串联这两个角色的媒介:

第一个核心角色是调度器,即 tokio 里的 Runtime 或 glommio 的 LocalExecutor 等。它负责取得注册到调度器上的 Future 并调用 poll 方法触发计算。注册的方法通常是 block_on 函数或 spawn 方法,如果支持后者,则需要额外设计某种存储 RawTask 的结构。

第二个核心角色是驱动器,即 Async IO 的 Driver 或 Timer 的 Driver 等,也可以对应到 async-io 库的 “async-io” 线程。它负责监听外界信息(资源是否可用,时间过去多久等),并在外界信息显示被挂起的任务可以继续运行时,恢复任务运行。

驱动器和调度器沟通的媒介,就是经由 poll 接口传递的 Context 及其中的 Waker 实例。调度器构造出 Waker 实例,驱动器存储 Waker 并在任务可以继续执行时,调用 Waker 的 wake 方法恢复任务运行。恢复运行的常见实现,要么是解除 block_on 线程的阻塞,重新 poll 一遍,要么是把任务以 RawTask 的形式提交到调度器里,等待调度器取出作业运行。

需要说明的是,本文介绍的所有概念和实现方式,只有 Future API 和 Waker API 是标准库里的接口。其他设计和实现都是目前 Rust 社群探索出来的实践,并不是实现异步运行时的要求。

实际上,上面这些实现里至少介绍了几种不同的异步运行时风格:

  • block_on vs. spawn
  • thread per task vs. RawTask + TaskQueue
  • Async IO 和 Timer 的多种实现形式

通往罗马的路不止一条。

最后,关于并发编程的实践,我想分享以下几点:

  1. 尽量写串行代码。并发不是银弹,实际上它会增加代码复杂性。我们聊了这么多只是为了应对固有复杂性。现实编程当中,能不写就不写。
  2. Timer 必须并发,IO 几乎必须并发,独立的 Worker 需要并发跑主循环。这种情况下,绑专门的线程(池)运行并发逻辑;也就是说,不要 #[tokio::main] 全局都在同一个大池子里竞争。这个时候,不同线程组之间,必要时用 channel 传递状态。
  3. 并行计算的情形,用自动并行库(rayon)并原地等待。

参考阅读

Bonus: async/await

上面讨论中省略了 Async Rust 的一个重要组成部分,那就是 asyncawait 语法糖到底是怎么展开的。解释这个问题涉及到 Rust 编译器解语法糖的细节,这里展开讨论内容就实在太长了,而且 Rust 编译器本身的实现一直在改。

大致上,async 块会被展开成一个接受 Context 参数的闭包,回想 Future trait 唯一定义的 poll 方法也接受一个 Context 参数,async 块转成 Future 实例的过程应该不难想象。

await 的展开比较复杂,当前实现里,它会被展开成一段带 yield 的 Coroutine 代码,这是一个能保存当前运行状态的结构。例如,async { foo().await } 大概会被展开成:

1
2
3
4
5
6
7
8
9
10
11
12
match ::std::future::IntoFuture::into_future(foo()) {
mut __awaitee => loop {
match unsafe { ::std::future::Future::poll(
<::std::pin::Pin>::new_unchecked(&mut __awaitee),
::std::future::get_context(task_context),
)} {
::std::task::Poll::Ready(result) => break result,
::std::task::Poll::Pending => {}
}
task_context = yield ();
}
}

这些实现都还不是 stable 的,相关资料如下,想了解的读者可以进一步琢磨。

Rust 与 Java 程序的异步接口互操作

作者 tison
2023年7月30日 08:00

许多语言的高性能程序库都是建立在 C/C++ 的核心实现上的。

例如,著名 Python 科学计算库 Pandas 和 Numpy 的核心是 C++ 实现的,RocksDB 的 Java 接口是对底层 C++ 接口的封装。

Rust 语言的基本目标之一就是替代 C++ 在这些领域的位置,为开发者提供 Rust 具备的安全性和可组合性优势。

Apache OpenDAL (incubating) 是 Databend 工程师 Xuanwo 开发的一个 Rust 语言实现的开放数据访问层。它的核心设计支持通过相同的对象存储 API 访问不同的存储服务(Service),并提供可扩展的中间件(Layer)来支持通用的请求重试、限流和指标上报功能。目前,包括 Databend / RisingWave / GreptimeDB / mozilla sccache 在内的多个软件都选用 OpenDAL 作为其存储访问接口。

OpenDAL 架构概念图

在 Rust 核心实现的基础上,OpenDAL 提供了 Java / Python / Node.js 等不同语言的 API 绑定(Binding),以支持更广泛的生态利用 OpenDAL 已经完成的工作。例如,使用 Python 绑定,诸多大模型应用库能够在不同云厂商的对象存储服务间无缝迁移,支持用户使用任意对象存储服务。而在开发期间,则可以用内存或文件实现来模拟测试相同 API 的语义。

要在 OpenDAL 实现一个特定语言的 API 绑定,涉及到功能实现、程序库打包和发布等多个环节。本文从功能实现的角度出发,以 Java 绑定为例,讨论 OpenDAL 如何在社群力量的支持下实现 opendal-java 库。同时,重点剖析行内首个完整的 Java ↔ Rust 异步接口互操作的最佳实践。

跨语言互操作的基本知识

我的本科毕业论文《多计算机语⾔原理及实现机制分析之初探》当中讨论了三种跨语言互操作的方法:外部函数接口(FFI)、进程间通信(IPC)和多语言运行时。

最常见的是基于 FFI 的方案,即通过一套语言无关的函数调用约定,完成不同语言之间的通信。例如,opendal-java 就是使用 Java 的 FFI 方案 JNI 来完成 Java 和 Rust 之间的互操作的。CPython、Ruby 和 Haskell 等语言实现,则是通过 libffi 来完成和 Native 函数的互操作。

可以看到,FFI 方案基本都是实现了本语言与 Native 函数即遵循 C ABI 的函数之间的互操作,要想使用这样的方案实现 Java 程序调用 CPython 函数是不可能的。这不仅仅是没有人为 Java 和 CPython 之间定义一套调用规则的原因,还有只有 Native 函数才不需要运行时的缘故。要想调用一个 Java 函数,或是一个 CPython 函数,都必须先启动一个对应语言的运行时(JRE 或 CPython 解释器)。如果每次调用都启动一个新的运行时实例,那么这个性能损耗将彻底疯狂,而如果常驻一个目标运行时的进程实例,那么更加成熟的解决方案是进程间通信。

说进程间通信或 IPC 可能还有很多人不知道是什么,举一个例子就很容易理解了:Protobuf + gRPC 的解决方案就是典型的 IPC 方案。

如果说 FFI 是定义了一套语言无关的 Native 函数调用约定,那么 IPC 就是定义了一套语言无关进程接口调用约定。在 gRPC 之外,Apache Thrift / Apache Avro RPC / Apache Arrow Flight RPC 也都定义了各自的语言无关的进程接口调用约定,一般称为接口描述语言(IDL)。

这种方式下,开发者需要首先使用 IDL 定义好想要进行互操作的接口,随后使用对应方案的编译器产生调用方或被调用方语言的数据结构定义和接口存根(stub)对象,接着实现接口逻辑并在进程启动时暴露访问端口。实际调用时,调用方将接口访问及其参数结构编码为字节流,发送到接收方端口,接收方解码请求及其参数,完成请求后回传编码后的结果。

显而易见,IPC 的方式比起 FFI 的方式多了大约两轮数据编解码,加上一个来回网络字节传输的开销。

最后一种跨语言互操作的方案是多语言运行时,这个词汇可能又很陌生。同样举一个实例:JVM 就是一个跨语言运行时。

JVM 上面首先可以运行 Java 语言。然后,它可以运行 Scala / Groovy / Kotlin 等 JVM 族的语言。到这里,JVM 已经可以实现定义上的跨语言互操作了,因为 Java 和后面几个语音确实不是同一个编程语言。进一步地,JVM 上可以运行 Clojure 语言,这意味着 JVM 支持 Java 和 Lisp 之间的互操作。当然,Lisp 比较小众,所以最后我给出百分百令人信服的例子:在 JVM 上可以用 Jython 和 JRuby 实现 Java 和 Python 或 Ruby 的互操作,甚至实现 Python 和 Ruby 的互操作。虽然 Jython 项目凉凉了,但是 JRuby 仍然有很多下游使用,例如 HBase 的 Shell 是 JRuby 实现的,ELK 软件栈中的 Logstash 也是 JRuby 实现的。

此外,在多语言运行时的理论先锋 GraalVM 和 Truffle Framework 的支持下,GraalPy / TruffleRuby / FastR / Sulong (LLVM bitcode) 等等方案接连出现并活跃发展至今。这也是我在毕业论文中重点讨论和研究的对象。

OpenDAL 的多语言 API 绑定最终选择了基于 FFI 的方案。

首先,OpenDAL 根本不启动进程,它被设计为程序直接调用的软件库,所以 IPC 方案从模型上就是不适合的,更不用说调用一个基本的数据访问 API 不应该有多余的网络开销。不过,由于 Golang 自闭的跨语言生态和极力推崇 RPC 的哲学,OpenDAL 支持 Golang 调用的方式可能真的得做一个 service 然后暴露出 RPC 接口。

而多语言运行时的方案,应该说目前还没有支持 Java 和 Rust 或 Native 函数互操作的多语言运行时方案。最接近的是 GraalVM 上的 Sulong 运行时,但是它和它所依赖的 GraalVM 都还不算成熟甚至还未大规模生产使用,且 Sulong 支持的是执行 LLVM bitcode 代码,采用这个方案,就要解决 Rust ↔ LLVM bitcode ↔ Java 三方的沟通和版本适配问题。一言以蔽之,这个方案技术上就很难实现。

opendal-java 的实现

Java 通过 JNI 约定调用 C ABI 函数的一般实现流程如下:

  1. Java 侧定义一个 native 方法;
1
2
3
4
package org.apache.opendal;
public class BlockingOperator extends NativeObject {
private static native long constructor(String schema, Map<String, String> map);
}
  1. C ABI 侧定义一个符合方法编码规则的函数,这里以 opendal-java 中的定义为例;
1
2
3
4
5
6
7
8
9
#[no_mangle]
pub extern "system" fn Java_org_apache_opendal_BlockingOperator_constructor(
mut env: JNIEnv,
_: JClass,
scheme: JString,
map: JObject,
) -> jlong {
// ...
}
  1. Java 程序启动时,调用 System.loadLibrary(libname)System.load(filename) 方法加载 native 库,后续对 native 方法的调用便会转为在 native 库中查找经过编码后的对应 native 函数的调用。

知道了基本的方法映射模式,我们就可以分点来讨论 opendal-java 中的设计要点和技术难点了。

Native Object

从简单的不涉及异步接口互操作的 Blocking Operator 开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class BlockingOperator extends NativeObject {
// ...

public BlockingOperator(String schema, Map<String, String> map) {
super(constructor(schema, map));
}

public String read(String path) {
return read(nativeHandle, path);
}

public Metadata stat(String path) {
return new Metadata(stat(nativeHandle, path));
}

@Override
protected native void disposeInternal(long handle);
private static native long constructor(String schema, Map<String, String> map);
private static native String read(long nativeHandle, String path);
private static native long stat(long nativeHandle, String path);
}

public class Metadata extends NativeObject {
// ...

protected Metadata(long nativeHandle) {
super(nativeHandle);
}
}

public abstract class NativeObject implements AutoCloseable {
// ...

protected final long nativeHandle;

protected NativeObject(long nativeHandle) {
this.nativeHandle = nativeHandle;
}

@Override
public void close() {
disposeInternal(nativeHandle);
}

protected abstract void disposeInternal(long handle);
}

这个代码片段介绍了 Java 侧的主要映射策略:

  1. 每个对应到 Rust 侧结构的类都继承自 NativeObject 类,它持有一个 nativeHandle 字段,指示 Rust 侧对应结构的指针。
  2. 这个指针通过 constructor native 方法获得,通过 disposeInternal native 方法释放。
  3. 每个方法,例如上面的 read 方法,在内部都会被转成 methodName(nativeHandle, args..) 的 native 方法调用,前面可能有一些必要的 marshalling 工作。
  4. 每个返回 Rust 结构的方法,例如上面的 stat 方法,其 native 方法返回对应结构指针的整数,在 Java 侧方法返回前包装成继承自 NativeObject 的类。

NativeObject 包括了一段动态库加载的 static 逻辑,这是一个独立且复杂的话题,这里不做展开。

对应到 Rust 侧,native 方法实现的模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#[no_mangle]
pub extern "system" fn Java_org_apache_opendal_BlockingOperator_constructor(
mut env: JNIEnv,
_: JClass,
scheme: JString,
map: JObject,
) -> jlong {
intern_constructor(&mut env, scheme, map).unwrap_or_else(|e| {
e.throw(&mut env);
0
})
}

fn intern_constructor(env: &mut JNIEnv, scheme: JString, map: JObject) -> Result<jlong> {
let scheme = Scheme::from_str(env.get_string(&scheme)?.to_str()?)?;
let map = jmap_to_hashmap(env, &map)?;
let op = Operator::via_map(scheme, map)?;
Ok(Box::into_raw(Box::new(op.blocking())) as jlong)
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_BlockingOperator_disposeInternal(
_: JNIEnv,
_: JClass,
op: *mut BlockingOperator,
) {
drop(Box::from_raw(op));
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_BlockingOperator_read(
mut env: JNIEnv,
_: JClass,
op: *mut BlockingOperator,
path: JString,
) -> jstring {
intern_read(&mut env, &mut *op, path).unwrap_or_else(|e| {
e.throw(&mut env);
JObject::null().into_raw()
})
}

fn intern_read(env: &mut JNIEnv, op: &mut BlockingOperator, path: JString) -> Result<jstring> {
let path = env.get_string(&path)?;
let content = String::from_utf8(op.read(path.to_str()?)?)?;
Ok(env.new_string(content)?.into_raw())
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_BlockingOperator_stat(
mut env: JNIEnv,
_: JClass,
op: *mut BlockingOperator,
path: JString,
) -> jlong {
intern_stat(&mut env, &mut *op, path).unwrap_or_else(|e| {
e.throw(&mut env);
0
})
}

fn intern_stat(env: &mut JNIEnv, op: &mut BlockingOperator, path: JString) -> Result<jlong> {
let path = env.get_string(&path)?;
let metadata = op.stat(path.to_str()?)?;
Ok(Box::into_raw(Box::new(metadata)) as jlong)
}

这里有三个要点。

第一,虽然 Rust 的 FFI 理论上可以直接对接 JNI 的标准,但是我还是使用了 jni-rs 库来简化开发。这个库的质量很不错,其主要工作是在 FFI 接口上封装了一套 JNI 领域模型的 Rust 结构。例如 JMap 这样的结构在 JNI 里是不存在的,JString 提供的接口也非常方便。注意 String 在这个传递过程中是有可能产生 marshalling 开销的。

第二,每个 JNI 接口函数都实现为调用对应的 intern 函数,然后用一段 unwrap_or_else(|e| {e.throw}) 的模板处理可能的错误。这是因为 JNI 的接口不能返回 Result 类型,所以做了一个错误处理的集中抽象。具体设计实现下一段会谈,这里主要说明的是可以最大程度的避免 unwrap 或对等方法的调用,把错误传递到 Java 侧用 Exception 来处理,而不是 Rust 侧 panic 即等价与 C++ core dump 来处理失败。后者显然是所有 Java 用户都不想处理的问题,也无法在 Java 侧捕捉处理。

第三,可以注意下如何返回 Rust 结构的指针,以及 disposeInternal 时如何释放指针。这是 Rust 内存安全的边界,理解这里面的逻辑对编写内存安全的 Rust FFI 有很大的帮助。

这里有一个潜在的优化点:Metadata 其实是个记录结构(record),如果能做好 marshalling 对应,可以直接编码返回,这样 Java 拿到的就是一个完全自己管理生命周期的数据对象,后续也不用走 JNI 去访问 Metadata 的数据。

错误处理

opendal-java 的一个创新价值是实现了一套 Rust ↔ Java 的错误处理范式。

在 Rust 侧,我们在 intern 系列方法里完成调用 Rust 函数的工作,回传 Result 到外层 FFI 接口处理。如果 Result 是错误结果,那么会走一个 throw 的过程抛出异常。这个过程会从 Rust 侧的错误提取出错误信息和错误码,然后构造 Java 侧的异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
pub(crate) struct Error {
inner: opendal::Error,
}

impl Error {
pub(crate) fn throw(&self, env: &mut JNIEnv) {
if let Err(err) = self.do_throw(env) {
env.fatal_error(err.to_string());
}
}

fn do_throw(&self, env: &mut JNIEnv) -> jni::errors::Result<()> {
let exception = self.to_exception(env)?;
env.throw(exception)
}

pub(crate) fn to_exception<'local>(
&self,
env: &mut JNIEnv<'local>,
) -> jni::errors::Result<JThrowable<'local>> {
let class = env.find_class("org/apache/opendal/OpenDALException")?;
let code = env.new_string(...);
let message = env.new_string(self.inner.to_string())?;
let exception = env.new_object(...);
Ok(JThrowable::from(exception))
}
}

对应 Java 侧 OpenDALException 定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class OpenDALException extends RuntimeException {
private final Code code;
public OpenDALException(String code, String message) {
this(Code.valueOf(code), message);
}
public OpenDALException(Code code, String message) {
super(message);
this.code = code;
}
public Code getCode() {
return code;
}
public enum Code {
// ...
}
}

运用这个范式,我把整个绑定 Rust 侧的 panic 调用控制在了 10 个以内,且全部是在异步接口互操作的范畴里的。其中大部分在 Load 和 Unload 的逻辑里,这是整个程序启动和终止的地方。其他的调用在 Rust 侧完成 Futrue 后回调的上下文里。这两者的共同点是:它们都对应不到一个用户控制的 Java 上下文来抛出异常。

异步接口互操作

opendal-java 的另一个创新价值,也是业内首创的方案,是实现了 Rust ↔ Java 异步接口互操作。

opendal-java 的第一版异步接口互操作实现是基于 Global Reference 的。但这个方案有一个缺陷,那就是 Global Reference 上限是 65535 个。所谓基于 Global Reference 的方案,就是把需要异步完成的 CompletableFuture 对象注册为 JNI 的 Global Reference 并跨线程共享,这意味着整个程序的 API 调用并发上限一定不超过 65535 个。

虽然这个数量对于大部分场景已经够用,但是毕竟是个无谓的开销,且 Global Reference 的访问没有经过特别的优化,很难估计重度使用这个特性会带来怎样的不稳定性。

我曾经构思过基于全局 Future Registry 的解决方案,或者演化成一个类似于跨语言 Actor Model (Dispatcher + Actor with Mailbox) 的方案,但是最终都没有成功写出来。

这里面主要的难点是 JNI 调用所必须的 JNIEnv 不是线程安全的。而要想真正实现 Java 调用 Rust 的异步接口,并在 Rust 异步动作完成后回调,而不是原地阻塞等待,调用过程一定会经历从 JNI 调用线程转移到 Rust 的后台异步线程。Global Reference 能够把 Java 对象提升到全局空间,进而跨线程共享,但是这其实也不解决 JNIEnv 不能移动到另一个线程的问题。

opendal-java 的第一版异步接口互操作实现解决了这个问题,其核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static mut RUNTIME: OnceCell<Runtime> = OnceCell::new();
thread_local! {
static ENV: RefCell<Option<*mut jni::sys::JNIEnv>> = RefCell::new(None);
}

#[no_mangle]
pub unsafe extern "system" fn JNI_OnLoad(vm: JavaVM, _: *mut c_void) -> jint {
RUNTIME
.set(
Builder::new_multi_thread()
.worker_threads(num_cpus::get())
.on_thread_start(move || {
ENV.with(|cell| {
let env = vm.attach_current_thread_as_daemon().unwrap();
*cell.borrow_mut() = Some(env.get_raw());
})
})
.build()
.unwrap(),
)
.unwrap();

JNI_VERSION_1_8
}

#[no_mangle]
pub unsafe extern "system" fn JNI_OnUnload(_: JavaVM, _: *mut c_void) {
if let Some(r) = RUNTIME.take() {
r.shutdown_background()
}
}

unsafe fn get_current_env<'local>() -> JNIEnv<'local> {
let env = ENV.with(|cell| *cell.borrow_mut()).unwrap();
JNIEnv::from_raw(env).unwrap()
}

unsafe fn get_global_runtime<'local>() -> &'local Runtime {
RUNTIME.get_unchecked()
}

其中,RUNTIME 的启动、关闭和获取是常规的使用 tokio 异步框架的方式:虽然可能更多人是简单的 #[tokio::main] 解决,但是其实 tokio 底下大概也是这么一个全局共享的 RUNTIME 的实现。

真正值得注意的是 JNI_OnLoad 传进来了一个线程安全的 JavaVM 对象,我们基于它在每个 tokio RUNTIME 的线程里 attach 了一个 JNIEnv 实例。

上面提到,JNIEnv 不是线程安全的,但是我们现在是在每个 tokio 线程池的线程里各自创建了一个本地的 JNIEnv 实例,这些实例在各自的线程里存活,并不跨线程共享。

JNI_OnLoad 方法就是这里破解难点的关键,它在本动态库被加载(通过 System.load 或者 System.loadLibrary 方法)之后被调用,传递当前 JavaVM 实例以供使用。由于运行当前程序的 JavaVM 全局只有一个,它是线程安全的,并且有一个 attach_current_thread_as_daemon 方法可以把当前线程注册到 JVM 上,获取 JNI 操作必须的 JNIEnv 对象。

突破这个问题以后,我们其实完全就不需要用 Global Reference 来传递 CompletableFuture 对象,而是可以实现我设想过的全局 Future Registry 方案了。其主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private enum AsyncRegistry {
INSTANCE;
private final Map<Long, CompletableFuture<?>> registry = new ConcurrentHashMap<>();
private static long requestId() {
final CompletableFuture<?> f = new CompletableFuture<>();
while (true) {
final long requestId = Math.abs(UUID.randomUUID().getLeastSignificantBits());
final CompletableFuture<?> prev = INSTANCE.registry.putIfAbsent(requestId, f);
if (prev == null) {
return requestId;
}
}
}
private static CompletableFuture<?> get(long requestId) {
return INSTANCE.registry.get(requestId);
}
private static <T> CompletableFuture<T> take(long requestId) {
final CompletableFuture<?> f = get(requestId);
if (f != null) {
f.whenComplete((r, e) -> INSTANCE.registry.remove(requestId));
}
return (CompletableFuture<T>) f;
}
}

public class Operator extends NativeObject {
// ...

public CompletableFuture<Metadata> stat(String path) {
final long requestId = stat(nativeHandle, path);
final CompletableFuture<Long> f = AsyncRegistry.take(requestId);
return f.thenApply(Metadata::new);
}

public CompletableFuture<String> read(String path) {
final long requestId = read(nativeHandle, path);
return AsyncRegistry.take(requestId);
}

private static native long stat(long nativeHandle, String path);
private static native long read(long nativeHandle, String path);
}

这次,所有的 native 方法都返回一个 long 值,它是一个从 AsyncRegistry 中获取结果对应的 CompletableFuture 的凭证。

Rust 侧通过 JNI 调用 AsyncRegistry#requestId 方法注册一个 Future 并取得它的凭证,随后这个凭证(整数)被传递到 tokio RUNTIME 创建的后台线程里,完成 API 调用后,通过后台线程的 JNIEnv 调用 AsyncRegistry#get 方法取得 CompletableFuture 对象,调用 CompletableFuture#complete 方法回填结果,或者 CompletableFuture#completeExceptionally 方法回调异常。

其主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
fn request_id(env: &mut JNIEnv) -> Result<jlong> {
Ok(env
.call_static_method(
"org/apache/opendal/Operator$AsyncRegistry",
"requestId",
"()J",
&[],
)?
.j()?)
}

fn get_future<'local>(env: &mut JNIEnv<'local>, id: jlong) -> Result<JObject<'local>> {
Ok(env
.call_static_method(
"org/apache/opendal/Operator$AsyncRegistry",
"get",
"(J)Ljava/util/concurrent/CompletableFuture;",
&[JValue::Long(id)],
)?
.l()?)
}

fn complete_future(id: jlong, result: Result<JValueOwned>) {
let mut env = unsafe { get_current_env() };
let future = get_future(&mut env, id).unwrap();
match result {
Ok(result) => {
let result = make_object(&mut env, result).unwrap();
env.call_method(
future,
"complete",
"(Ljava/lang/Object;)Z",
&[JValue::Object(&result)],
)
.unwrap()
}
Err(err) => {
let exception = err.to_exception(&mut env).unwrap();
env.call_method(
future,
"completeExceptionally",
"(Ljava/lang/Throwable;)Z",
&[JValue::Object(&exception)],
)
.unwrap()
}
};
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_Operator_read(
mut env: JNIEnv,
_: JClass,
op: *mut Operator,
path: JString,
) -> jlong {
intern_read(&mut env, op, path).unwrap_or_else(|e| {
e.throw(&mut env);
0
})
}

fn intern_read(env: &mut JNIEnv, op: *mut Operator, path: JString) -> Result<jlong> {
let op = unsafe { &mut *op };
let id = request_id(env)?;

let path = env.get_string(&path)?.to_str()?.to_string();

unsafe { get_global_runtime() }.spawn(async move {
let result = do_read(op, path).await;
complete_future(id, result.map(JValueOwned::Object))
});

Ok(id)
}

async fn do_read<'local>(op: &mut Operator, path: String) -> Result<JObject<'local>> {
let content = op.read(&path).await?;
let content = String::from_utf8(content)?;

let env = unsafe { get_current_env() };
let result = env.new_string(content)?;
Ok(result.into())
}

fn make_object<'local>(
env: &mut JNIEnv<'local>,
value: JValueOwned<'local>,
) -> Result<JObject<'local>> {
let o = match value {
JValueOwned::Object(o) => o,
JValueOwned::Byte(_) => env.new_object("java/lang/Long", "(B)V", &[value.borrow()])?,
JValueOwned::Char(_) => env.new_object("java/lang/Char", "(C)V", &[value.borrow()])?,
JValueOwned::Short(_) => env.new_object("java/lang/Short", "(S)V", &[value.borrow()])?,
JValueOwned::Int(_) => env.new_object("java/lang/Integer", "(I)V", &[value.borrow()])?,
JValueOwned::Long(_) => env.new_object("java/lang/Long", "(J)V", &[value.borrow()])?,
JValueOwned::Bool(_) => env.new_object("java/lang/Boolean", "(Z)V", &[value.borrow()])?,
JValueOwned::Float(_) => env.new_object("java/lang/Float", "(F)V", &[value.borrow()])?,
JValueOwned::Double(_) => env.new_object("java/lang/Double", "(D)V", &[value.borrow()])?,
JValueOwned::Void => JObject::null(),
};
Ok(o)
}

可以看到,我构建了一个实现 API 接口绑定的模式:

  1. 外层 JNI 映射函数和阻塞接口一样,调用 intern 方法并串接 throw 回调,处理同步阶段可能的异常。这主要来自于 String marshalling 和参数合法性检查的步骤。
  2. intern 方法处理参数映射,从 AsyncRegistry 里取得 Future 的凭证,随后调用 unsafe { get_global_runtime() }.spawn(...) 把 API 请求发送到后台线程处理,并返回 Futrue 凭证。Java 侧的 native 方法返回,取得凭证。
  3. do 方法在后台线程执行,得到结果。该结果由 complete_future 方法处理回调 CompletableFuture 的方法回填结果或异常。

其他的细节可以读源码分析,这里再提一下对异常的处理。

可以看到,只要是在 Java 侧调用 JNI 线程里的异常,我都压在 intern 方法的 Result 里抛出去了。JNI Onload 和 Unload 过程没有用户能处理的线程,tokio RUNTIME 的后台线程调用 complete_future 方法的时候也不在用户能处理的线程上,所以这些地方我都用了 unwrap 来处理错误。一方面是用户根本处理不了,另一方面也是这些调用是可以确保一定成功的,如果不成功,一定是代码写错了或者底层的不变式被破坏了,即使用户可以捕获这些异常,也不可能有合理的处理方式。

当然,如果未来发现其中某些异常可以恢复,可以在 Rust 侧从错误里恢复。技术上,do 方法返回的 err 会被 complete_future 回传到 CompletableFuture 的错误结果里,这也是一种不 panic 的 tokio RUNTIME 中的错误处理方式。

社群驱动的开发方式

虽然当前版本的 opendal-java 主要是我的设计,但是它的第一版并不是我写的。

项目作者 Xuanwo 首先开了 Java 绑定的 Issue-1572 提出需求,随后 @kidylee 很快表达了兴趣。由于我此前尝试过构建基于 TiKV Rust client 的 Java client 绑定,我分享了我做过的尝试。

不过,我没能实现一个符合自己期望的 TiKV Java client 绑定,所以在我想清楚之前,我并没有动力去做一个自己不满意的实现。

但是这个时候 @kidylee 很快做出了第一版 blocking operator 的实现。一个月后,来自 RocketMQ 社群的 @ShadowySpirits 也加入了进来。他想实现异步接口的支持,而这就是我之前没想通所以不愿意动手的卡点。

@ShadowySpirits 很快做了一个基于我放弃的 Global Reference 的解决方案,虽然 Global Reference 有上面我提到过的缺陷,但是他构建的 JNI Onload 方法及其全局线程池共享的方式给了我启发,Thread loacal 共享 JNIEnv 的方案打通了我之前面临的 JNIEnv 不 Sync 的难题,我于是得以实现自己就差最后一个技术难点的基于全局 AsyncRegistry 的解决方案,彻底绕过了 Global Reference 的限制。

功能实现以后,出于没有发布的软件就得不到严肃使用的认知,我着手解决了基本的项目打包和发布逻辑问题(Issue-2313)和发布前的其他功能、测试和文档工作(Issue-2339)。

这些工作完成以后,opendal-java 就正式发布到 Maven 中央资源库了。

昨天 @luky116 上报的另一个问题验证了我对软件发布重要性的认知。他凭着直觉使用 opendal-java 库,马上撞上了一个构建问题。这使得我重新思考了之前打包方式对下游用户的不方便之处,并记录了对应的 Issue 追踪。

我的计划是复刻 rocksdbjni 的发布方法,在不同平台编译动态库,最后合并不同平台编译出来的库到 resources 目录下发布,加载逻辑对应处理好平台架构的命名和发现逻辑。这个同时要修改 NativeObject 里的动态库加载逻辑,Maven 的打包逻辑和 GitHub Actions 的构建和发布逻辑。如果你了解 RocksDB 的打包发布方式,可以参与进来。不过这样的人应该很少,所以如果你感兴趣,也可以订阅这个问题,等我下个月找到时间演示一下解法。

此外,我在绕过 @luky116 遇到的构建问题以后,还发现了 opendal-java 对 OpenDAL features 打包的问题,可能会影响下游用户的使用预期。这个问题是个产品问题,我也记了一个 Issue 来讨论。基本上,用户可以自己打包动态库并指定动态库发现路径,这是最终兜底方案。但是这个方案目前没有直接的文档,只是我这个实现的人心里清楚。而且作为上游,有些 features 是适合一揽子打包出去,提供更好的开箱体验的。

最后,如果你也想体验一下开发 OpenDAL 多语言 API 绑定的过程,可以参与到我做了一半的 C# 绑定上来:

基本的项目框架我已经定好了,后续工作的参考材料也列出来了。如果你有足够的背景,我提供的材料应该已经足够作为直接实现的参考。

C# 绑定相较于 Java 绑定的优势在于它有原生的 C ABI repr 支持,这能减少一部分 marshalling 的开销。但是这些技术使用的人比较少,或者说整个 .NET 技术栈的用户都显著少于 JVM 技术栈,更不用说国内几乎没有 .NET 技术栈的企业,也就没有什么中文材料,所以学习新知识的门槛可能会有一些。

❌
❌