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::tasks | VecDeque<Notified> | 跑调度器的那个线程自己 | 不需要同步,只有持有 Core 的线程能碰 |
Shared::inject | 侵入式链表加 Mutex | 其他线程,比如另一个线程里的 tx.send() 叫醒了这里的任务 | 加锁 |
VecDeque1.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 谁跑
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,等着的线程醒来接手。
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,原因在阻塞线程池一章。
问: 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,不睡。
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
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 上,但限制不了别的,见协作式调度预算。
还是 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 卡住,整个运行时都停住。