poll 与唤醒
主线位置:任务主线的「worker 取出任务 →
Harness::poll→ 遇到Pending挂起 →Waker唤醒 → 再次调度」这一段。
flowchart TD run["worker:task.run()"] --> poll["Harness::poll"] poll --> inner["poll_inner:transition_to_running"] inner -->|Success| pf["poll_future:catch_unwind 里 poll"] inner -->|Cancelled| cancel["cancel_task:drop future,存 JoinError"] inner -->|Failed / Dealloc| stop["什么都不做 / 释放"] pf -->|Ready 或 panic| complete["complete"] pf -->|Pending| idle["transition_to_idle"] idle -->|Ok| done["结束,等 wake"] idle -->|OkNotified| yield["yield_now:放回队列尾"] idle -->|Cancelled| cancel cancel --> complete
入口:从 Notified 到 Harness
worker 从队列里拿到的是 Notified<S>。run_task 先用 OwnedTasks::assert_owner 把它换成 LocalNotified(检查任务属于本运行时),然后调 LocalNotified::run:mem::forget 掉自己,把引用计数原样交给 raw.poll()。raw.poll() 查 vtable,进入有类型的 Harness::poll:
pub(super) fn poll(self) {
// We pass our ref-count to `poll_inner`.
match self.poll_inner() {
PollFuture::Notified => {
// The `poll_inner` call has given us two ref-counts back.
// We give one of them to a new task and call `yield_now`.
self.core()
.scheduler
.yield_now(Notified(self.get_new_task()));
// The remaining ref-count is now dropped. We kept the extra
// ref-count until now to ensure that even if the `yield_now`
// call drops the provided task, the task isn't deallocated
// before after `yield_now` returns.
self.drop_reference();
}
PollFuture::Complete => {
self.complete();
}
PollFuture::Dealloc => {
self.dealloc();
}
PollFuture::Done => (),
}
}
引用计数是这里的主线索:Notified 带来一个引用,poll_inner 消耗它,或者把它(连同新造的一个)还回来。每个分支结束时,账都要平。
poll_inner:拿锁、poll、放锁
fn poll_inner(&self) -> PollFuture {
use super::state::{TransitionToIdle, TransitionToRunning};
match self.state().transition_to_running() {
TransitionToRunning::Success => {
// ...
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);
if res == Poll::Ready(()) {
// The future completed. Move on to complete the task.
return PollFuture::Complete;
}
let transition_res = self.state().transition_to_idle();
if let TransitionToIdle::Cancelled = transition_res {
// The transition to idle failed because the task was
// cancelled during the poll.
cancel_task(self.core());
}
transition_result_to_poll_future(transition_res)
}
TransitionToRunning::Cancelled => {
cancel_task(self.core());
PollFuture::Complete
}
TransitionToRunning::Failed => PollFuture::Done,
TransitionToRunning::Dealloc => PollFuture::Dealloc,
}
}
三步对应上一章的三个转换:transition_to_running 拿锁,poll,transition_to_idle 放锁。有两点要注意:
- future 返回
Ready时不检查CANCELLED。如果abort和任务完成赛跑,只要这次 poll 已经产出结果,结果就保留下来,JoinHandle拿到的是Ok。这就是JoinHandle::abort文档里「已经完成的任务被取消,.await仍可能正常返回」的来源。 Pending之后才检查CANCELLED。abort发生在 poll 期间时,future 不会被打断,要等它这次 poll 返回,由 poll 的线程在transition_to_idle里发现,再当场 drop。所以一个从不返回的同步计算(比如死循环)是取消不掉的。
传给 future 的 waker:借来的,不增加引用
注意上面构造 Context 用的是 waker_ref,不是一个真正的 Waker。task/waker.rs 第 16 行:
pub(super) fn waker_ref<S>(header: &NonNull<Header>) -> WakerRef<'_, S>
where
S: Schedule,
{
// `Waker::will_wake` uses the VTABLE pointer as part of the check. This
// means that `will_wake` will always return false when using the current
// task's waker. (discussion at rust-lang/rust#66281).
//
// To fix this, we use a single vtable. Since we pass in a reference at this
// point and not an *owned* waker, we must ensure that `drop` is never
// called on this waker instance. This is done by wrapping it with
// `ManuallyDrop` and then never calling drop.
let waker = unsafe { ManuallyDrop::new(Waker::from_raw(raw_waker(*header))) };
WakerRef {
waker,
_p: PhantomData,
}
}
poll 期间任务一定活着(poll 的线程手里有引用),所以给 future 的 waker 不需要自己的引用。ManuallyDrop 保证它不会被 drop,不会多扣一次。只有 future 要把 waker 存起来时(比如 IO 资源记下「可读时叫醒谁」),才会 clone,这时 clone_waker 才把引用计数加一:
unsafe fn clone_waker(ptr: *const ()) -> RawWaker {
// Safety: `ptr` was created from a `Header` pointer in function `waker_ref`.
let header = unsafe { NonNull::new_unchecked(ptr as *mut Header) };
// ...(tracing)
unsafe { header.as_ref() }.state.ref_inc();
raw_waker(header)
}
大量 future 在 poll 里根本不碰 waker(第一次 poll 就 Ready 的,或者把 waker 交给子 future 而子 future 也直接 Ready 的),这省掉了一次原子加和一次原子减。
问: 借来的 waker 不占引用。要是 future 把 cx.waker() 存进自己的结构体,poll 返回后任务被释放了,那不就成了悬垂指针?
答: 存不进去。Context::waker() 返回的是 &Waker,生命周期跟着 Context,而 Context 只活到这次 poll 返回,借用检查器不允许把这个引用带出 poll。tokio 这边,WakerRef<'a, S> 也用 PhantomData<(&'a Header, S)> 把自己绑在 header_ptr 的借用上。想留下来,只能 cx.waker().clone() 拿一个自己拥有的 Waker,这一步就走进了 clone_waker,引用计数加一。「不加引用」只在类型系统能证明 waker 活不过这次 poll 的范围里成立。
注释里提到的 will_wake:Waker::will_wake 比较 data 指针和 vtable 指针,两个都相同才返回 true。tokio 全局只有一张 WAKER_VTABLE,借来的 waker 和 clone 出来的 waker 用的是同一张,will_wake 才能认出它们是同一个任务。很多库(包括 tokio 自己的 JoinHandle)靠 will_wake 跳过重复保存 waker。
static WAKER_VTABLE: RawWakerVTable =
RawWakerVTable::new(clone_waker, wake_by_val, wake_by_ref, drop_waker);
2020 年 1 月以前,waker_ref 给借来的 waker 单独造一张 vtable:clone 照常走 clone_waker,按值 wake 是 wake_unreachable,drop 是 noop。drop 什么都不做,所以那时不需要 ManuallyDrop。代价落在 will_wake 上:借来的 waker 和 clone 出来的 waker 指向两张不同的表,比较结果总是 false。
#2045(0.2.7)的说明是:will_wake 同时比较数据指针和 vtable,为了避免假阴性,WakerRef 里的 waker 也用同一张 vtable。共用一张表以后 drop 不再是空操作,于是加了 ManuallyDrop,保证这个 waker 永远不被 drop。上面源码里那段注释就是这个 PR 写的。PR 还加了一个测试:在 poll 里 clone cx.waker(),断言两者 will_wake 为 true。
唤醒:从 wake() 到进队列
vtable 里的 wake_by_val 和 wake_by_ref 都转到 RawTask 上的同名方法(harness.rs 第 68 行):
pub(super) fn wake_by_val(&self) {
use super::state::TransitionToNotifiedByVal;
match self.state().transition_to_notified_by_val() {
TransitionToNotifiedByVal::Submit => {
// The caller has given us a ref-count, and the transition has
// created a new ref-count, so we now hold two. We turn the new
// ref-count Notified and pass it to the call to `schedule`.
//
// The old ref-count is retained for now to ensure that the task
// is not dropped during the call to `schedule` if the call
// drops the task it was given.
self.schedule();
// Now that we have completed the call to schedule, we can
// release our ref-count.
self.drop_reference();
}
TransitionToNotifiedByVal::Dealloc => {
self.dealloc();
}
TransitionToNotifiedByVal::DoNothing => {}
}
}
self.schedule() 又是一次 vtable 调用,进入 raw.rs 的 schedule::<S>:用 vtable 里的 scheduler_offset 找到 Core 里存的调度器句柄,调 S::schedule(Notified)。多线程运行时的实现是 schedule_task(task, false):
with_current(|maybe_cx| {
if let Some(cx) = maybe_cx {
// Make sure the task is part of the **current** scheduler.
if self.ptr_eq(&cx.worker.handle) {
// And the current thread still holds a core
if let Some(core) = cx.core.borrow_mut().as_mut() {
self.schedule_local(core, task, is_yield);
return;
}
}
}
// Otherwise, use the inject queue.
self.push_remote_task(task);
self.notify_parked_remote();
});
在本运行时的 worker 线程上唤醒,任务进这个 worker 的 LIFO 槽(或本地队列);在别的线程上唤醒(比如 IO 驱动在另一个线程、或者一个普通线程往 channel 里 send),进全局注入队列,再叫醒一个睡着的 worker。这两个队列分别在本地队列和全局注入队列两章展开。
运行时被唤醒:留记号,poll 完再说
如果 wake 发生时任务正在被 poll(RUNNING 为 1),上一章讲过,状态转换只置 NOTIFIED,不提交。等 poll 返回 Pending,transition_to_idle 看到 NOTIFIED,返回 OkNotified 和两个引用,Harness::poll 走第一个分支:调 scheduler.yield_now。
多线程运行时的 yield_now 是 schedule_task(task, true)。is_yield 为 true 时任务不进 LIFO 槽,排到本地队列尾部。道理是:一个在 poll 期间就被唤醒的任务,很可能是「自己叫醒自己」,放进 LIFO 槽会让它立刻再跑,挤掉队列里其他任务。
平台是 M2 Max、rustc 1.99.0,#[tokio::main](多线程运行时)。一个手写的 future,第一次 poll 时连着调三次 wake_by_ref,然后返回 Pending;之后的每次 poll 都直接返回 Pending,不再叫醒任何人:
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let n = self.polls.fetch_add(1, Ordering::SeqCst) + 1;
if n == 1 {
cx.waker().wake_by_ref();
cx.waker().wake_by_ref();
cx.waker().wake_by_ref();
}
Poll::Pending
}
spawn 它,主任务睡 50ms 后打印 polls。打印出来是几?答案在本页末尾。
顺带一提,tokio::task::yield_now() 并不走这条路。它在 1.54 里调的是 context::defer(cx.waker()),把 waker 交给调度器暂存,等 worker 把手头任务跑完、查过一次 IO 驱动之后再统一唤醒(task/yield_now.rs)。协作式预算用完时(coop::poll_proceed 返回 Pending)也是同样的 defer。如果在 poll 里直接 wake_by_ref 自己,任务排到本地队列尾,前面的任务跑完就轮到它,这期间 IO 驱动不一定被查过。
yield_now 以前就是「叫醒自己」1.23.0 之前,tokio::task::yield_now() 正是上一段最后说的写法:第一次 poll 调 cx.waker().wake_by_ref(),返回 Pending。2022 年 11 月的 #5223(1.23.0)指出,这样让出的任务会在调度器 poll 资源驱动之前就被再次 poll,可能让资源驱动饿死。改法是新增一个队列记下让出的任务,调度器 poll 完资源驱动之后才唤醒它们。现在 yield_now 调的 context::defer,就是这个队列。
panic:两层 catch_unwind
fn poll_future<T: Future, S: Schedule>(core: &Core<T, S>, cx: Context<'_>) -> Poll<()> {
// Poll the future.
let output = panic::catch_unwind(panic::AssertUnwindSafe(|| {
struct Guard<'a, T: Future, S: Schedule> {
core: &'a Core<T, S>,
}
impl<'a, T: Future, S: Schedule> Drop for Guard<'a, T, S> {
fn drop(&mut self) {
// If the future panics on poll, we drop it inside the panic
// guard.
self.core.drop_future_or_output();
}
}
let guard = Guard { core };
let res = guard.core.poll(cx);
mem::forget(guard);
res
}));
// Prepare output for being placed in the core stage.
let output = match output {
Ok(Poll::Pending) => return Poll::Pending,
Ok(Poll::Ready(output)) => Ok(output),
Err(panic) => Err(panic_to_error(&core.scheduler, core.task_id, panic)),
};
// Catch and ignore panics if the future panics on drop.
let res = panic::catch_unwind(panic::AssertUnwindSafe(|| {
core.store_output(output);
}));
if res.is_err() {
core.scheduler.unhandled_panic();
}
Poll::Ready(())
}
- 第一层接住 poll 里的 panic。
Guard的作用是:panic 展开时,Guard被 drop,顺手 drop 掉 future。正常返回时mem::forget(guard),什么都不做。future 的析构函数如果也 panic,那就是展开过程中二次 panic,进程直接 abort。 - future 返回
Ready后,Core::poll当场把它 drop 掉(res.is_ready()时调drop_future_or_output),这一步也在第一层里面。所以析构函数 panic 时,已经算出来的结果被丢掉,JoinHandle拿到的是JoinError::panic。 - 接住的 panic 变成
JoinError::panic(task_id, payload),作为任务的输出,和正常结果走同一条完成流程。 - 第二层包着
store_output,注释说是防 future 析构时 panic。按现在的代码,走到这里时Stage已经是Consumed,这一层只是防御。
unhandled_panic 默认什么也不做。只有 current_thread 运行时开了 tokio_unstable 的 Builder::unhandled_panic(UnhandledPanic::ShutdownRuntime) 时,才会在这里关掉运行时;对多线程运行时设这个选项,Builder 会直接 panic。
所以 worker 线程不会因为任务 panic 而退出,panic 只会到达 JoinHandle:
#[tokio::main]
async fn main() {
let err = tokio::spawn(async { panic!("boom") }).await.unwrap_err();
println!("{err}"); // task 13 panicked with message "boom"
println!("{err:?}"); // JoinError::Panic(Id(13), "boom", ...)
}
任务 ID 是全局自增的,具体数字因程序而异。panic 的消息照样会被默认 panic hook 打到 stderr(thread 'tokio-rt-worker' panicked at ...),catch_unwind 只是阻止了展开继续往上走。
完成:complete 和那个「1 还是 2」
harness.rs 第 331 行,省掉 hooks 部分:
fn complete(self) {
// The future has completed and its output has been written to the task
// stage. We transition from running to complete.
let snapshot = self.state().transition_to_complete();
// We catch panics here in case dropping the future or waking the
// JoinHandle panics.
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
if !snapshot.is_join_interested() {
// The `JoinHandle` is not interested in the output of
// this task. It is our responsibility to drop the
// output. The join waker was already dropped by the
// `JoinHandle` before.
self.core().drop_future_or_output();
} else if snapshot.is_join_waker_set() {
// Notify the waker. ...
self.trailer().wake_join();
// ...
if !self
.state()
.unset_waker_after_complete()
.is_join_interested()
{
// SAFETY: We have COMPLETE=1 and JOIN_INTEREST=0, so
// we have exclusive access to the waker.
unsafe { self.trailer().set_waker(None) };
}
}
}));
// ...(task_terminate_callback)
// The task has completed execution and will no longer be scheduled.
let num_release = self.release();
if self.state().transition_to_terminal(num_release) {
self.dealloc();
}
}
没人等结果(JoinHandle 已 drop)就当场丢掉输出;有人在 .await 就叫醒它。JOIN_WAKER 的握手细节放在下一章。
最后是 release:
fn release(&self) -> usize {
// We don't actually increment the ref-count here, but the new task is
// never destroyed, so that's ok.
let me = ManuallyDrop::new(self.get_new_task());
if let Some(task) = self.core().scheduler.release(&me) {
mem::forget(task);
2
} else {
1
}
}
scheduler.release 把任务从 OwnedTasks 链表上摘下来。摘到了,说明链表那个引用也要还,加上 poll 用的那个引用,一共扣 2;没摘到(任务在关闭时已经被链表弹出,那个引用由弹出方负责),只扣 1。transition_to_terminal 用一条 fetch_sub 一次扣完,剩下的引用就只有 JoinHandle、AbortHandle 和还没 drop 的 waker 了,最后一个离开的负责 dealloc。
动脑筋答案
完整程序:
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
struct WakeSelf {
polls: Arc<AtomicUsize>,
}
impl Future for WakeSelf {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let n = self.polls.fetch_add(1, Ordering::SeqCst) + 1;
if n == 1 {
// 第一次 poll:叫醒自己三次,然后返回 Pending
cx.waker().wake_by_ref();
cx.waker().wake_by_ref();
cx.waker().wake_by_ref();
}
// 之后的 poll 不再叫醒任何人
Poll::Pending
}
}
#[tokio::main]
async fn main() {
let polls = Arc::new(AtomicUsize::new(0));
tokio::spawn(WakeSelf { polls: polls.clone() });
tokio::time::sleep(Duration::from_millis(50)).await;
println!("polls = {}", polls.load(Ordering::SeqCst));
}
M2 Max、rustc 1.99.0、--release,连跑 4 次,每次都是:
polls = 2
是 2,不是 4。逐步看状态:
- 第一次 poll 开始,
transition_to_running置RUNNING、清NOTIFIED。 - 第一次
wake_by_ref:任务正在运行,transition_to_notified_by_ref只置NOTIFIED,不提交。 - 第二、三次
wake_by_ref:NOTIFIED已经是 1,什么都不提交。三次唤醒只留下一位。 - poll 返回
Pending,transition_to_idle看到NOTIFIED,返回OkNotified,Harness::poll调yield_now把任务放回队列。 - 第二次 poll 清掉
NOTIFIED,返回Pending,这次没人唤醒,transition_to_idle返回Ok,任务停在那里。
之后它再也不会被 poll,但也不会被释放:OwnedTasks 还持有它的引用,直到运行时关闭(见状态机一章 transition_to_idle 一节里 pending() 的例子)。
小结
Harness::poll的四个结局:Done(挂起,等唤醒)、Notified(poll 期间被唤醒,放回队列尾)、Complete(完成、panic 或取消)、Dealloc。- future 返回
Ready时不看CANCELLED,结果优先;Pending后才处理取消,所以 poll 中的任务要等这次 poll 返回才能被取消。 - 传给 future 的 waker 是借来的,不加引用计数,只有
clone时才加;全局一张 vtable 让will_wake能认出同一个任务。 - 在 worker 线程上唤醒进本地队列,在其他线程上唤醒进注入队列;poll 中被唤醒的任务排到本地队列尾,不进 LIFO 槽。
tokio::task::yield_now和预算耗尽则走defer,等 IO 驱动跑过一次再唤醒。 - poll 包在
catch_unwind里,panic 变成JoinError存为输出,worker 线程不受影响。 - 完成时从
OwnedTasks摘下任务,一次扣掉 1 或 2 个引用。