Note.
取消安全性与异步清理的困境
Note. 取消安全性与异步清理的困境
在
拉取模型
中,外部驱动者只要停止调用
poll
poll
并直接
drop
drop
状态机,就能在任意
.await
.await
挂起点处同步取消一个异步任务.Without Boats 在 Asynchronous clean-up 与 poll_next 中将这种机制归类为半协作式取消:任务无法拒绝在
.await
.await
处被取消,但可以在被丢弃时通过同步析构函数
Drop::drop
Drop::drop
清理自身持有的资源.
这一机制在工程中引出了两类问题:
循环中的
select!select!与取消安全性: 不少代码会在looploop循环里使用tokio::select!tokio::select!同时等待多个分支:loop { tokio::select! { msg = rx.recv() => handle(msg), _ = do_work() => break, } }
这里容易忽略的是:每一次循环迭代中,只要某个分支率先就绪,其余未就绪分支的loop { tokio::select! { msg = rx.recv() => handle(msg), _ = do_work() => break, } }FutureFuture状态机就会被当场dropdrop取消,并在下一轮迭代重新构造. 如果被取消的FutureFuture内部持有跨挂起点的局部状态(例如已经从字节流读了一半的数据缓冲、或者在侵入式等待队列中排到了靠前的位置),丢弃重建就会造成数据丢失或排回队尾饿死. 只有当一个FutureFuture自身不保存任何跨挂起点的实质局部进展(其状态仅是对外部持久化对象的引用,丢弃后再调一次构造函数能回到完全相同的状态)时,在select!select!循环中反复取消重建它才不会产生可见副作用——这就是tokiotokio文档所称的“取消安全”. 避免在循环里意外取消状态机的办法,是在循环外用let mut work = pin!(do_work());let mut work = pin!(do_work());先把状态机固定住,再在select!select!分支里传可变借用&mut work&mut work(或者优先使用将多个异步流合流的StreamExt::mergeStreamExt::merge).为什么 Rust 难以支持隐式的异步析构函数(
AsyncDropAsyncDrop): 既然Drop::drop(&mut self)Drop::drop(&mut self)是同步函数,如果一个异步对象(如数据库连接或 TLS 套接字)在销毁时需要发送关闭握手包并.await.await确认,该怎么办? 直接允许在DropDrop里.await.await会遇到四重阻碍:- 若该值在同步函数里离开作用域,没有异步上下文可供
.await.await; - 若任意泛型类型
TT都可能含有隐式异步析构,那么任何包含TT的外层FutureFuture在生成状态机时,都必须为每个局部变量的析构预留额外的挂起状态变体与存储空间,这会使dyn Traitdyn Trait丧失对象安全性(无法静态确定析构状态机的大小),还会让跨线程发送TT时的SendSend证明传染到析构产生的匿名状态机上; - 在每次变量离开作用域的地方插入隐式挂起点,会打破 Rust “挂起点必须通过
.await.await显式标出”的约定; - 多个含异步析构的变量同时离开作用域时,编译器无法替用户决定应当串行依次
.await.await还是并发join!join!.
因此,目前在 Rust 中需要异步清理的资源通常提供显式的
close().awaitclose().await方法,并在同步DropDrop中仅做非阻塞通知或后台任务投递;语言层面的演进讨论则聚焦于显式的do ... finaldo ... final块、Future::poll_cancelFuture::poll_cancel协议或限制隐式丢弃的必移动类型.- 若该值在同步函数里离开作用域,没有异步上下文可供