运行时

到目前为止,我们一直在将异步运行时作为一个抽象概念进行讨论。让我们深入了解一下它们的实现方式——很快你就会发现,这对我们的代码有影响。

类型

tokio提供了两种不同类型的运行时风味(flavors)**。

你可以通过tokio::runtime::Builder配置你的运行时:

  • Builder::new_multi_thread为你提供了一个多线程的tokio运行时
  • Builder::new_current_thread则依赖于当前线程进行执行。

默认情况下,#[tokio::main]返回一个多线程运行时,而#[tokio::test]则直接使用当前线程的运行时。

当前线程运行时

顾名思义,当前线程运行时完全依赖于启动它的操作系统线程来调度和执行任务。使用当前线程运行时,你拥有并发性但没有并行性:异步任务会交错执行,但在任何给定的时间最多只有一个任务在运行。

多线程运行时

而使用多线程运行时,可以在任意给定时间最多有N个任务并行运行,这里的N是运行时使用的线程数量。默认情况下,N等于可用CPU核心的数量。

不仅如此,tokio还执行工作窃取。如果一个线程空闲,它不会闲置:它会尝试找到一个新的、准备好执行的任务,这可以从全局队列中获取,或者从另一个线程的本地队列中窃取。工作窃取在性能上有显著的好处,特别是在尾部延迟上,尤其是当你的应用程序处理的工作负载在各线程间并非完美平衡时。

影响

tokio::spawn是不受运行时风味限制的:无论你是在多线程还是当前线程运行时上运行,它都能工作。缺点是它的签名假定了最坏的情况(即多线程)并据此进行了约束:

#![allow(unused)]
fn main() {
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static,
{ /* */ }
}

我们暂时忽略Future特质,专注于其余部分。spawn要求所有输入都是Send且具有'static生命周期。

'static约束遵循与std::thread::spawn'static约束相同的理由:被派生的任务可能会超出它被派生的上下文的生命周期,因此它不应该依赖任何可能在其派生上下文被销毁后被析构的局部数据。

#![allow(unused)]
fn main() {
fn spawner() {
    let v = vec![1, 2, 3];
    // 这样不行,因为`&v`生命周期不够长。
    tokio::spawn(async { 
        for x in &v {
            println!("{x}")
        }
    })
}
}

另一方面,Sendtokio的工作窃取策略的直接结果:在一个线程A上派生的任务,如果线程B空闲,可能会被移动到B上执行,因此需要Send约束,因为我们在跨越线程边界。

#![allow(unused)]
fn main() {
fn spawner(input: Rc<u64>) {
    // 这也不行,因为`Rc`不是`Send`。
    tokio::spawn(async move {
        println!("{}", input);
    })
}
}