Rust in Everything
基于 tokio 1.54.1

current_thread

主线位置:任务主线的「任务进入队列 → 取出任务 → poll」,单线程版本。只有一个线程,所以没有窃取,也没有 LIFO 槽,适合先把调度器的骨架看清楚。
flowchart TB
  start[block_on:拿到 Core] --> woken{woken 为真?}
  woken -->|是| pollmain[poll 主 future]
  pollmain -->|Ready| done[返回结果,Core 放回去]
  pollmain -->|Pending| loop
  woken -->|否| loop[最多跑 event_interval 个任务]
  loop -->|next_task 取到任务| run[run_task]
  run --> loop
  loop -->|没有任务| park[park:在驱动上睡,等 IO、定时器或唤醒]
  park --> woken
  loop -->|跑满 61 个| yield[park_yield:不睡,只查一次驱动]
  yield --> woken

源码只有一个文件,scheduler/current_thread/mod.rs。

三个结构

调度器本体 CurrentThread 只有两个字段:

pub(crate) struct CurrentThread {
    /// Core scheduler data is acquired by a thread entering `block_on`.
    core: AtomicCell<Core>,

    /// Notifier for waking up other threads to steal the
    /// driver.
    notify: Notify,
}

真正干活的数据在 Core 里:

struct Core {
    /// Scheduler run queue
    tasks: VecDeque<Notified>,

    /// Current tick
    tick: u32,

    /// Runtime driver
    ///
    /// The driver is removed before starting to park the thread
    driver: Option<Driver>,

    // ...

    /// How often to check the global queue
    global_queue_interval: u32,

    // ...
}

能被别的线程碰到的数据放在 Shared 里,它挂在 Handle 上:

struct Shared {
    /// Remote run queue
    inject: Inject<Arc<Handle>>,

    /// Collection of all active tasks spawned onto this executor.
    owned: OwnedTasks<Arc<Handle>>,

    /// Indicates whether the blocked on thread was woken.
    woken: AtomicBool,

    // ...
}

两个队列的分工:

队列类型谁往里放同步方式
Core::tasksVecDeque<Notified>跑调度器的那个线程自己不需要同步,只有持有 Core 的线程能碰
Shared::inject侵入式链表加 Mutex其他线程,比如另一个线程里的 tx.send() 叫醒了这里的任务加锁
📝NOTE
冷知识:1.29.0 以前,这里的注入队列是一把锁加一个 VecDeque

1.28 的 current_thread 有自己的远程队列,类型是 Mutex<Option<VecDeque<Notified>>>:取任务先加锁再 pop_front,关闭时把 Option 换成 None。#5702(1.29.0)把它换成和多线程调度器共用的 Inject。PR 说主要目的是统一两个调度器的注入队列代码,方便以后调整;Inject 还多一个好处:队列为空时取任务不用加锁。PR 贴的基准测试前后差别在误差范围内。

Core 的注释说它是一个 capability token:谁拿着 Box<Core>,谁就有权跑这个调度器。Core 放在 AtomicCell 里,AtomicCell 内部是一个 AtomicPtr,取走和放回都是一次原子交换,同一时刻最多一个线程拿到它。多线程调度器也用同样的办法交接 Core,那里的作用更大,见 multi_thread 总览。

谁拿到 Core 谁跑

CurrentThread::block_on:

crate::runtime::context::enter_runtime(handle, false, |blocking| {
    let handle = handle.as_current_thread();

    // Attempt to steal the scheduler core and block_on the future if we can
    // there, otherwise, lets select on a notification that the core is
    // available or the future is complete.
    loop {
        if let Some(core) = self.take_core(handle) {
            // ...
            return core.block_on(future);
        } else {
            let notified = self.notify.notified();
            pin!(notified);

            if let Some(out) = blocking
                .block_on(poll_fn(|cx| {
                    if notified.as_mut().poll(cx).is_ready() {
                        return Ready(None);
                    }

                    if let Ready(out) = future.as_mut().poll(cx) {
                        return Ready(Some(out));
                    }

                    Pending
                }))
                .expect("Failed to `Enter::block_on`")
            {
                return out;
            }
        }
    }
})

