JoinHandle、取消与 JoinError
主线位置:任务主线的最后一段,「完成,
JoinHandle拿到结果」。也包括主线的岔路:任务被abort。
sequenceDiagram participant J as JoinHandle(等结果的任务) participant S as 任务状态 participant W as worker(跑任务) J->>S: poll:未完成,存 waker,置 JOIN_WAKER W->>S: transition_to_complete(置 COMPLETE) W->>J: JOIN_WAKER 为 1,wake_join W->>S: 清 JOIN_WAKER,waker 字段交还 JoinHandle J->>S: 再次 poll:已完成,take_output
JoinHandle 是什么
pub struct JoinHandle<T> {
raw: RawTask,
_p: PhantomData<T>,
}
一个 RawTask 加上输出类型。它占任务的一个引用计数,状态里的 JOIN_INTEREST 位表示它还活着。T: Send 时它才是 Send 的:输出要能跟着它跨线程。
poll:输出从 vtable 里「倒」出来
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
ready!(crate::trace::trace_leaf(cx));
let mut ret = Poll::Pending;
// Keep track of task budget
let coop = ready!(crate::task::coop::poll_proceed(cx));
// Try to read the task output. If the task is not yet complete, the
// waker is stored and is notified once the task does complete.
//
// The function must go via the vtable, which requires erasing generic
// types. To do this, the function "return" is placed on the stack
// **before** calling the function and is passed into the function using
// `*mut ()`.
//
// Safety:
//
// The type of `T` must match the task's output type.
unsafe {
self.raw.try_read_output(&mut ret, cx.waker());
}
if ret.is_ready() {
coop.made_progress();
}
ret
}
两个细节:
.await一个JoinHandle也消耗协作式预算。预算用完时,即使任务已经完成,也先返回Pending,让出 worker。- 输出怎么穿过类型擦除:vtable 里的函数签名不能带
T,所以调用方先在自己栈上放一个Poll<Result<T>>,把它的地址当*mut ()传进去。vtable 那头的try_read_output::<T, S>知道真实类型,把指针转回来,往里写。两边类型对得上,靠的是JoinHandle<T>只能由new_task和对应的 future 一起造出来。
真正干活的是 can_read_output:任务已完成就返回 true,调用方从 Stage::Finished 里取走输出(换成 Consumed);没完成就把 waker 存进 Trailer,返回 false。
完成后再 poll 一次会 panic(JoinHandle polled after completion),因为输出已经被取走了。
JOIN_WAKER:两个线程,一个 waker 字段
Trailer 里的 waker 字段(UnsafeCell<Option<Waker>>,没有锁)有两个访问者:
JoinHandle所在的线程:每次 poll 可能要写一个新 waker。- 跑任务的 worker:任务完成时要读它,调
wake。
这两件事可能同时发生。tokio 用状态里的 JOIN_WAKER 位做访问控制,task/mod.rs 的模块文档里写了 7 条规则,归纳起来是:
COMPLETE | JOIN_WAKER | 谁能碰 waker 字段 |
|---|---|---|
| 0 | 0 | JoinHandle 独占(可写) |
| 0 | 1 | JoinHandle 只读 |
| 1 | 1 | 运行时只读(用来 wake) |
| 1 | 0 | JoinHandle 独占(运行时用完了,交还;或者从没存过) |
表里假设 JoinHandle 还在。它已经被 drop(JOIN_INTEREST 为 0)而任务完成了,waker 字段归运行时独占,由运行时负责清掉。
另外两条:只有任务未完成时 JoinHandle 才能改 JOIN_WAKER,只有任务完成后运行时才能改它。 完成这一刻(COMPLETE 从 0 变 1)就是所有权交接点。
等结果的人(JoinHandle)在快递柜的格子里留一张写着电话的纸条(Trailer 里的 waker 字段),再把格子门上的小旗竖起来(置 JOIN_WAKER)。快递员(跑任务的 worker)送到货(任务完成)时,看到小旗竖着,就照纸条上的号码打电话(wake_join)。
小旗竖着时,纸条归快递员读,留条的人不能改。想换一张纸条,得先把旗放倒(unset_waker),换好再竖起来(set_join_waker)。放旗、竖旗只能在货到之前做;货一到(COMPLETE 置 1),旗子就归快递员管:电话打完,他把旗放倒(unset_waker_after_complete),格子还给留条的人。
比方到这里为止:真人放旗时发现货已经到了,还会等一个电话;这里不等。unset_waker 或 set_join_waker 的 CAS 一失败,JoinHandle 就知道任务已经完成,直接去取输出。
JoinHandle 要换 waker 时,按三步走:
- 如果
JOIN_WAKER是 1,先把它清成 0,拿回独占权(unset_waker)。 - 写入新 waker。
- 把
JOIN_WAKER置回 1(set_join_waker)。
第 1、3 步都是 CAS,任务未完成时才成功。任一步失败,说明任务就在这期间完成了:第 3 步失败时把刚写的 waker 清掉,然后按「已完成」处理,直接去读输出。can_read_output 里还有一个快路径:已存的 waker 和这次的 will_wake 相同,就什么都不做。同一个任务反复 poll 同一个 JoinHandle 时,大多走这条路。
运行时这边,complete 里看到 JOIN_WAKER 为 1 就调 wake_join,然后用 unset_waker_after_complete(一条 fetch_and)清掉它,把字段交还给 JoinHandle。如果清的时候发现 JOIN_INTEREST 已经是 0(JoinHandle 刚好在这期间被 drop 了),就由运行时自己把 waker drop 掉。
2024 年 4 月的 issue #6505 指出:注册在 JoinHandle 上的 waker,要等任务的内存被释放时才 drop。两个任务互相等对方的 JoinHandle,就会形成引用环,两块任务内存都永远不会释放。
#6986(1.43.0)改成主动 drop:任务完成时,运行时用 unset_waker_after_complete 交还字段,发现 JoinHandle 已经不在就自己 drop 掉 waker;JoinHandle 被 drop 时,由 transition_to_join_handle_dropped 返回的 drop_waker 告诉它要不要当场清掉。这两个函数都是这个 PR 加的,之前 drop JoinHandle 只用 unset_join_interested 清掉 JOIN_INTEREST。
drop JoinHandle:快路径
tokio::spawn(fut); 不接返回值,是最常见的写法之一。这时 JoinHandle 在 spawn 返回后立刻 drop,Drop 先试快路径:
impl<T> Drop for JoinHandle<T> {
fn drop(&mut self) {
if self.raw.state().drop_join_handle_fast().is_ok() {
return;
}
self.raw.drop_join_handle_slow();
}
}
pub(super) fn drop_join_handle_fast(&self) -> Result<(), ()> {
use std::sync::atomic::Ordering::Relaxed;
// Relaxed is acceptable as if this function is called and succeeds,
// then nothing has been done w/ the join handle.
//
// The moment the join handle is used (polled), the `JOIN_WAKER` flag is
// set, at which point the CAS will fail.
//
// Given this, there is no risk if this operation is reordered.
self.val
.compare_exchange_weak(
INITIAL_STATE,
(INITIAL_STATE - REF_ONE) & !JOIN_INTEREST,
Release,
Relaxed,
)
.map(|_| ())
.map_err(|_| ())
}
它赌的是:状态还是一模一样的初始值,即任务还没被 poll 过、没人 clone 过 waker、JoinHandle 也没被 poll 过。这时不可能有输出要丢、也不可能有 waker 要清,只要扣一个引用、清 JOIN_INTEREST,一次 CAS 完事。用 compare_exchange_weak 是因为失败也无所谓:伪失败只是多走一次慢路径。
慢路径 drop_join_handle_slow 先做 transition_to_join_handle_dropped:清 JOIN_INTEREST,任务未完成时顺便清 JOIN_WAKER。返回两个布尔值:
drop_output:任务已完成,输出还在,由JoinHandle负责 drop。drop 包在catch_unwind里,panic 被吞掉:用户都不要这个JoinHandle了,显然也不关心输出析构时的 panic。drop_waker:waker 字段归自己独占了,清掉它。
JoinHandle 可能 panicissue #4412 指出:tokio 一般会接住 panic,不让它传给 drop JoinHandle 的人;但任务已经完成时,drop JoinHandle 要顺带 drop 输出,输出的析构函数一 panic,就传到了调用方。#4430(1.17.0)的改法就是上面说的:把这次 drop 包进 catch_unwind,吞掉 panic。
为什么输出要由 JoinHandle 当场 drop,而不是留在任务里等释放时一起 drop?源码注释给的理由:输出可能不是 Send 的(LocalSet 上的任务,见 JoinSet、LocalSet、task_local),它只能在 JoinHandle 所在的线程上 drop;留在任务里,最后一个引用可能是某个在任意线程上被 drop 的 waker。
abort:一次置位,交给调度器
JoinHandle::abort 和 AbortHandle::abort 都是一行 self.raw.remote_abort()(harness.rs 第 118 行):
pub(super) fn remote_abort(&self) {
if self.state().transition_to_notified_and_cancel() {
// The transition has created a new ref-count, which we turn into
// a Notified and pass to the task.
//
// Since the caller holds a ref-count, the task cannot be destroyed
// before the call to `schedule` returns even if the call drops the
// `Notified` internally.
self.schedule();
}
}
abort 自己从不 drop future,只是置 CANCELLED,必要时让任务进队列,由调度器在任务所属的线程上完成取消。按调用时任务的状态,结果是:
调用 abort 时任务在… | 发生什么 | .await 得到 |
|---|---|---|
| 空闲(在等 IO、定时器等) | 置 CANCELLED、NOTIFIED,进队列;worker 取出后 transition_to_running 返回 Cancelled,不 poll,直接 drop future | Err(cancelled) |
队列里(已有 NOTIFIED) | 只置 CANCELLED;轮到它时同上 | Err(cancelled) |
| 正在被 poll | 只置位;这次 poll 返回 Pending 后,poll 线程在 transition_to_idle 里发现,当场取消 | Err(cancelled) |
正在被 poll,而这次 poll 返回了 Ready | 结果优先,CANCELLED 被忽略 | Ok(output) |
| 已完成 | 什么都不做 | Ok(output) |
最后两行就是文档那句「已经完成的任务被取消,.await 仍可能正常返回」。所以 abort 只是「请求」,想确认任务确实没有产生副作用,要看 .await 的结果是不是 is_cancelled()。
M2 Max、rustc 1.99.0,多线程运行时,2 个 worker。两个任务都在一次 poll 里同步忙 100ms,主线程 10ms 后把两个都 abort 掉:
let a = tokio::spawn(async {
std::thread::sleep(Duration::from_millis(100));
42
});
let b = tokio::spawn(async {
std::thread::sleep(Duration::from_millis(100));
tokio::task::yield_now().await;
42
});
std::thread::sleep(Duration::from_millis(10));
a.abort();
b.abort();
println!("A: {:?}", a.await);
println!("B: {:?}", b.await);
两个任务只差一个 yield_now().await。答案在本页末尾。
spawn_blocking 的任务没有 .await 点,取消只能在它开始运行之前生效;一旦开始,abort 就没有作用。
abort 以前会在调用线程上 drop future1.8.1 之前,JoinHandle::abort 调的是 raw.shutdown():任务没在运行,future 就在调用 abort 的线程上当场 drop。2021 年 7 月的 issue #3929 指出这对 LocalSet 上的任务是错的,并给了一个例子:从另一个线程 abort,就能把一个非 Send 的值送到别的线程上析构。issue 里写到,很多项目在任务里用 Rc 或 RefCell,这很容易引出竞争。修复 #3934 加了 remote_abort:只置位,把任务交给调度器。它随 1.8.1 发布,同一天也回移到了 1.5.1、1.6.3、1.7.2。
这个改动马上带出一个回归,issue #3964:abort 之后立刻 drop JoinHandle,future 的析构没有在预期的时候执行,而是晚了。修复 #3965(1.8.2)的标题是「被取消的 future 会泄漏」,harness.rs 里的改动只有一行:worker 发现 CANCELLED 时,原来只存一个 JoinError::cancelled(),改成调 cancel_task,先 drop future 再存错误。现在 transition_to_running 返回 Cancelled 时走的就是这一步。
AbortHandle 也只包了一个 RawTask,JoinHandle::abort_handle() 和 AbortHandle::clone 都是 ref_inc,drop 时扣掉。它不碰 JOIN_INTEREST,所以只拿着 AbortHandle 不影响任务完成时丢弃输出。
JoinError
pub struct JoinError {
repr: Repr,
id: Id,
}
enum Repr {
Cancelled,
Panic(SyncWrapper<Box<dyn Any + Send + 'static>>),
}
只有两种:取消,或者 panic。panic 的 payload 是 Box<dyn Any + Send>,不是 Sync,但 JoinError 需要是 Sync(才能放进 anyhow::Error 这类要求 Send + Sync 的错误类型里)。SyncWrapper 的办法是不提供 &T 访问:拿着 &SyncWrapper 什么也做不了,跨线程共享引用也就是安全的,只有 into_inner 按值取出。
JoinError 里曾经有一把锁JoinError 一开始不是 Sync,转不成 dyn Error + Send + Sync(issue #1883)。2019 年 12 月的 #1888(0.2.3)把 panic payload 包进了 Mutex,PR 说明里写的理由是:Box 和 Mutex 有开销,但能用,而且这不是热路径。2021 年 7 月的 #3959(1.9.0)去掉了这把锁,换成 sync_wrapper crate 里的做法,也就是上面的 SyncWrapper。
把 panic 原样抛到等待方,标准写法是:
match handle.await {
Ok(v) => v,
Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
Err(e) => return Err(e.into()), // 取消:转成调用方自己的错误类型
}
Display 的输出带任务 ID,排查日志时能和 tokio::task::id() 对上:
task 13 panicked with message "boom"
task 14 was cancelled
Display 里没有 panic 消息1.40.0 之前,panic 的 JoinError 只打印 task 13 panicked。issue #6749 说,应用里拿到 JoinError 几乎都是要转成别的错误往上抛,而它的 Debug 和 Display 都不带 panic 消息,于是每个项目都复制粘贴同一段转换函数:try_into_panic,再把 payload 按 String 或 &'static str 试着 downcast。#6753(1.40.0)把这一步挪进了 JoinError 自己的格式化,payload 是字符串时就带上 with message "..."。
动脑筋答案
完整程序:
use std::time::Duration;
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() {
// A:一次 poll 里同步忙 100ms,然后直接返回
let a = tokio::spawn(async {
std::thread::sleep(Duration::from_millis(100));
42
});
// B:同样忙 100ms,但返回之前还有一个 .await
let b = tokio::spawn(async {
std::thread::sleep(Duration::from_millis(100));
tokio::task::yield_now().await;
42
});
// main 不是 worker 线程,这里同步睡 10ms 不影响两个任务
std::thread::sleep(Duration::from_millis(10));
a.abort();
b.abort();
println!("A: {:?}", a.await);
println!("B: {:?}", b.await);
}
M2 Max、rustc 1.99.0、--release,连跑 5 次,每次都是:
A: Ok(42)
B: Err(JoinError::Cancelled(Id(4)))
- A:
abort时 A 正在 worker 上那次 poll 里睡觉。abort只置CANCELLED,打断不了它。100ms 后这次 poll 返回Ready(42),poll_inner拿到Ready直接走完成流程,不看CANCELLED,结果保留下来。这就是上表「这次 poll 返回了Ready」那一行。 - B:睡完以后碰到
yield_now().await,这次 poll 返回Pending,transition_to_idle看到CANCELLED,返回Cancelled,poll 的线程当场 drop future。B 的42永远算不出来。 Id(4)是 B 的任务 ID,全局自增,数字因程序而异。
这个结果依赖时序:A 得在 abort 之前就开始被 poll。第一版程序在 main 里用的是 tokio::time::sleep 等 10ms,结果不稳定,5 次里 B 有 4 次是 Ok(42)。在 abort 前打印耗时,5 次都在 100ms 到 105ms 之间:两个 worker 都在同步睡觉,10ms 的定时器要等其中一个睡完空下来才触发,这时 B 可能已经跑完了。换成 std::thread::sleep 以后,main 线程自己计时,5 次都和上面一样。
小结
JoinHandle是一个RawTask加输出类型。poll消耗协作式预算;输出靠「调用方在栈上放好槽位,按*mut ()传进 vtable」穿过类型擦除。- waker 字段由
JOIN_WAKER位做访问控制:任务未完成时归JoinHandle,完成后归运行时,用完交还。will_wake相同时跳过重存。 - drop
JoinHandle先试快路径:状态还是初始值就一次 CAS 完事,覆盖了tokio::spawn(fut);这种不要返回值的写法。慢路径负责丢弃输出和 waker,非Send输出因此不会跑到别的线程上析构。 abort只置CANCELLED,由调度器在正确的线程上 drop future。poll 中的任务要等这次 poll 返回;这次 poll 若产出了结果,结果优先。JoinError只有取消和 panic 两种,SyncWrapper让装着 panic payload 的它也是Sync。