不要阻塞运行时

让我们回到放弃点的概念上来。与线程不同,Rust的任务不能被抢先

tokio本身不能决定暂停一个任务并代之以运行另一个任务。控制权仅当任务放弃时才会回到执行器——也就是说,当Future::poll返回Poll::Pending,或者在async fn的情况下,当你.await一个future时。

这使运行时面临风险:如果一个任务从不放弃,运行时就永远不会能够运行另一个任务。这被称为阻塞运行时

什么是阻塞?

多久算太久?一个任务在不放弃之前可以花费多少时间才成为问题?

这取决于运行时、应用程序、正在执行的任务数量以及许多其他因素。但作为一个经验法则,尽量在放弃点之间花费少于100微秒。

后果

阻塞运行时可能导致:

  • 死锁:如果永不放弃的任务正在等待另一个任务完成,而那个任务又在等待第一个任务放弃,就会形成死锁。除非运行时能够在不同的线程上调度其他任务,否则无法取得进展。
  • 饥饿:其他任务可能无法运行,或者可能在长时间延迟后才运行,这可能导致性能不佳(例如,高尾部延迟)。

阻塞并不总是显而易见

有些操作通常应该在异步代码中避免,比如:

  • 同步I/O。你无法预测它将花费多长时间,而且很可能会超过100微秒。
  • 耗时的CPU密集型计算。

然而,后一类情况并不总是显而易见。例如,对含有少量元素的向量进行排序没有问题;但如果向量包含数十亿条目,这一评估就会改变。

如何避免阻塞

那么,假设你必须执行一个可能被视为阻塞或有风险的操作,你如何避免阻塞运行时呢?你需要将工作移到不同的线程上。你不希望使用所谓的运行时线程,即tokio用来运行任务的那些线程。

tokio为此目的提供了一个专用的线程池,称为阻塞池。你可以使用tokio::task::spawn_blocking函数在阻塞池上启动同步操作。spawn_blocking返回一个future,当操作完成后解析为操作的结果。

#![allow(unused)]
fn main() {
use tokio::task;

fn expensive_computation() -> u64 {
    // [...]
}

async fn run() {
    let handle = task::spawn_blocking(expensive_computation);
    // 同时做其他事情
    let result = handle.await.unwrap();
}
}

阻塞池是长期存在的。相较于直接通过std::thread::spawn创建新线程,spawn_blocking应该更快,因为线程初始化的成本在多次调用中被分摊了。

进一步阅读