关于用户模式线程和协程的性能

关于协程和用户模式线程的讨论——比如 Project Loom 的虚拟线程 或 Go 的协程——经常转向性能主题。我将尝试在这里回答的问题是,用户模式线程如何提供比操作系统线程更好的应用程序性能?

一个常见的假设是,这与任务切换成本有关,而用户模式线程和协程的性能优势——这两者在与此次讨论相关的方面非常相似,因此我将它们互换处理——是由于它们与操作系统线程相比具有较低的任务切换开销。因此,一些人进一步询问,如果有的用户模式线程与异步编程(使用回调或某些异步组合,比如 CompletableFutureFlow/反应流)相比增加了多少任务切换开销,以及任务切换成本与异步编程相比是否有害或提高了性能。但正如我们所见,在它们最常见、最有用的用例中,用户模式线程(或协程)的性能优势与任务切换成本几乎无关。它们的力量来自其他地方。

性能是一个复杂的问题,所以首先——几个定义。吞吐量是一个性能指标,定义为每时间单位完成的操作数;延迟是另一个性能指标,表示某个操作完成所需的时间。例如,通过将数据放在闪存驱动器上并通过 747 飞机将它们运送到纽约,从伦敦发送数据到纽约将遭受较差(高)的延迟,但可能具有相当好(高)的吞吐量。

另一个重要概念是我将称之为影响。它是某个操作对整体性能的贡献,并且是衡量优化该操作的重要性的一种方法。如果某个操作占应用程序中总时间花费的 1%,那么让该操作无限快地工作只会将应用程序的总性能提高 1%,因此此操作的影响很低。影响高度依赖于应用程序,但我们将讨论常见类型的应用程序,并按用例对影响进行分类。

考虑协程的一个用例:生成器——编写迭代器的便捷方式。假设一个生成器向一个对它们求和的使用者发出一个递增整数序列;每次生成一个数字时,都会在生成器和使用者之间进行任务切换。至关重要的是,此场景只涉及纯计算,而且非常短暂。我们称之为“纯计算”用例。吞吐量是每秒求和的整数数。在此场景中,任务切换操作的影响非常大。如果我们认为处理时间(递增数字并对它们求和)为零,则任务切换开销的影响为 100%。

现在考虑另一个用例,我们称之为“事务处理”:服务器等待并响应通过网络到达的请求。当请求到达时,服务器通过进行一些计算以及通过网络联系其他辅助服务(比如数据库)来处理它;它向那些其他服务发送请求,收集它们的响应,最后以一些汇总结果回复客户端。此系统的吞吐量是服务器每秒处理的传入请求数。

如果请求不会无限期堆积,则我们的服务器被称为稳定,因此响应速率等于请求速率。为了分析其吞吐量,我们转向Little 定律,该定律指出,在稳定系统中,平均并发级别L — 服务器同时处理的请求数量 — 等于平均请求速率,λ,乘以每个请求的平均处理持续时间,W

L = λW

由于系统稳定,其吞吐量等于λ,并且可实现的最大λ是系统的容量。该定律很简单,但实际上很显著,因为结果不依赖于请求到达的分布。

为了简化问题,让我们像以前一样假设所有计算都花费零时间,并且只考虑通过网络联系辅助服务所需的时间,因为我们预计该成本会主导延迟,W。有两个细微差别需要处理:首先,如果我们的服务器使用可以并行处理的多个内核,我们可以将每个内核视为一个单独的服务器;因此,在不失一般性的情况下,我们假设我们的服务器是单核的(确实,内核共享网络接口,但我们将忽略此复杂性)。其次,如果在处理请求的过程中,我们必须使用三个网络服务(每个服务以 1 毫秒响应),我们可以通过并行执行它们将收集其响应的总延迟从 3 毫秒减少到 1 毫秒。这将W减少了三倍,但与远程服务的交互还增加了三个子操作,从而将我们的并发级别L也增加了三倍,并且两者相互抵消。因此,为了使用 Little 定律,我们应该将W视为所有传出请求持续时间的总和。因此,我们的吞吐量为

