Rust in Everything
基于 async-stream 0.3.6

怎么读这一卷

主线位置:主线的总览。本页把「宏展开」和「运行时」两段各画一遍,再用一段程序把整条主线走一遍。
flowchart TB
  start[从这里开始] --> q{读这一卷为了什么}
  q -->|会用| use1[00 导读]
  use1 --> use2[04 常见坑]
  q -->|读懂实现| all["00 到 06 按顺序,01 和 02 连着读"]

锁定版本

项值
crateasync-stream 0.3.6,async-stream-impl 0.3.6
tagv0.3.6
commitb0b2f22(2024-10-02,「Prepare v0.3.6 release (#112)」)
MSRV1.65(两个 Cargo.toml 的 rust-version)
实验用的依赖tokio 1.54.1(和 tokio 卷相同)、futures 0.3.34、tokio-stream 0.1.19
实验环境M2 Max,rustc 1.99.0

源码链接都用 tag,形如 https://github.com/tokio-rs/async-stream/blob/v0.3.6/async-stream/src/yielder.rs#L37。0.3.6 发布已经两年,仓库的 master 在这之后又合了四个提交,都还没发布:

提交改了什么对本卷的影响
#113修一个 clippy needless_lifetimes 警告无
#120MSRV 提到 1.71,修 CI无
#119syn 升到 3proc-macro 的写法会变,0.3.6 里是 syn 2
#115AsyncStream 里的 async 块包进一个 SyncWrapper,内层 future 不是 Sync 时 AsyncStream 也是 Sync0.3.6 里没有这个包装;AsyncStream::poll_next 末尾实测了 0.3.6 的行为

读 master 会看到 sync_wrapper.rs 这个 0.3.6 没有的文件,行号也对不上。你 cargo add async-stream 拿到的是 0.3.6。

想在本地对照着读:

git clone https://github.com/tokio-rs/async-stream
cd async-stream
git checkout v0.3.6

两个 crate 的分工

async-stream 必须分成两个 crate:proc-macro 只能放在 proc-macro = true 的 crate 里,而这种 crate 不能导出普通的类型和函数。于是改写语法的部分在 async-stream-impl,运行时要用的类型在 async-stream,用户只依赖后者。

文件行数去掉注释和空行作用本卷
async-stream/src/lib.rs24231crate 文档;stream!、try_stream! 两个 macro_rules!;__private 模块外壳
async-stream-impl/src/lib.rs316267proc-macro stream_inner、try_stream_inner;Scrub 改写器;replace_for_await01 宏展开
async-stream/src/yielder.rs9465Sender、Receiver、线程局部的 STORE、Send futureyielder
async-stream/src/async_stream.rs7967AsyncStream:Stream 和 FusedStream 的实现poll_next
async-stream/src/next.rs3225自己写的 StreamExt::next,给 for await 用for await

lib.rs 的 242 行里只有 31 行是代码,其余是 crate 文档和两个宏的示例。整个运行时是 yielder.rs 加 async_stream.rs 一百多行代码。

依赖也很少:async-stream 只依赖 futures-core(Stream trait)和 pin-project-lite(给 AsyncStream 做字段投影);async-stream-impl 依赖 proc-macro2、quote 和打开了 full、visit-mut 两个 feature 的 syn 2。两个 crate 都没有自己的 feature,也没有 cfg 分支,所有平台上是同一份代码。

用户能看到的公开 API 只有两个宏。AsyncStream、pair、next 和两个 proc-macro 都在 __private 模块里,带 #[doc(hidden)],只供宏展开出来的代码调用。

主线

只有一条:yield 怎么从 async 块里把值递出去。它分成两段,每一章开头都会标出本章在哪一段。

宏展开(编译期):

flowchart TB
  A["stream! { ... }"] --> B["macro_rules!:塞进 ($crate)"]
  B --> C["proc-macro stream_inner"]
  C --> D["replace_for_await:for await 换成 #[await_] for"]
  D --> E["Scrub:改写 yield、?、#[await_] for"]
  E --> F["AsyncStream::new(rx, async move { ... })"]

运行时(每次 poll_next):

sequenceDiagram
  participant C as 消费者
  participant S as AsyncStream::poll_next
  participant T as 线程局部 STORE
  participant B as async 块
  C->>S: poll_next(cx)
  S->>S: 栈上放 dst = None
  S->>T: 写入 &mut dst 的地址
  S->>B: poll(cx)
  B->>T: send(v):取出地址,写入 v
  B-->>S: Pending
  S->>T: 恢复旧值
  S-->>C: Ready(Some(v))

下面这段程序在 async 块里和消费者那边都打印,看两边怎么交替执行:

async-stream = "=0.3.6"
futures = "0.3"
tokio = { version = "=1.54.1", features = ["full"] }
use async_stream::stream;
use futures::{pin_mut, StreamExt};

#[tokio::main]
async fn main() {
  let s = stream! {
    println!("  [block] start");
    for i in 0..2 {
      println!("  [block] yield {i}");
      yield i; // 宏展开:改写成 __yield_tx.send(i).await
      println!("  [block] resumed after {i}");
    }
    println!("  [block] end");
  };
  println!("[consumer] stream created, nothing ran yet");
  pin_mut!(s); // AsyncStream 里是一个 async 块,!Unpin,next() 之前要先 pin
  while let Some(v) = s.next().await {
    println!("[consumer] got {v}");
  }
  println!("[consumer] got None");
}

输出(跑 3 次相同):

[consumer] stream created, nothing ran yet
  [block] start
  [block] yield 0
[consumer] got 0
  [block] resumed after 0
  [block] yield 1
[consumer] got 1
  [block] resumed after 1
  [block] end
[consumer] got None

逐段对应:

  • 第一行:stream! 只是构造了一个 AsyncStream,里面的 async 块还没被 poll,一行都没执行。
  • start、yield 0、got 0:第一次 next() 调到 AsyncStream::poll_next,它 poll async 块;块一直跑到 send(0).await,Send::poll 把 0 写进 poll_next 栈上的槽,返回 Pending;poll_next 看到槽里有值,返回 Ready(Some(0))。
  • resumed after 0:第二次 next() 再 poll async 块,它从上次停下的 .await 接着跑。这次 Send::poll 发现值已经交出去了,返回 Ready(()),块继续往下。
  • end、got None:块跑完,poll_next 记下 done,返回 Ready(None)。

async 块里的代码只在消费者 poll 的时候执行,执行到下一个 yield 就停。这就是「惰性」,为什么需要它会把它和通道的写法放在一起比较。

测试与 CI

async-stream/tests/ 下有四个集成测试文件(stream.rs 237 行、try_stream.rs 87 行、for_await.rs、spans_preserved.rs),外加两个小 crate tests/reexporter 和 tests/test-reexport,专门验证宏被别的 crate 重新导出之后还能用。

tests/ui/ 下有 6 个编译失败用例,用 trybuild 比对编译器的完整报错。这个测试只在 stable 上跑:报错的措辞随 rustc 版本变,nightly 上一定对不上。本卷引用这些用例时,都抄进自己的实验工程,用本机的 rustc 1.99.0 重新编译,贴的是本机的报错。

阅读路线

  • 只想会用:读 00 导读和 04 常见坑。读完知道 stream! 和 try_stream! 的区别、? 在两个宏里的行为、为什么要 pin,以及几条常见报错从哪来。
  • 想读懂实现:按顺序读。01 讲宏把代码改写成了什么,02 讲改写出来的 send().await 在运行时做了什么,这两部分要连着读。03 把取舍汇总,06 从零写一个最小的 stream!。

摘自 async-stream 的代码片段一律保留原来的格式(4 空格缩进),方便和原文件对照;本站自己写的示例用 2 空格缩进。宏展开的结果用 cargo expand 实际生成,只摘关键几行。

前置知识

这一卷默认你会写 async Rust,也大致知道 proc-macro 是什么。用到的概念在别的卷里讲过,这里只链过去:

概念在哪里讲本卷用到它的地方
Future::poll、Wakertokio 卷 poll 与唤醒Send::poll 返回 Pending 却不登记 waker,为什么不会挂死
线程局部变量tokio 卷 线程本地上下文tokio 用 CONTEXT 存当前运行时,async-stream 用 STORE 存当前槽的地址
select!tokio 卷 select! 与 join!yield 能写在 select! 的分支里
mpsctokio 卷 mpsc「通道 + spawn」的写法
syn 与 proc-macroserde 卷 派生宏的流水线Scrub 用 syn 的 VisitMut 遍历语法树

目录

部分内容
00 导读本部分;为什么需要它
01 宏展开stream! 外壳与 stream_inner;Scrub 改写 yield 与 ?;for await
02 运行时yielder.rs:线程局部指针;AsyncStream::poll_next
03 设计取舍线程局部指针而不是通道;两个 soundness 洞;和其他写法对比
04 常见坑用报错原文做标题,讲它在宏或运行时的哪一步产生
05 在本站中本站依赖树里的 async-stream 从哪来;哪里可以用
06 动手从零写一个最小的 stream!

小结

  • 全卷锁定 tag v0.3.6(b0b2f22);master 上之后的四个提交(syn 3、MSRV 1.71、Sync 包装等)都没发布,行号以 tag 为准。
  • async-stream-impl 是 proc-macro,负责改写语法;async-stream 放运行时类型和两个 macro_rules! 外壳。用户可见的只有 stream! 和 try_stream!。
  • 主线分两段:宏把 yield v 改写成 __yield_tx.send(v).await;运行时 send 借线程局部指针把值写进 poll_next 栈上的 Option,返回 Pending。
  • async 块只在消费者 poll 时执行,跑到下一个 yield 就停。
  • 只想会用读 00、04;想读懂实现按顺序读,01 和 02 连着读。

💬 关联讨论

发起讨论
加载中...