Rust in Everything
基于 tokio 1.54.1

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、放锁

harness.rs 第 193 行:

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 的),这省掉了一次原子加和一次原子减。

📝NOTE
没有蠢问题

问: 借来的 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);
📝NOTE
冷知识:0.2.7 之前,借来的 waker 有自己的一张 vtable

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 槽会让它立刻再跑,挤掉队列里其他任务。

❗IMPORTANT
动脑筋:叫醒自己三次,会被 poll 几次?

平台是 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 驱动不一定被查过。

📝NOTE
冷知识: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

poll_future:

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 个引用。

💬 关联讨论

发起讨论
加载中...