不要阻塞运行时
让我们回到放弃点的概念上来。与线程不同,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
应该更快,因为线程初始化的成本在多次调用中被分摊了。
进一步阅读
- 可以查阅Alice Ryhl的博客文章,了解更多关于这个主题的内容。