从零写一个最小 stream!
主线位置:两段都自己走一遍。宏展开一段:
yield v改写成__tx.send(v).await,整块包进MiniStream::new(rx, async move { .. });运行时一段:poll_next在栈上放一个Option<T>,把地址写进线程局部变量,SendFut::poll把值写过去,返回Pending。
flowchart TB
src["stream! { ... yield v; ... }"] --> sc["Scrub:yield 改写"]
sc --> exp["MiniStream::new(rx, async move { '__mini_scope: { .. } })"]
exp --> pn["poll_next:slot = None,STORE = &mut slot"]
pn --> fut["poll async 块"]
fut --> sf["SendFut::poll:写进 slot,Pending"]
sf --> back["恢复 STORE;slot 有值就 Ready(Some(v))"]
本页只讲思路和关键代码。工程是一个三个包的 workspace:mini(运行时,107 行,去掉空行和注释 82 行)、mini-macro(proc-macro,91 行,去掉空行和注释 79 行)、demo(几个跑示例的二进制)。只支持 yield:没有 ?、没有 for await、没有 try_stream!。
# mini-macro/Cargo.toml
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "visit-mut"] }
# mini/Cargo.toml
[dependencies]
futures-core = "0.3"
mini-macro = { path = "../mini-macro" }
# demo/Cargo.toml
[dependencies]
futures = "0.3"
mini = { path = "../mini" }
实际解析到 syn 2.0.119、quote 1.0.47、proc-macro2 1.0.107、futures 0.3.34;demo 用 futures::executor::block_on,不需要 tokio。M2 Max、rustc 1.99.0。
第一步:运行时,先手写展开结果
运行时就是 yielder 和 poll_next 两章的内容,照着写一遍。两端和线程局部变量:
thread_local!(static STORE: Cell<*mut ()> = const { Cell::new(ptr::null_mut()) });
/// 发送端:零大小,对 T 不变(防 #107)。
pub struct Sender<T> {
_p: PhantomData<fn(T) -> T>,
}
/// 接收端:零大小,只用来把 T 钉在 MiniStream 上。
pub struct Receiver<T> {
_p: PhantomData<T>,
}
/// 只许宏调用:两端必须来自同一次 pair,send 只能在这个流的 poll_next 里被 poll。
#[doc(hidden)]
pub unsafe fn pair<T>() -> (Sender<T>, Receiver<T>) {
(Sender { _p: PhantomData }, Receiver { _p: PhantomData })
}
Sender 一开始就写成 PhantomData<fn(T) -> T>,对 T 不变,这是 0.3.6 对 #107 的修法,没理由再踩一遍(取舍与对比)。pair 本身没有不安全的操作,标成 unsafe 是为了表达「调用方要负责的前提」,和 async-stream 一样。
发送的 future:
impl<T> Future for SendFut<T> {
type Output = ();
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<()> {
if self.value.is_none() {
return Poll::Ready(());
}
STORE.with(|cell| {
let slot = cell.get() as *mut Option<T>;
// SAFETY: 宏保证此刻 STORE 指向同一个流的 poll_next 栈上的 Option<T>
match unsafe { slot.as_mut() } {
Some(slot) if slot.is_none() => *slot = self.value.take(),
Some(_) => {}
None => panic!("send polled outside of poll_next"),
}
});
Poll::Pending
}
}
和 Send::poll 逐行对应:值已交出就 Ready;否则写进空槽、返回 Pending;不碰 _cx。impl<T> Unpin for SendFut<T> {} 也照抄,理由一样:从不 pin 住 T。
接收端。async-stream 用 Enter 守卫恢复 STORE,这里写一个只有 Drop 的小结构体,作用相同:
/// drop 时把 STORE 恢复成进入前的值;poll 中途 panic 也会恢复。
struct Restore(*mut ());
impl Drop for Restore {
fn drop(&mut self) {
STORE.with(|cell| cell.set(self.0));
}
}
impl<T, F: Future<Output = ()>> Stream for MiniStream<T, F> {
type Item = T;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<T>> {
// SAFETY: fut 是结构性 pin 的字段,这里不移动它,MiniStream 也没有手写 Unpin
let this = unsafe { self.get_unchecked_mut() };
if this.done {
return Poll::Ready(None);
}
let mut slot: Option<T> = None;
let prev = STORE.with(|cell| cell.replace(&mut slot as *mut Option<T> as *mut ()));
let restore = Restore(prev);
let res = unsafe { Pin::new_unchecked(&mut this.fut) }.poll(cx);
drop(restore);
this.done = res.is_ready();
match slot {
Some(v) => Poll::Ready(Some(v)),
None if this.done => Poll::Ready(None),
None => Poll::Pending,
}
}
}
和 AsyncStream::poll_next 的差别只有一处:async-stream 用 pin-project-lite 生成字段投影,这里不想多一个依赖,手写了 get_unchecked_mut 和 Pin::new_unchecked。这两句 unsafe 成立的前提写在注释里:fut 从不被移出,MiniStream 没有手写 Unpin,所以 async 块是 !Unpin 时 MiniStream 也是 !Unpin。drop(restore) 放在读 slot 之前,保证读槽时 STORE 已经不再指向它。
有了运行时,先不写宏,手写一份「展开结果」:
fn main() {
// 手写「展开结果」:for i in 0..3 { yield i * 10; }
let (mut tx, rx) = unsafe { pair::<u32>() };
let s = MiniStream::new(rx, async move {
for i in 0..3 {
println!(" [block] send {}", i * 10);
tx.send(i * 10).await;
}
println!(" [block] end");
});
let items: Vec<u32> = block_on(s.collect());
println!("{items:?}");
}
[block] send 0
[block] send 10
[block] send 20
[block] end
[0, 10, 20]
运行时这条路通了。这一步先做的好处是:后面宏出了问题,可以确定问题在改写,不在运行时。
第二步:proc-macro,改写 yield
改写器只覆盖三个方法,结构和 Scrub 一样,但每个都更简单:
impl VisitMut for Scrub {
fn visit_expr_mut(&mut self, expr: &mut syn::Expr) {
match expr {
syn::Expr::Yield(y) => {
self.has_yielded = true;
visit_mut::visit_expr_yield_mut(self, y);
let value = match y.expr.take() {
Some(v) => *v,
None => syn::parse_quote!(()),
};
let check = if self.check_scope {
quote! {
#[allow(unreachable_code)]
if false {
break '__mini_scope (loop {});
}
}
} else {
quote! {}
};
*expr = syn::parse_quote! {{ #check __tx.send(#value).await }};
}
// 闭包和 async 块里的 yield 不属于这个流
syn::Expr::Closure(_) | syn::Expr::Async(_) => {}
_ => visit_mut::visit_expr_mut(self, expr),
}
}
// 只处理参数恰好是一个表达式的宏,比如 asynk!(yield 42)
fn visit_macro_mut(&mut self, mac: &mut syn::Macro) {
let name = mac.path.segments.last().map(|s| s.ident.to_string());
if matches!(name.as_deref(), Some("stream" | "stream_unchecked")) {
return;
}
if let Ok(mut inner) = syn::parse2::<syn::Expr>(mac.tokens.clone()) {
self.visit_expr_mut(&mut inner);
mac.tokens = quote!(#inner);
}
}
// 嵌套的 fn、impl 不进去
fn visit_item_mut(&mut self, _item: &mut syn::Item) {}
}
check_scope 是为第三步准备的开关,stream! 打开,stream_unchecked! 关掉。visit_macro_mut 是刻意做小的:async-stream 的 visit_token_stream_impl 逐个 token 扫描,能处理 select! 那种「模式 => 表达式」的参数;这里只在宏参数整体能解析成一个表达式时才改写,tokio::select!、tokio::join! 都不行。但它足以复现 #106:问题的根源就是「进宏调用改写 yield」,不管扫描得多完整。
外壳把语句解析出来、交给 Scrub,再包起来:
fn expand(input: TokenStream, check_scope: bool) -> TokenStream {
let mut stmts = match syn::Block::parse_within.parse(input) {
Ok(stmts) => stmts,
Err(e) => return e.to_compile_error().into(),
};
let mut scrub = Scrub { check_scope, has_yielded: false };
for stmt in &mut stmts {
scrub.visit_stmt_mut(stmt);
}
// 一次都没 yield 时让 T 推断成 ()
let dummy = if scrub.has_yielded {
quote! {}
} else {
quote! { if false { __tx.send(()).await; } }
};
let body = if check_scope {
quote! { '__mini_scope: { #dummy #(#stmts)* } }
} else {
quote! { #dummy #(#stmts)* }
};
quote!({
let (mut __tx, __rx) = unsafe { ::mini::pair() };
::mini::MiniStream::new(__rx, async move { #body })
})
.into()
}
和 async-stream 的外壳比,少了一层:async-stream 的 stream! 是 macro_rules!,把 $crate 塞给 proc-macro,这样被别的 crate 重新导出时路径也对(外壳);这里直接写死 ::mini::,mini 被改名或重新导出就会找不到。dummy 对应 async-stream 的 dummy_yield。
cargo expand 一个 stream! { yield 1u32; },结果和 async-stream 的形状一样:
let s = {
let (mut __tx, __rx) = unsafe { ::mini::pair() };
::mini::MiniStream::new(
__rx,
async move {
'__mini_scope: {
{
#[allow(unreachable_code)]
if false {
break '__mini_scope (loop {});
}
__tx.send(1u32).await
};
}
},
)
};
用起来:
async fn double(x: u32) -> u32 {
x * 2
}
fn main() {
block_on(async {
let s = stream! {
for i in 0..3 {
if i == 1 {
continue;
}
yield double(i).await;
}
yield 100;
};
let items: Vec<u32> = s.collect().await;
println!("basic: {items:?}");
// 嵌套:内层的槽和外层的槽各写各的
let outer = stream! {
let inner = stream! { yield "a"; yield "b"; };
let mut inner = pin!(inner);
while let Some(v) = inner.next().await {
yield format!("outer saw {v}");
}
};
let items: Vec<String> = outer.collect().await;
println!("nested: {items:?}");
let empty: Vec<()> = stream! {}.collect().await;
println!("empty: {empty:?}");
});
}
basic: [0, 4, 100]
nested: ["outer saw a", "outer saw b"]
empty: []
嵌套能工作靠两件事:改写时跳过内层的 stream!(visit_macro_mut 开头那个判断,否则内层的 yield 会被改写成外层的 __tx);运行时 Restore 把 STORE 恢复成外层的槽。空的 stream! 的 Item 是 (),来自 dummy。
第三步:标签检查挡住 #106
用 #106 的结构,但两个流的 Item 都是 u32:这样即使值送错了地方,写入的也是同一种类型,不会越界,可以安全地跑出来看。
macro_rules! asynk {
($e:expr) => {
async { $e }
};
}
fn main() {
block_on(async {
// #106 的结构,两个流的 Item 都是 u32,所以只是「送错了流」,不是内存错误
let outer = stream_unchecked! {
let yield_42 = asynk!(yield 42u32);
let inner = stream_unchecked! {
yield 1u32;
yield_42.await;
yield 2u32;
};
let mut inner = pin!(inner);
while let Some(v) = inner.next().await {
println!("inner got {v}");
}
};
let items: Vec<u32> = outer.collect().await;
println!("outer got {items:?}");
});
}
inner got 1
inner got 42
inner got 2
outer got []
yield 42u32 字面上写在外层的块里,被改写成了外层的 __tx.send(42u32).await,本该是外层的一项;asynk! 把它包进 async { },这个 future 被交给内层,在内层的 poll_next 里被 poll,STORE 指着内层的槽,42 就成了内层的第二项。外层一项也没产出。两个 Item 类型不同时,这一步就是 #106 的越界写入。
同一段代码换成打开了检查的 stream!:
error[E0767]: use of unreachable label `'__mini_scope`
--> demo/src/bin/blocked.rs:16:17
|
16 | let outer = stream! {
| _________________^
17 | | let yield_42 = asynk!(yield 42u32);
18 | | let inner = stream! {
19 | | yield 1u32;
... |
27 | | };
| | ^
| | |
| |_____unreachable label `'__mini_scope`
| unreachable label defined here
|
= note: labels are unreachable through functions, closures, async blocks and modules
error[E0267]: `break` inside `async` block
--> demo/src/bin/blocked.rs:16:17
|
9 | async { $e }
| ----- enclosing `async` block
和 async-stream 0.3.6 对 unsoundness_issue_106.rs 的报错是同一对(E0767 + E0267),只是标签名不同。两个版本的运行时一行都没变,差别只在改写结果里那三行 if false { break .. }。
和 async-stream 的差别
| mini | async-stream 0.3.6 | |
|---|---|---|
| 路径 | 写死 ::mini:: | macro_rules! 外壳传 $crate |
宏参数里的 yield | 只在参数整体是一个表达式时 | 逐 token 扫描,select! 也行 |
? | 不支持 | 改写成发出 Err 后 return |
for await | 不支持,用 pin! + while let | token 层替换后展开 |
| 字段投影 | 手写 unsafe | pin-project-lite |
FusedStream、size_hint | 没有 | 有 |
完整实现(? 与 try_stream!、for await、逐 token 扫描)和配套作业在付费课程里。
这份代码里的 unsafe(unsafe fn pair、get_unchecked_mut、Pin::new_unchecked,以及 SendFut::poll 里对裸指针的 as_mut)应当在 miri 下跑一遍。本机没有装 miri,没有跑过;上面的输出只说明这几个用例的行为符合预期。
小结
- 先手写展开结果、确认运行时能跑,再写宏:运行时 82 行代码,就是
thread_local的Cell<*mut ()>、写完槽就Pending的SendFut、在栈上放槽并用Drop恢复STORE的poll_next。 - 改写器是 syn 的
VisitMut,覆盖visit_expr_mut(改yield,跳过闭包和 async 块)、visit_macro_mut(跳过嵌套的stream!,其余宏的参数是一个表达式时也改写)、visit_item_mut(不进嵌套条目)。 - 嵌套靠两件事:改写时跳过内层宏,运行时恢复
STORE。实测外层["outer saw a", "outer saw b"]。 - 去掉标签检查后,#106 的结构把外层的
yield 42送进了内层:实测内层产出1, 42, 2,外层产出[];加上检查后编译报 E0767 + E0267,与 async-stream 0.3.6 相同。