运行时
到目前为止,我们一直在将异步运行时作为一个抽象概念进行讨论。让我们深入了解一下它们的实现方式——很快你就会发现,这对我们的代码有影响。
类型
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}") } }) } }
另一方面,Send
是tokio
的工作窃取策略的直接结果:在一个线程A
上派生的任务,如果线程B
空闲,可能会被移动到B
上执行,因此需要Send
约束,因为我们在跨越线程边界。
#![allow(unused)] fn main() { fn spawner(input: Rc<u64>) { // 这也不行,因为`Rc`不是`Send`。 tokio::spawn(async move { println!("{}", input); }) } }