通过自动合作任务的产生来减少尾部延迟

Console是一个Rust异步调试工具。它的目标是让你在试图更好地理解你的异步任务的行为方式时,成为你所要使用的工具。

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 调用都是一个向调度器回馈(yield back)的机会。在上面的例子中,listener.accept().await 将返回一个等待中的套接字。如果没有待处理的套接字,控制权就会交还给调度器。

这个系统在大多数情况下运行良好。然而,当系统处于负载状态时,异步资源有可能始终处于准备状态。例如,考虑一个 echo 服务器:

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的用户要负责在应用程序和库中添加让出点(yield points)。在实践中,很少有人真正这样做,最终容易出现这种问题。

解决这个问题的一个常见办法是抢占(preemption)。对于正常的操作系统线程,内核会每隔一段时间就中断执行,以确保所有线程的公平调度。对执行有完全控制权的运行时(Go、Erlang等)也会使用抢占来确保任务的公平调度。这是通过在编译时注入让出点来实现的,让出点是指检查任务是否已经执行了足够长的时间,如果是,则让出于调度器的代码。不幸的是,Tokio不能使用这种技术,因为Rust的异步生成器没有为执行器(如Tokio)提供任何机制来注入这种让出点。

每个任务的操作预算

尽管Tokio不能抢占,但仍有机会促使一个任务让出调度器中。从0.2.14开始,每个Tokio任务都有一个操作预算。这个预算在调度器切换到任务时被重置。每个 Tokio 资源(套接字、定时器、通道…)都知道这个预算。只要任务有剩余的预算,资源就会像以前那样运行。每个异步操作(用户必须等待的操作)都会减少任务的预算。一旦任务超出预算,所有Tokio资源将永远返回 “未准备好”,直到任务返回到调度器。在这一点上,预算被重置,未来的Tokio资源的.await 将再次正常运行。

让我们回到上面的 echo 服务器的例子。当任务被调度时,它被分配的预算是每 “tick” 128个操作。选择128这个数字主要是因为它感觉很好,而且在我们测试的情况下(Noria和HTTP)似乎效果很好。当 socket.read(..)socket.write(..) 被调用时,预算被递减。如果预算为零,任务就会返回到调度器。如果由于底层套接字没有准备好(没有待处理的数据或发送缓冲区已满),读或写都不能进行,那么任务也会返回到调度器。

这个想法源于我和 Ryan Dahl 的一次谈话。他正在使用 Tokio 作为 Deno 的底层运行时。在前段时间用 Hyper 做一些 HTTP 实验的时候,他在一些基准测试中看到一些高的尾部延迟。这个问题是由于一个循环在负载下没有让出给调度器。在这种情况下,Hyper最终通过手工解决了这个问题,但Ryan提到,当他在 node.js 工作时,他们通过增加每个资源的限制来处理这个问题。所以,如果一个TCP套接字总是准备好的,它就会每隔一段时间就强制让出一次。我向 Jon Gjenset 提到了这个对话,他想出了将限制放在任务本身而不是每个资源上的想法。

最终的结果是,Tokio 应该能够在负载下提供更一致的运行时行为。虽然确切的启发式方法很可能会随着时间的推移而调整,但最初的测量表明,在某些情况下,尾部延迟几乎减少了3倍。

73222456-4a103300-4131-11ea-9131-4e437ecb9a04 2

“master” 是在自动让出之前,“preempt” 是在之后。点击查看大图,更多细节请参见原始PR评论。

关于阻塞的说明

虽然自动合作任务的让出在许多情况下提高了性能,但它不能抢占任务。Tokio的用户仍然必须注意避免CPU密集型工作和阻塞的API。spawn_blocking 函数可以用来 “异步化” 这些任务,在允许阻塞的线程池中运行它们。

Tokio不会,也不会试图检测阻塞的任务,并通过在调度器中添加线程来自动进行补偿。这个问题在过去已经出现过很多次了,所以请允许我详细说明。

就背景而言,我们的想法是让调度器包括一个监控线程。这个线程每隔一段时间就会轮询调度器线程,并检查工作者是否在取得进展。如果一个工作者没有取得进展,就会认为该工作者正在执行一个阻塞任务,并应生成一个新的线程来进行补偿。

这个想法并不新鲜。据我所知,这种策略的第一次出现是在 .NET 线程池中,而且是在10多年前引入的。不幸的是,这个策略有很多问题,正因为如此,它没有在其他线程池/调度器(Go、Java、Erlang等)中出现。

第一个问题是很难定义 “进度”。对进度的一个天真的定义是,一个任务是否已经被调度到某个时间单位以上。例如,如果一个工作者在调度同一个任务时被卡住超过100ms,那么这个工作者就会被标记为阻塞,并生成一个新的线程。在这个定义中,如何检测催生新线程会降低吞吐量的情况?这可能发生在调度器普遍处于负载状态的时候,增加线程会使情况变得更加糟糕。为了解决这个问题,.NET线程池采用爬坡法。这篇文章对它的工作原理做了很好的概述。

第二个问题是,任何自动检测策略都容易受到突发性或其他不平衡工作负载的影响。这个具体问题一直是.NET线程池的祸根,被称为 “停顿” 问题。爬坡策略需要一定的时间(数百毫秒)来适应负载变化。这段时间的需要,部分是为了能够确定增加线程是在改善情况,而不是使情况恶化。

停顿问题可以用.NET线程池来管理,部分原因是线程池被设计用来安排粗略的任务,即执行时间在几百毫秒到几十秒的任务。然而,在Rust中,异步任务调度器被设计用来调度那些最多应该在微秒到几十毫秒内运行的任务。在这种情况下,任何基于启发式调度器的呆滞问题都会导致更大的延迟变化。

在这之后,我收到的最常见的后续问题是 “Go调度器不会自动检测阻塞的任务吗?"。简短的回答是:没有。这样做会导致上面提到的同样的卡顿问题。而且,Go没有必要进行通用的阻塞任务检测,因为Go能够抢占。Go调度器所做的是注释潜在的阻塞性系统调用。这大致等同于Tokio的 block_in_place

简而言之,截至目前,刚刚介绍的自动合作任务让出策略是我们发现的减少尾部延迟的最佳方法。因为这个策略只需要Tokio的类型选择加入,终端用户不需要改变任何东西就可以获得这个好处。只需升级Tokio的版本就可以包括这个新功能。另外,如果从Tokio运行时之外使用Tokio的类型,它们的行为将和以前一样。

在这个问题上还有很多工作要做。目前还不清楚任务预算应该如何与 “子表程序”(如 FuturesUnordered)一起工作。任务预算API最终应该公开,以便第三方软件可以与之集成。如果能想出一个办法来普及这个概念,那么不仅仅是Tokio的用户可以利用这个概念,那也是很好的。

我们希望你在这次发布后发现你的尾部延迟有所改善。无论怎样,我们都有兴趣听到这个变化对现实世界的部署有什么影响。欢迎对这个问题发表评论。

—Carl Lerche

内容出处: https://tokio.rs/blog/2020-04-preemption