项目织物:使用虚拟线程时如何提高性能?

27

为了提供一些背景信息,我已经关注Project Loom有一段时间了。我阅读过Loom的现状,并且已经进行了异步编程。

Java NIO提供的异步编程会在任务等待时将线程返回到线程池,并尽力避免阻塞线程。这带来了很大的性能提升,我们现在可以处理更多的请求,因为它们不直接受操作系统线程数量的限制。但是我们在这里失去的是上下文。一旦我们将任务与线程分离,同一任务就不再与一个线程相关联。异常跟踪没有提供非常有用的信息,调试也变得困难。

这就是Project Loom引入了虚拟线程作为并发的单个单位的原因。现在,您可以在单个虚拟线程上执行一个单个任务。

到目前为止都还好,但文章继续声明,使用Project Loom:

一个简单的同步Web服务器将能够处理更多的请求而无需更多的硬件。

我不明白我们如何通过Project Loom获得比异步API更好的性能?异步API确保不会让任何线程空闲。那么,Project Loom做了什么使它比异步API更高效和更具性能?

编辑

让我重新表述这个问题。假设我们有一个HTTP服务器,它接收请求,并与持久化数据库进行一些crud操作。假设,该HTTP服务器处理了很多请求 - 100K RPM。有两种实现方式:
  1. HTTP服务器具有专用线程池。当请求到达时,线程执行任务,直到任务传递到数据库,此时任务必须等待来自数据库的响应。此时,线程返回到线程池,并继续执行其他任务。当数据库响应时,由线程池中的某个线程处理,并返回HTTP响应。
  2. HTTP服务器为每个请求生成虚拟线程。如果有IO,则虚拟线程只需等待任务完成即可。然后返回HTTP响应。基本上,对于虚拟线程没有池业务正在进行。
假设硬件和吞吐量保持不变,任何一种解决方案在响应时间或处理更多吞吐量方面是否表现更好? 我猜在性能方面不会有任何区别。

4
你得到的答案很简短,但言简意赅。除此之外,你已经链接到了一份详细解释这个概念的文档。我建议阅读它,特别是其中解释虚拟线程如何在另一个执行器上运行,比如线程池,以及同步调用如何被异步对应物所替代的部分。这使得第二种方法在底层转变为第一种方法。因此,你希望从赏金中获得什么额外的信息呢? - Holger
1
请注意,Oracle 公司的 Ron Pressler 在 2020 年晚些时候关于 Project Loom 技术的演示:这里这里 - Basil Bourque
3个回答

16

我们不能从异步API中获得好处。我们可能会获得类似于异步代码的性能,但使用同步代码。


5
恰是如此。编写同步代码通常更容易,因为您不必在每次无法取得前进时都不断编写代码来放下并拾起东西。直接的“做这个,然后做那个,如果发生这种情况做另一件事”代码比更新显式状态的状态机更容易编写(且维护起来要容易得多)。虚拟线程可以为您提供大部分异步代码的好处,同时您的编码体验更接近编写同步代码。 - David Schwartz
4
@DavidSchwartz 当涉及到分析异常或调试代码时,这更为正确。此外,使用虚拟线程取消正在进行的操作要容易得多。而线程本地变量也将按预期工作。但这些在链接问题的文档中已经解释过了... - Holger