λ = L/W

我们现在准备计算任务切换的影响。W是我们服务请求的延迟之和,由于我们讨论的是平均值,因此它是每个事务的平均服务调用次数(我们称之为n)乘以平均服务调用延迟(w),因此W = nw

为了在单个内核上同时处理多个请求,当我们等待来自服务的响应时,我们会切换到另一个事务,因此每个传出调用都伴随着一次任务切换。如果平均任务切换延迟为t,服务响应的平均等待时间为μ,则w = µ + tW = n(µ + t)。任务切换t对我们的吞吐量λ的影响(如果任务切换绝对不花费任何成本,我们将获得多少容量)是

(L/n(µ + 0)) / (L/n(µ + t)) = n(µ + t)/nµ = (µ + t)/µ = 1 + t/µ

如果我们选择在每次等待服务时都会阻塞的单个操作系统线程上处理每个事务,则通过内核进行任务切换(一项缓慢的操作,大约需要t = 1 微秒)——即使我们的服务和网络非常快,并且为我们提供了平均服务调用延迟μ,为 20 微秒,任务切换的影响也是 1/20。通过优化任务切换,我们所能希望获得的最佳结果是将我们的容量增加 5%!更一般地说,任务切换的影响是其平均延迟除以平均等待时间。如果涉及等待网络 IO,即使任务切换相对低效,该比率也可能相当低。

显然,为了显著提高此类系统的容量,我们不应该专注于降低t,这只会适度降低延迟W并增加吞吐量λ,而应该专注于增加L,即我们可以同时处理的事务数量。如果我们保留简单的每请求一个线程的编程模型并在单个线程上处理每个事务,那么L将是我们需要的活动线程数。而这就是用户模式线程提供帮助的方式:它们通过数百万个用户模式线程(而不是操作系统可以支持的数千个线程)将L提高了几个数量级(但不要指望容量增加 1000 倍;我们忽略了计算成本,并且一定会遇到辅助服务中的瓶颈)。异步编程也以相同的方式提高性能:不是通过降低任务切换成本,而是通过增加同时处理的事务数量L,只是它不使用线程而是使用不同的构造来表示每个事务。

尽管如此,任务切换开销仍然很重要。如果N是每个事务的任务切换次数,则W = nµ + Nt,任务切换对吞吐量的实际影响为

Nt / nµ

我们假设每个服务调用一个任务切换,因此N = n,但是当我们有如此多的线程可供使用时,通过传递消息相互通信来处理事务非常方便。这会将每个事务的任务切换次数增加到远远超过每个服务调用一次,因此保持任务切换成本较低仍然是一个好主意。这很重要,但没有我们天真地认为的那么重要。

我们认为,事务处理是用户模式线程或协程在 Java 中的一个更重要的用例,当然比纯计算用例更重要,因此,虽然 Loom 努力保持虚拟线程的任务切换成本较低,但这并不是它对我们心目中应用程序的性能的主要贡献,也不是其主要优势来源。如果在针对事务处理用例的便利性和任务切换开销之间出现冲突,我们会优先考虑前者,原因我希望现在已经很清楚了。

最后,关于实现的几句话:从一种语言中协程或用户模式线程的实现质量推断到另一种语言的实现质量的可能性很小。一些语言允许指向局部变量的指针,而另一些语言不允许。在一些语言中,分配很便宜,并且可以隐藏,而在另一些语言中,分配可能很昂贵和/或需要显式管理。而且,正如我们所看到的,目标用例可能不同。针对所有参与某个作业的协程都可能适合 CPU 缓存(如生成器)的情况进行优化的设计可能不适合涉及大量任务并且任务切换总是会导致缓存未命中的用例。判断实现的优点需要在它寻求优化的用例的上下文中对其进行评估,以及它所针对的语言/运行时的特殊约束和优势。但这又是另一个讨论的话题了。