Runtime::block_on 只要 &self,所以可以把一个 current_thread 运行时放进 Arc,在几个线程里同时 block_on。只有一个线程能拿到 Core,由它跑所有 spawn 出来的任务。没拿到的线程只 poll 自己那个 future,同时等 notify。拿着 Core 的线程退出 block_on 时,CoreGuard 的 Drop 把 Core 放回去,再 notify_one,等着的线程醒来接手。

📝NOTE
冷知识:0.3.0 以前,几个线程不能同时 block_on

那时 current_thread 还叫 basic_scheduler。#2804 的说明是:之前进入 block_on 要先拿一把 mutex,所以 block_on 不能并发调用。改动之后,第一个调 block_on 的线程拿到驱动;它还没退出时,其他线程调 block_on 拿不到驱动,只进入运行时上下文 poll 自己的 future;第一个线程结束时通过一个条件变量通知它们来「偷」走驱动。今天的 take_core 加 notify 做的是同一件事,只是交接的对象从驱动换成了整个 Core。这个改动随 0.3.0 发布。

enter_runtime 的第二个参数 false 是 allow_block_in_place。current_thread 上调 block_in_place 会 panic,原因在阻塞线程池一章。

📝NOTE
没有蠢问题

问: current_thread 只有一个线程,为什么 tokio::spawn 还要求 future 是 Send?

答: 「只有一个线程」是说同一时刻只有一个线程在跑任务,不是说永远是同一个线程。任务被唤醒后进的是 Core::tasks 或注入队列,谁拿着 Core 谁去 poll。主线程的 block_on 返回、Core 放回去以后,另一个线程再 block_on,剩下的任务就在那个线程上接着跑。在 1.54.1 上试一下:一个每 50ms 打印一次线程 ID 的任务,主线程 block_on 120ms 后换另一个线程 block_on,前三次打印 ThreadId(1),第四次打印 ThreadId(2)。tokio::spawn 管不了你会不会这样用,只能要求 Send。任务不是 Send 的,用 LocalSet 或 LocalRuntime 的 spawn_local。

block_on 的循环

拿到 Core 以后,CoreGuard::block_on 是整个调度器的主循环:

let waker = Handle::waker_ref(&context.handle);
let mut cx = std::task::Context::from_waker(&waker);

'outer: loop {
    let handle = &context.handle;

    if handle.reset_woken() {
        let (c, res) = context.enter(core, || {
            crate::task::coop::budget(|| future.as_mut().poll(&mut cx))
        });

        core = c;

        if let Ready(v) = res {
            return (core, Some(v));
        }
    }

    for _ in 0..handle.shared.config.event_interval {
        // Make sure we didn't hit an unhandled_panic
        if core.unhandled_panic {
            return (core, None);
        }

        core.tick();

        let entry = core.next_task(handle);

        let task = match entry {
            Some(entry) => entry,
            None => {
                // ...
                core = if context.has_pending_work(&core) {
                    context.park_yield(core, handle)
                } else {
                    context.park(core, handle)
                };

                // Try polling the `block_on` future next
                continue 'outer;
            }
        };

        let task = context.handle.shared.owned.assert_owner(task);

        let c = context.run_task(task, core);

        core = c;
    }

    // Yield to the driver, this drives the timer and pulls any
    // pending I/O events.
    core = context.park_yield(core, handle);
}

几个要点:

  • 主 future 不是任务。 传给 block_on 的 future 不在任何队列里,它有自己的 waker:Handle 的 Wake 实现把 Shared::woken 设为 true,再叫醒驱动。循环每一圈开头看一眼 woken,为真才 poll 主 future。waker_ref 在进入循环前先把 woken 设成 true,保证主 future 至少被 poll 一次。
  • 每圈最多跑 event_interval 个任务,默认 61(Builder::new_current_thread,注释说这个数是从 Go 抄来的)。跑满就 park_yield:用 0 超时 park 一次驱动,处理已经就绪的 IO 和到期的定时器,然后回到循环开头。没有这一步,队列一直有任务时,IO 事件就永远没人收。
  • 没任务就 park。 park 把驱动从 Core 里取出来,在上面睡到有事件为止:IO 就绪、定时器到期,或者别的线程往注入队列放了任务(schedule 会调 driver.unpark())。如果 defer 里还有等着唤醒的 waker(yield_now 和预算耗尽会放进去),或者主 future 已经被唤醒,就只 park_yield,不睡。