9
talex的答案精辟地阐述了问题,我在此基础上做进一步解释。
Loom更多关注于本机并发抽象,同时帮助人们编写异步代码。由于它是一种虚拟机层面的抽象,而不仅仅是代码层面的(如我们之前使用的CompletableFuture等),它可以实现异步行为,但减少了样板代码。
使用Loom时,“更强大的抽象是救世主”。我们反复看到抽象和语法糖如何使人有效地编写程序,无论是JDK8中的FunctionalInterfaces,还是Scala中的for循环。
使用Loom时,不需要链接多个CompletableFuture以节省资源,而是可以同步编写代码。每次遇到阻塞操作(ReentrantLock、I/O、JDBC调用)时,虚拟线程就会被挂起。由于这些都是轻量级线程,上下文切换的成本非常低,与内核线程有所区别。
当被阻塞时,实际的载体线程(正在运行虚拟线程的run体)会执行其他虚拟线程的运行。因此,载体线程不是闲置的,而是在执行其他工作。当解除挂起时,它会回到原始虚拟线程的执行。就像线程池一样工作。但是,在这里,您只有一个单独的载体线程在执行多个虚拟线程的run体,当阻塞时从一个切换到另一个。
我们可以获得与手动编写异步代码相同的行为(因此也具有相同的性能),但避免了编写相同内容的模板。
考虑一个Web框架的情况,其中有一个专门用于处理I/O和另一个用于执行HTTP请求的线程池。对于简单的HTTP请求,可以直接在HTTP线程池线程本身中处理该请求。但是,如果有任何阻塞(或高CPU占用)操作,我们会让这个活动异步在另一个线程上发生。
这个线程将从传入请求收集信息,生成一个CompletableFuture,并将其链接到管道(从数据库中读取为一阶段,然后计算它,然后再写回到数据库、Web服务调用等)。每个都是一个阶段,返回的CompletableFuture将被返回到Web框架。
当结果完成时,Web框架使用结果将其传递回客户端。这就是Play-Framework和其他框架处理的方式。在HTTP线程池处理和每个请求的执行之间提供隔离。但是,如果我们深入研究一下,为什么要这样做呢? 一个核心原因是有效地使用资源。尤其是在阻塞调用时。因此我们使用thenApply等方法链接起来,以便没有线程被阻塞在任何活动上,可以用更少的线程做更多的事情。
这很好用,但很啰嗦。调试确实很痛苦,如果中间阶段出现异常,控制流就会失控,导致需要更多的代码来处理。有了 Loom,我们编写同步代码,让其他人在被阻塞时决定该做什么。而不是睡眠并无所事事。

1
这个很好用,但是有点啰嗦。而且调试确实很痛苦,如果中间某个阶段出现异常,控制流就会失控,导致需要编写更多的代码来处理它。 - Ashwin
1
是的。话虽如此,我认为未来对于其他几种情况是不可避免的......特别是当一个人想要进行并行活动时(比如进行多个Web服务调用并合并结果)。但是每个活动都可以是同步的。 - Jatin
@Jatin,关于CompletableFuture的这部分内容是误导性的:“一个核心原因是有效地使用资源。特别是阻塞调用。因此,我们使用thenApply等链式操作,以便没有线程在任何活动上被阻塞,并且我们可以使用更少的线程做更多的事情。” CompletableFuture并不能通过系统线程实现任何魔法。我们无法通过较少的线程来保持吞吐量可扩展性 - supplyAsync中的completable task supplier将阻塞它所在的线程(从提供的执行器或默认的ForkJoinPool中借用)。 - RoK
@Jatin,但是通过newVirtualThreadPerTaskExecutor使用虚拟线程,即使只有一个线程,我们确实可以从中受益,从而为CF供应商提供更少的线程。此外,使用属性jdk.virtualThreadScheduler.maxPoolSize = 1。 - RoK

4
  1. Http服务器有一个专门的线程池来处理请求....那么线程池需要多大呢? (CPU核数)*N + C?N>1时,会出现反向扩展,因为锁争用会延长延迟;而当N=1时,可用带宽无法充分利用。这里有一篇很好的分析(链接)

  2. Http服务器仅仅是生成...这将是这个概念非常幼稚的实现。一个更加现实的实现应该努力收集一个动态池,其中保留了每个阻塞系统调用所需的一个真实线程+每个真实CPU所需的一个真实线程,至少Go背后的人们是这么想的。

关键在于让{处理程序、回调、完成、虚拟线程、goroutines: 都是pod中的PEA}不要互相竞争内部资源,除非绝对必要,否则它们不会依赖于基于系统的阻止机制。这属于锁避免的范畴,可能通过各种排队策略(见libdispatch)等实现。请注意,这将使PEA与底层系统线程分离,因为它们在内部之间进行复用。这就是你对概念分离的担忧。在实践中,您可以传递您最喜欢的语言的上下文指针的抽象。

1所示,这种方法可以直接链接到具体结果以及一些无形的结果。锁定很容易 - 你只需要在你的事务周围做一个大锁,然后你就可以开始了。但这不会扩展;而细粒度锁定则很难。很难让它正常工作,难以选择颗粒度。何时使用{锁、CV、信号量、屏障......}在教科书例子中很明显;在深度嵌套的逻辑中则略微不同。锁避免在很大程度上消除了这个问题,仅限于争用的叶组件,如malloc()。

我对此持有一些怀疑态度,因为研究通常显示出缩放效果不佳的系统被转换为锁避免模型,然后显示出更好的效果。我还没有看到过一个将一些经验丰富的开发人员解放出来,分析系统的同步行为,使其可扩展性,并进行测量的案例。但即使那是一个胜利,经验丰富的开发人员也是一个相对稀缺且昂贵的商品;可扩展性的核心实际上是财务。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接