Rust in Everything
基于 tokio 1.54.1

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 与关闭一章读过。

五个结构

结构每个运行时有几个谁能碰放什么
Shared1所有线程注入队列、空闲 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,
}

Shared 和 Remote:

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 都会频繁读写这两个字段,挤在同一条缓存行上会互相拖慢(伪共享)。

📝NOTE
冷知识:这两个 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 放在这里。
💡TIP
打个比方:工位钥匙

一个 worker 是一个工位。工位上的待办篮子和手边那张加急单(本地队列的 Local 端、LIFO 槽)都锁在工位里,钥匙只有一把(Box<Core>)。没人坐的时候,钥匙挂在前台的钩子上(Worker::core);有人坐下干活,钥匙就揣在他口袋里(Context::core)。员工就是线程。篮子另外开了个小窗口(Remote 里的 Steal),别的工位的人可以从窗口里拿走一半待办,但拿不到那张加急单。

员工要出去办一件很久的事(block_in_place),就把钥匙挂回钩子,再叫一个人来顶班(spawn_blocking 一个执行 run(worker) 的线程)。顶班的人摘下钥匙坐下,工位上的活一直没停。

比方到这里为止:现实里出去办事的人回来就回自己的工位。这里不一定:他回来时先看钩子,钥匙还挂着就自己拿回去接着干,顶班的人后到,看见钩子空了就直接走;钥匙已经被顶班的人拿走,他就不再是 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,有两个用处:

  1. 被 poll 的任务里调 tokio::spawn,或者唤醒了别的任务,schedule_task 要从 Context 里拿到 Core,才能把新任务放进本地队列和 LIFO 槽。
  2. 被 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 线程的原因。

❗IMPORTANT
动脑筋: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 行)。

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

💬 关联讨论

发起讨论
加载中...