📝NOTE
冷知识:1.4.0 以前,主 future 每一圈都被 poll

woken 这个标志是 #3582 加的(1.4.0)。起因是 issue #3475:传给 block_on 的 future,在同一个运行时里只要有别的任务能往前走就会被 poll,不管它自己的 waker 有没有被唤醒。报告者的 future 每次 poll 都要锁一个同步 mutex,大约 100 微秒,这段时间里另一个 tokio 任务就被挡住了。修复就是上面那个标志:主 future 的 waker 把它设成真,循环开头只有它为真才 poll 主 future。

每个任务的 poll 都包在 coop::budget 里,主 future 也一样。预算是什么见协作式调度预算。

取任务:next_task

Core::next_task:

fn next_task(&mut self, handle: &Handle) -> Option<Notified> {
    if self.tick % self.global_queue_interval == 0 {
        handle
            .next_remote_task()
            .or_else(|| self.next_local_task(handle))
    } else {
        self.next_local_task(handle)
            .or_else(|| handle.next_remote_task())
    }
}

平时先看本地队列,空了再看注入队列。但如果本地队列一直不空(比如两个任务互相唤醒),注入队列里的任务会一直排不上。所以每 global_queue_interval 个 tick 反过来,先看注入队列。current_thread 的这个间隔默认是 31,固定不变。多线程调度器的间隔会按任务耗时自动调节,见全局注入队列。

next_local_task 是 tasks.pop_front(),所以本地队列是先进先出。

放任务:schedule

任务被 spawn 或被唤醒时,最终调 Schedule::schedule:

fn schedule(&self, task: task::Notified<Self>) {
    use scheduler::Context::CurrentThread;

    // ...

    context::with_scheduler(|maybe_cx| match maybe_cx {
        Some(CurrentThread(cx)) if Arc::ptr_eq(self, &cx.handle) => {
            let mut core = cx.core.borrow_mut();

            // If `None`, the runtime is shutting down, so there is no need
            // to schedule the task.
            if let Some(core) = core.as_mut() {
                core.push_task(self, task);
            }
            self.driver.unpark_self();
        }
        _ => {
            // Track that a task was scheduled from **outside** of the runtime.
            self.shared.scheduler_metrics.inc_remote_schedule_count();

            // Schedule the task
            self.shared.inject.push(task);
            self.driver.unpark();
        }
    });
}

判断条件是「当前线程正在跑这个运行时」:线程本地上下文里有 current_thread 的 Context,并且 Handle 是同一个。满足就直接 push_back 进 VecDeque;不满足就加锁放进注入队列,再叫醒驱动,因为跑调度器的那个线程可能正睡在驱动上。

Arc::ptr_eq 这个检查不能省。一个线程可以先进入运行时 A,在 A 的任务里又持有运行时 B 的 Handle 去 spawn。这时线程本地上下文是 A 的,任务却属于 B,只能走 B 的注入队列。

实验:spawn 出去的任务什么时候跑

use std::thread;
use std::time::Duration;

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let h = tokio::spawn(async {
    println!("spawned task on {:?}", thread::current().id());
  });
  println!("main on {:?}", thread::current().id());
  thread::sleep(Duration::from_millis(100));
  println!("main slept 100ms");
  h.await.unwrap();
  println!("done");
}

在 tokio 1.54.1 上的输出:

main on ThreadId(1)
main slept 100ms
spawned task on ThreadId(1)
done

tokio::spawn 之后,任务已经在 Core::tasks 里了,但没人去跑它:唯一的线程正在 poll 主 future。直到主 future 执行到 h.await 返回 Pending,block_on 的循环才走到 next_task。两个线程 ID 相同,主 future 和任务确实在同一个线程上。

把这个例子里的 thread::sleep 换成任何一段长时间不返回的代码,结论都一样:在 current_thread 上,一个 poll 不返回,整个运行时就停住,包括其他任务、IO 和定时器。多线程调度器能把这种影响限制在一个 worker 上,但限制不了别的,见协作式调度预算。

❗IMPORTANT
动脑筋:主 future 被 poll 了几次?

