使用自动协作任务让步减少尾部延迟
2020年4月1日
Tokio 是一个用于异步 Rust 应用程序的运行时。它允许使用 async
& await
语法编写代码。例如
let mut listener = TcpListener::bind(&addr).await?;
loop {
let (mut socket, _) = listener.accept().await?;
tokio::spawn(async move {
// handle socket
});
}
Rust 编译器将此代码转换为状态机。Tokio 运行时执行这些状态机,在一个少量的线程上多路复用许多任务。Tokio 的调度器要求生成任务的状态机将控制权交还给调度器,以便多路复用任务。每个 .await
调用都是将控制权交还给调度器的机会。在上面的例子中,如果有一个挂起的套接字,listener.accept().await
将返回一个套接字。如果没有挂起的套接字,控制权将交还给调度器。
这个系统在大多数情况下都运行良好。然而,当系统负载过重时,异步资源可能总是准备就绪。例如,考虑一个回声服务器
tokio::spawn(async move {
let mut buf = [0; 1024];
loop {
let n = socket.read(&mut buf).await?;
if n == 0 {
break;
}
// Write the data back
socket.write(buf[..n]).await?;
}
});
如果数据的接收速度快于处理速度,则在数据块处理完成时,可能已经接收到更多的数据。在这种情况下,.await
将永远不会将控制权交还给调度器,其他任务将不会被调度,从而导致饥饿和大的延迟差异。
目前,解决这个问题的方法是 Tokio 的用户负责在应用程序和库中添加 让步点。实际上,很少有人真正这样做,最终容易受到这类问题的影响。
解决此问题的常见方案是抢占。对于普通的操作系统线程,内核会定期中断执行,以确保所有线程的公平调度。完全控制执行的运行时(Go、Erlang 等)也将使用抢占来确保任务的公平调度。这是通过注入让步点来实现的——代码检查任务是否已执行足够长的时间,如果是,则让步回调度器——在编译时。不幸的是,Tokio 无法使用这种技术,因为 Rust 的 async
生成器没有为执行器(如 Tokio)提供注入此类让步点的任何机制。
每个任务的操作预算
即使 Tokio 无法抢占,仍然有机会推动任务让步回调度器。从 0.2.14 版本开始,每个 Tokio 任务都有一个操作预算。当调度器切换到任务时,此预算将被重置。每个 Tokio 资源(套接字、定时器、通道...)都知道此预算。只要任务有剩余预算,资源就会像以前一样运行。每个异步操作(用户必须 .await
的操作)都会减少任务的预算。一旦任务超出预算,所有 Tokio 资源将永久返回“未就绪”,直到任务让步回调度器。届时,预算将被重置,并且将来对 Tokio 资源的 .await
将再次正常运行。
让我们回到上面回声服务器的例子。当任务被调度时,它被分配了每个“滴答” 128 个操作的预算。选择数字 128 主要是因为它感觉良好,并且似乎在我们正在测试的案例中效果良好(Noria 和 HTTP)。当调用 socket.read(..)
和 socket.write(..)
时,预算会减少。如果预算为零,则任务让步回调度器。如果由于底层套接字未就绪(没有挂起的数据或发送缓冲区已满)而导致 read
或 write
无法继续,则任务也会让步回调度器。
这个想法起源于我与 Ryan Dahl 的一次对话。他正在使用 Tokio 作为 Deno 的底层运行时。在一段时间前使用 Hyper 进行一些 HTTP 实验时,他看到一些基准测试中的尾部延迟很高。问题是由于循环在负载下没有让步回调度器。Hyper 最终 手动修复 了这一个案例中的问题,但 Ryan 提到,当他在 node.js 上工作时,他们通过添加每个资源的限制来处理这个问题。因此,如果 TCP 套接字总是准备就绪,它会每隔一段时间强制让步。我向 Jon Gjenset 提到了这次对话,他提出了将限制放在任务本身而不是每个资源上的想法。
最终结果是 Tokio 应该能够在负载下提供更一致的运行时行为。虽然确切的启发式方法很可能会随着时间的推移进行调整,但初步测量表明,在某些情况下,尾部延迟减少了近 3 倍。
“master” 是在自动让步之前,“preempt” 是在之后。单击以获得更大的版本,另请参阅原始 PR 评论 以了解更多详细信息。
关于阻塞的说明
虽然自动协作任务让步在许多情况下提高了性能,但它不能抢占任务。Tokio 的用户仍然必须注意避免 CPU 密集型工作和阻塞 API。spawn_blocking
函数可用于通过在允许阻塞的线程池上运行这些类型的任务来“异步化”它们。
Tokio 不会,也不会尝试检测阻塞任务并通过向调度器添加线程来自动补偿。过去已经多次提出这个问题,所以请允许我详细说明。
对于上下文,这个想法是让调度器包含一个监控线程。该线程会定期轮询调度器线程,并检查工作线程是否正在取得进展。如果工作线程没有取得进展,则假定该工作线程正在执行阻塞任务,并且应生成一个新线程来补偿。
这个想法并不新鲜。我所知的这种策略的第一次出现是在 .NET 线程池中,并且是在十多年前引入的。不幸的是,该策略存在许多问题,因此它没有在其他线程池/调度器(Go、Java、Erlang 等)中得到应用。
第一个问题是很难定义“进展”。对进展的朴素定义是任务是否已被调度超过某个时间单位。例如,如果一个工作线程被卡住调度同一个任务超过 100 毫秒,那么该工作线程将被标记为阻塞,并生成一个新线程。在这个定义中,如何检测到生成新线程降低吞吐量的场景?当调度器通常处于负载状态并且添加线程会使情况更糟时,可能会发生这种情况。为了解决这个问题,.NET 线程池使用 爬山算法。这篇文章很好地概述了它的工作原理。
第二个问题是任何自动检测策略都容易受到突发性或不均匀工作负载的影响。这个具体问题一直是 .NET 线程池的祸根,被称为 “口吃”问题。爬山算法需要一段时间(数百毫秒)来适应负载变化。部分原因是需要这段时间来确定添加线程正在改善情况而不是使情况更糟。
口吃问题可以在 .NET 线程池中得到管理,部分原因是该池旨在调度粗粒度任务,即执行时间在数百毫秒到几秒钟数量级的任务。但是,在 Rust 中,异步任务调度器旨在调度应该在微秒到最多数十毫秒数量级内运行的任务。在这种情况下,来自基于启发式的调度器的任何口吃问题都会导致更大的延迟变化。
在此之后我收到的最常见的后续问题是“Go 调度器不是会自动检测阻塞任务吗?”。简短的回答是:不。这样做会导致与上面提到的相同的口吃问题。此外,Go 不需要进行通用的阻塞任务检测,因为 Go 能够抢占。Go 调度器确实做的是注释潜在的阻塞系统调用。这大致相当于 Tokio 的 block_in_place
。
简而言之,到目前为止,刚刚引入的自动协作任务让步策略是我们为减少尾部延迟找到的最佳策略。由于此策略仅要求 Tokio 的类型选择加入,因此最终用户无需更改任何内容即可获得此好处。只需升级 Tokio 版本即可包含此新功能。此外,如果从 Tokio 运行时外部使用 Tokio 的类型,它们的行为将与以前一样。
在这个主题上应该进行更多的工作。任务预算应如何与“子调度器”(例如 FuturesUnordered
)一起工作仍然不清楚。任务预算 API 最终应该公开,以便第三方库可以与它们集成。如果能够找到一种方法来推广这个概念,以便不仅仅是 Tokio 用户可以从中受益,那也很不错。
我们希望在此版本发布后,您的尾部延迟能够得到改善。无论如何,我们都有兴趣了解此更改如何影响实际部署。请随时在 这个 issue 上发表评论。