multi_thread 总览
主线位置:任务主线的「worker 取出任务」。这一章先认清参与者,后面几章再分别看队列、主循环和窃取。
flowchart TB
subgraph handle[Arc Handle:所有线程共享]
direction TB
shared[Shared]
remotes[remotes:每个 worker 一个 Remote]
inject[inject:全局注入队列]
idle[idle:谁在找活、谁在睡]
owned[owned:所有存活任务]
shared --- remotes
shared --- inject
shared --- idle
shared --- owned
end
subgraph worker[Arc Worker:每个 worker 一个]
direction TB
wcore[core:AtomicCell,Core 的停放处]
end
subgraph thread[某个线程的线程本地 Context]
direction TB
ccore[core:RefCell,正在用的 Core]
defer[defer:等着唤醒的 waker]
end
wcore -->|run 开始时取走| ccore
ccore -->|block_in_place 时放回| wcore
remotes -->|Steal 句柄,同一个环形缓冲| ccore
源码主要在 multi_thread/worker.rs,1500 多行,文件开头有一段关于关闭流程的长注释,OwnedTasks 与关闭一章读过。
五个结构
| 结构 | 每个运行时有几个 | 谁能碰 | 放什么 |
|---|---|---|---|
Shared | 1 | 所有线程 | 注入队列、空闲 worker 记录、OwnedTasks、每个 worker 的 Remote |
Remote | 每个 worker 1 个 | 所有线程 | 这个 worker 本地队列的 Steal 端;叫醒这个 worker 的 Unparker |
Worker | 每个 worker 1 个 | 所有线程 | worker 编号;Core 的停放处 |
Core | 每个 worker 1 个 | 同一时刻只有一个线程 | 本地队列的 Local 端、LIFO 槽、tick、统计、随机数、Parker |
Context | 每个 worker 线程 1 个 | 只有本线程 | 当前持有的 Core;defer 列表 |
Core 是 worker 的全部私有状态:
struct Core {
/// Used to schedule bookkeeping tasks every so often.
tick: u32,
/// When a task is scheduled from a worker, it is stored in this slot. The
/// worker will check this slot for a task **before** checking the run
/// queue. This effectively results in the **last** scheduled task to be run
/// next (LIFO). This is an optimization for improving locality which
/// benefits message passing patterns and helps to reduce latency.
lifo_slot: Option<Notified>,
/// When `true`, locally scheduled tasks go to the LIFO slot. When `false`,
/// they go to the back of the `run_queue`.
lifo_enabled: bool,
/// The worker-local run queue.
run_queue: queue::Local<Arc<Handle>>,
// ...
/// True if the worker is currently searching for more work. Searching
/// involves attempting to steal from other workers.
is_searching: bool,
/// True if the scheduler is being shutdown
is_shutdown: bool,
// ...
/// Parker
///
/// Stored in an `Option` as the parker is added / removed to make the
/// borrow checker happy.
park: Option<Parker>,
/// Per-worker runtime stats
stats: Stats,
/// How often to check the global queue
global_queue_interval: u32,
/// Fast random number generator.
rand: FastRand,
}
pub(crate) struct Shared {
/// Per-worker remote state. All other workers have access to this and is
/// how they communicate between each other.
remotes: Box<[Remote]>,
/// Global task queue used for:
/// 1. Submit work to the scheduler while **not** currently on a worker thread.
/// 2. Submit work to the scheduler when a worker run queue is saturated
pub(super) inject: CachePadded<InjectQueue<Arc<Handle>>>,
/// Coordinates idle workers
idle: CachePadded<Idle>,
/// Collection of all active tasks spawned onto this executor.
pub(crate) owned: OwnedTasks<Arc<Handle>>,
/// Data synchronized by the scheduler mutex
pub(super) synced: Mutex<Synced>,
// ...
}
/// Used to communicate with a worker from other threads.
struct Remote {
/// Steals tasks from this worker.
pub(super) steal: queue::Steal<Arc<Handle>>,
/// Unparks the associated worker thread
unpark: Unparker,
}
inject 和 idle 包了 CachePadded,让它们各占一条缓存行。所有 worker 都会频繁读写这两个字段,挤在同一条缓存行上会互相拖慢(伪共享)。
CachePadded 是 1.54.0 才加的#8622 给 Shared::inject 和 Shared::idle 包上了 CachePadded,目的是把这两块频繁访问的状态和相邻字段隔开。PR 给的数据:在 144 个 worker 的 NVIDIA Grace 机器上跑 tokio 自带的 remote_spawn 基准,一个外部线程提交 12,800 个任务,中位耗时从 17.51ms 降到 11.71ms,少了 33.1%;4 个提交线程时少了 20.5%;16 个时看不出明显好处。随 1.54.0 发布。
一个队列,拆成两个句柄
本地队列创建时就拆成两半(queue.rs 第 85 行):
pub(crate) fn local<T: 'static>() -> (Steal<T>, Local<T>) {
Local 放进 Core,Steal 放进 Remote。两个句柄指向同一个 Arc<Inner>,但能做的事不一样:
Local的push_back_or_overflow、pop都要&mut self。Core同一时刻只在一个线程手里,所以「只有一个生产者」由借用检查保证,不靠文档约定。Steal只有steal_into和几个查询,用&self,任何线程都能调。
这个分法决定了本地队列的算法:一个生产者、多个消费者,见本地队列。
Core 为什么能换线程
Core 有两个停放的地方:
Worker::core: AtomicCell<Core>:所有线程都能看到。AtomicCell<T>内部是一个AtomicPtr<T>,take和set都是一次原子交换,所以里面存的必须是Box<Core>,一个指针大小。Context::core: RefCell<Option<Box<Core>>>:线程本地,正在跑的 worker 线程把Core放在这里。
一个 worker 是一个工位。工位上的待办篮子和手边那张加急单(本地队列的 Local 端、LIFO 槽)都锁在工位里,钥匙只有一把(Box<Core>)。没人坐的时候,钥匙挂在前台的钩子上(Worker::core);有人坐下干活,钥匙就揣在他口袋里(Context::core)。员工就是线程。篮子另外开了个小窗口(Remote 里的 Steal),别的工位的人可以从窗口里拿走一半待办,但拿不到那张加急单。
员工要出去办一件很久的事(block_in_place),就把钥匙挂回钩子,再叫一个人来顶班(spawn_blocking 一个执行 run(worker) 的线程)。顶班的人摘下钥匙坐下,工位上的活一直没停。
worker 线程启动后执行 run,第一步就是从 Worker 里把 Core 取走:
// Acquire a core. If this fails, then another thread is running this
// worker and there is nothing further to do.
let core = match worker.core.take() {
Some(core) => core,
None => return,
};
取不到就直接返回:说明别的线程已经在当这个 worker 了。之后 Core 在 Context::run 里作为 Box<Core> 按值传来传去;每次 poll 任务之前,run_task 把它放进 Context::core,poll 完再取回来(第 717 行):
// Check if we still have the core. If not, the core was stolen
// by another worker.
let mut core = match self.core.borrow_mut().take() {
Some(core) => core,
None => {
// In this case, we cannot call `reset_lifo_enabled()`
// because the core was stolen. The stealer will handle
// that at the top of `Context::run`
return ControlFlow::Break(());
}
};
poll 期间把 Core 放进 Context,有两个用处:
- 被 poll 的任务里调
tokio::spawn,或者唤醒了别的任务,schedule_task要从Context里拿到Core,才能把新任务放进本地队列和 LIFO 槽。 - 被 poll 的任务可以把
Core拿走。block_in_place就是这么做的:把Core放回Worker::core,再起一个线程执行run(worker),新线程取到Core,接着当这个 worker。原来的线程继续执行阻塞代码。阻塞结束时,如果新线程还没来得及取走Core,原来的线程会把它拿回来接着当 worker;已经被取走的话,poll 返回后发现Core没了,run_task返回Break,这个线程就不再当 worker。细节在阻塞线程池。
worker 的家当(本地队列、LIFO 槽)都在 Core 里,编号在 Worker 上,线程只是暂时借用它们。这是 tokio 能在不停下调度的情况下让出一个 worker 线程的原因。
block_in_place 之后还在原来的线程上吗?M2 Max、rustc 1.99.0、tokio 1.54.1。只有 1 个 worker。任务先记下线程 ID,调 block_in_place 睡一会儿,再 yield_now 一次,然后比较线程 ID:
tokio::spawn(async move {
let before = thread::current().id();
tokio::task::block_in_place(|| thread::sleep(Duration::from_millis(sleep_ms)));
tokio::task::yield_now().await;
(before, thread::current().id())
})
sleep_ms 为 0 时重复 200 次,为 50 时重复 20 次。两种情况下,前后线程 ID 相同的各有几次?答案在本页末尾。
启动:worker 线程从阻塞池里来
worker::create 为每个 worker 建好 Core 和 Remote:
for _ in 0..size {
let (steal, run_queue) = queue::local();
let park = park.clone();
let unpark = park.unpark();
let metrics = WorkerMetrics::from_config(&config);
let stats = Stats::new(&metrics);
cores.push(Box::new(Core {
tick: 0,
lifo_slot: None,
lifo_enabled: !config.disable_lifo_slot,
run_queue,
// ...
park: Some(park),
global_queue_interval: stats.tuned_global_queue_interval(&config),
stats,
rand: FastRand::from_seed(config.seed_generator.next_seed()),
}));
remotes.push(Remote { steal, unpark });
// ...
}
park.clone() 给每个 worker 一个自己的 Parker(自己的状态和条件变量),但它们共享同一个驱动。驱动放在 TryLock 里,哪个 worker 睡觉时抢到了锁,就由它在驱动上等 IO 和定时器(任务窃取与空闲一章细讲)。
Core 建好后放进 Worker,再由 Launch::launch 启动:
pub(crate) fn launch(mut self) {
for worker in self.0.drain(..) {
runtime::spawn_blocking(move || run(worker));
}
}
worker 线程是用 spawn_blocking 起的,它们就是阻塞线程池里的线程,所以默认线程名和阻塞线程一样,都是 tokio-rt-worker(builder.rs 第 327 行)。为了不让 worker 占掉阻塞任务的名额,构建多线程运行时的时候,线程池上限设成 max_blocking_threads + worker_threads(第 2195 行)。
worker 的数量默认是 CPU 核数:先读环境变量 TOKIO_WORKER_THREADS,没有就用 std::thread::available_parallelism()(loom/std/mod.rs 第 85 行)。
TOKIO_WORKER_THREADS 是 1.24.0 才有的在那之前,默认 worker 数就是 CPU 核数,想改只能在代码里调 worker_threads。issue #4249 说,同一个服务二进制要部署到资源限制各不相同的环境里,代码里没法写死线程数,按 CPU 核数算的默认值又不符合这些限制,混合部署时尤其明显;提议仿照 Go 的 GOMAXPROCS 加一个环境变量。#4250 实现了它,随 1.24.0 发布。环境变量只改默认值:代码里调了 worker_threads,就用代码里的(builder.rs 第 2189 行)。
run 拿到 Core 后进入运行时上下文:
crate::runtime::context::enter_runtime(&handle, true, |_| {
// Set the worker context.
let cx = scheduler::Context::MultiThread(Context {
worker,
core: RefCell::new(None),
defer: Defer::new(),
});
context::set_scheduler(&cx, || {
let cx = cx.expect_multi_thread();
cx.run(core);
// Check if there are any deferred tasks to notify. This can happen when
// the worker core is lost due to `block_in_place()` being called from
// within the task.
cx.defer.wake();
});
});
enter_runtime 的第二个参数 true 允许 block_in_place。set_scheduler 把 Context 挂到线程本地,schedule_task 和 context::defer 就是从这里找到当前 worker 的。cx.run(core) 是 worker 主循环,见 worker 主循环。
在 debug 构建下,run 开头还放了一个 AbortOnPanic:worker 线程如果 panic,直接 abort 整个进程。任务里的 panic 早在 Harness 里被 catch_unwind 接住了(见 poll 与唤醒),能走到这里的只会是调度器自己的 bug。
动脑筋答案
完整程序(M2 Max、rustc 1.99.0、--release):
use std::thread;
use std::time::Duration;
fn main() {
for (sleep_ms, rounds) in [(0u64, 200), (50, 20)] {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
let (mut same, mut moved) = (0, 0);
for _ in 0..rounds {
let (before, after) = rt.block_on(async move {
tokio::spawn(async move {
let before = thread::current().id();
tokio::task::block_in_place(|| thread::sleep(Duration::from_millis(sleep_ms)));
tokio::task::yield_now().await;
(before, thread::current().id())
})
.await
.unwrap()
});
if before == after {
same += 1;
} else {
moved += 1;
}
}
println!("sleep {sleep_ms}ms: same thread {same}, other thread {moved}");
}
}
连跑 3 次,输出都是:
sleep 0ms: same thread 200, other thread 0
sleep 50ms: same thread 0, other thread 20
block_in_place 先把 Core 放回 Worker::core(第 498 行),再 spawn_blocking 一个执行 run(worker) 的线程(第 506 行)。阻塞代码跑完,Reset 的 Drop 会试着把 Core 拿回来(第 380 行),源码注释写的是「we will attempt to steal the core back」。
- 0ms,200 次都相同。 阻塞代码几乎立刻结束,这时新线程还没起来或还没被叫醒,
Core还挂在Worker::core上,原来的线程把它拿了回去,接着当 worker。之后yield_now让出,任务再次被 poll 时还在这个线程上。晚到的run(worker)在worker.core.take()拿到None,直接返回。 - 50ms,20 次都不同。 50ms 足够新线程拿走
Core,Reset拿到的是None。poll 返回后run_task发现Core没了,返回Break,原来的线程不再是 worker。让出的任务进了注入队列,由新的 worker 线程 poll,线程 ID 就变了。
这两个数是时序决定的,不是保证:0ms 时只要新线程抢先一步,原来的线程就拿不回 Core。这台机器上 3 次运行结果都一样。
小结
- 共享的东西放在
Shared里:注入队列、空闲记录、OwnedTasks,以及每个 worker 一个Remote(窃取句柄和叫醒句柄)。 - 每个 worker 的私有状态都在
Core里:本地队列的Local端、LIFO 槽、tick、统计、Parker。同一时刻只有一个线程持有它。 - 本地队列拆成
Local和Steal两个句柄,「只有一个生产者」由&mut self保证。 Core在Worker::core(AtomicCell)和线程本地的Context::core之间交接;poll 期间放进Context,任务可以借此把整个 worker 交给别的线程。- worker 线程是用
spawn_blocking起的,阻塞池的上限因此是max_blocking_threads + worker_threads。