还是 current_thread 运行时。主 future 外面套一层计数器 CountPolls,每被 poll 一次就把 polls 加 1,再去 poll 里面的 future。里面 spawn 一个任务,它 yield_now 100 次;主 future await 它的 JoinHandle:

let polls = rt.block_on(CountPolls {
  inner: Box::pin(async {
    let h = tokio::spawn(async {
      for _ in 0..100 {
        tokio::task::yield_now().await;
      }
    });
    h.await.unwrap();
  }),
  polls: 0,
});
println!("main future polled {polls} times");
打印出来的数是多少?答案在本页末尾。

关闭

运行时 drop 时调 shutdown2:

fn shutdown2(mut core: Box<Core>, handle: &Handle) -> Box<Core> {
    // Drain the OwnedTasks collection. This call also closes the
    // collection, ensuring that no tasks are ever pushed after this
    // call returns.
    handle.shared.owned.close_and_shutdown_all(0);

    // Drain local queue
    // We already shut down every task, so we just need to drop the task.
    while let Some(task) = core.next_local_task(handle) {
        drop(task);
    }

    // Close the injection queue
    handle.shared.inject.close();

    // Drain remote queue
    while let Some(task) = handle.shared.inject.pop() {
        drop(task);
    }

    assert!(handle.shared.owned.is_empty());

    // ...

    // Shutdown the resource drivers
    if let Some(driver) = core.driver.as_mut() {
        driver.shutdown(&handle.driver);
    }

    core
}

先关 OwnedTasks 并取消所有任务,再清空本地队列,然后关闭并清空注入队列,最后关驱动。只有一个线程,一个函数就做完了;多线程版本要等每个 worker 各自看到关闭信号,分成好几个阶段。为什么每个集合都要先关、再清,见 OwnedTasks 与关闭。

动脑筋答案

完整程序(M2 Max、rustc 1.99.0、--release):

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct CountPolls<F> {
  inner: Pin<Box<F>>,
  polls: u32,
}

impl<F: Future> Future for CountPolls<F> {
  type Output = u32;

  fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
    self.polls += 1;
    match self.inner.as_mut().poll(cx) {
      Poll::Ready(_) => Poll::Ready(self.polls),
      Poll::Pending => Poll::Pending,
    }
  }
}

fn main() {
  let rt = tokio::runtime::Builder::new_current_thread()
    .enable_all()
    .build()
    .unwrap();
  let polls = rt.block_on(CountPolls {
    inner: Box::pin(async {
      let h = tokio::spawn(async {
        for _ in 0..100 {
          tokio::task::yield_now().await;
        }
      });
      h.await.unwrap();
    }),
    polls: 0,
  });
  println!("main future polled {polls} times");
}

连跑 3 次,输出都是:

main future polled 2 times
  • 第 1 次:进入循环前 waker_ref 把 woken 设成真,第一圈 poll 主 future。它 spawn 出任务,然后在 h.await 上返回 Pending。
  • 中间 100 次让出:任务每次 yield_now 都把自己的 waker 放进 defer。任务队列空了,has_pending_work 看到 defer 不为空,只 park_yield,醒来时把任务放回队列,回到循环开头。这时 woken 是假的,主 future 不被 poll。
  • 第 2 次:任务结束,JoinHandle 叫醒主 future 的 waker,woken 变成真。下一圈 poll 主 future,返回 Ready。

主 future 只在自己的 waker 被叫醒时才被 poll,和中间跑了多少圈循环无关。

小结

  • current_thread 调度器由一个 Box<Core> 驱动,谁拿到 Core 谁跑任务;Core 放在 AtomicCell 里,几个线程同时 block_on 时只有一个能拿到。
  • 两个队列:本线程产生的任务进 VecDeque,不用加锁;别的线程送来的任务进带锁的注入队列。
  • block_on 的循环:woken 为真就 poll 主 future;然后最多跑 61 个任务;没任务就在驱动上睡,跑满 61 个就不睡,只查一次驱动。
  • 每 31 个 tick 先看一次注入队列,防止它被饿死。
  • 主 future 和所有任务跑在同一个线程上,任何一次 poll 卡住,整个运行时都停住。

💬 关联讨论

发起讨论
加载中...