硬核!Rust异步编程方式重大升级:新版Tokio如何提升10倍性能详解

导读:协程或者绿色线程是近年来经常讨论的话题。Tokio作为Rust上协程调度器实现的典型代表,其设计和实现都有其特色。本文是Tokio团队在新版本调度器发布后,对其设计和实现的经验做的总结,十分值得一读。


Tokio——作为 Rust 语言的异步运行时,我们一直在为它的下一个大版本发布而努力。今天,伴随着 Pull request 的提交这个成果终于可以呈现出来:一个完全重写的调度器,带来巨大的性能提升。在一些性能基准测试中表现出10倍的提升,此外我们也对一些容易受到影响的“用例”,比如 Hyper 及 Tonic,做了额外的测试,以验证新的调度器是否如预期表现。(当然我们可以提前剧透下:结果非常棒!)

在我们着手之前,我花了大量时间去寻找其他可参考的调度器实现及其他信息,但是基本上除了(代码)实现本身,并没有发现太多有用的资料。同时我还发现,现有的大部分调度器实现,代码晦涩难懂。所以在新版 Tokio 的实现过程中,我始终提醒自己确保代码实现易读易懂。之所以写这篇关于调度器实现的详细文章,也是希望能帮助到其他人。

本文会从调度器的设计展开,然后再围绕新版调度器的一些特定细节。包括以下一些部分:

  • 新的 std:future任务系统(task system)

  • 更好的队列算法

  • 如何优化消息传递模式

  • 改进的“任务窃取”算法(throttle-stealing)

  • 减少跨线程同步

  • 减少内存分配

  • 减少原子的引用计数


可以看出来新的设计实现是围绕“减法”,有句话说:“没有什么代码比无代码更快”,话糙理不糙。

本文还覆盖了我们如何去测试新的调度器,我们都知道设计和实现出正确的、无锁的、并发编程是非常困难和有挑战的。毕竟,慢总好过有缺陷,特别是和内存安全有关的缺陷。所以我们为新调度器还设计开发了一个叫做 loom 的并发测试工具。

接下来,我建议读者们可以接杯咖啡,把座椅调整舒服,这将是一篇很长但需要集中注意力的文章。


调度器是如何工作的?


调度器,顾名思义,就是如何调度程序执行。通常来说,程序会分成许多“工作单元”,我们将这种工作单元成为任务(task)。一个任务要么是可运行的,要么是挂起的(空闲的或阻塞的)。任务是彼此独立的,因为处在“可运行的”任务都可能被并发的执行。调度器的职责就是执行任务,直到任务被挂起。这个过程中隐含得本质就是如何为任务分配全局资源——CPU 时间。

接下来的内容里只是围绕“用户空间”的调度器,有操作系统基础知识的读者应该明白,指的是运行于操作系统线程之上的调度器,而操作系统线程则是由内核调度器所调度。

Tokio 调度器会执行 Rust 的 future,就像我们讨论 Java 语言、Go 语言等线程模型时一样,Rust 的 future可以理解为 Rust 语言的“异步绿色线程”,它是 M:N 模式,很多用户空间的任务通过多路复用跑在少量的系统线程上。

调度器的设计模式有很多种,每种都有各自的优缺点。但本质上,可以将调度器抽象得看作是一个(任务)队列,以及一个不断消费队列中任务的处理器,我们可以用伪代码表示成如下形式:

 
 
while let Some(task) = self.queue.pop() {
         task.run();}


当任务变成“可运行”的,就被插入到队列中:

640?wx_fmt=png


虽然我们可以设计成将资源、任务以及处理器都存在于一个单独线程中,但 Tokio 还是选择多线程模型。现代计算机都具有多个 CPU 以及多个物理核,使用单线程模型调度器会严重得限制资源利用率,所以为了尽可能压榨所有 CPU 或物理核的能力,就需要:

  • 单一全局的任务队列,多处理器

  • 多任务队列,每个都有单独的处理器


单队列+多处理器

这种模型中,有一个全局的任务队列。当任务处于“可运行的”状态时,它被插到任务队列尾。处理器们都在不同的线程上运行,每个处理器都从队列头取出任务并“消费”,如果队列为空了,那所有线程(以及对应的处理器)都被阻塞。

640?wx_fmt=png


任务队列必须支持多个生产者和多个消费者。这里常用的算法就是使用侵入式链表,这里的侵入式表示放入队列的任务本身需要包含指向下(后)一个任务的指针。这样在插入和弹出操作时就可以避免内存分配的操作,同时插入操作是无锁,但弹出操作就需要一个信号量去协调多个消费者。

这种方式多用于实现通用线程池场景,它具有如下的优点:

  • 任务会被公平地调度

  • 实现相对简单明了


上面说得公平调度意味着所有任务是以“先进先出”的方式来调度。这样的方式在有一些场景下,比如使用 fork-join 方式的并行计算场景时就不够高效了。因为唯一重要的考量是最终结果的计算速度,而非子任务的公平性。

当然这种调度模型也有缺点。所有的处理器(消费者)都守着队列头,导致处理器真正执行任务所消耗的时间远远大于任务从队列中弹出的时间,这在长耗时型任务场景中是有益的,因为队列争用会降低。然而,Rust 的异步任务是被设计用于短耗时的,此时争用队列的开销就变得很大了。

并发和“机械共情”


读者们肯定听过“为xxx平台特别优化”这样的表达,这是因为只有充分了解硬件架构,才能知道如何最大化利用硬件资源,才能设计出运行性能最高的程序。这就是所谓的“机械共情”,这个词是由马丁汤普森最初提出并使用的。

至于现代硬件架构下如何处理并发的相关细节并不在本文讨论的范围内,感兴趣的读者也可以阅读文章末的更多参考资料部分。


通常来说,硬件不是通过提高速度(频率)而是为程序提供更多的 CPU 核来获取性能提升。每个核都可以在极短的时间内执行大量的计算,相较而言,访问内存之类的操作则需要更多时间。因此,为了使程序运行得更快,我们必须使每次内存访问的 CPU 指令数量最大化。尽管编译器可以帮助我们做很多事,但作为程序设计开发人员,我们需要谨慎地考虑数据在内存中的结构布局以及访问内存的模式。

当涉及到线程并发时,CPU 的缓存一致性机制就会发挥作用,它会确保每个 CPU 的缓存都保持最新状态。

所以显而易见,我们要尽可能地避免跨线程同步,因为它是性能杀手。

多处理器+多任务队列


与前面的模型对比,在这种方式下,我们使用多个单线程调度器,每个处理器都有自己独占的任务队列,这样完全避免了同步问题。由于 Rust 的任务模型要求任意线程都可以提交任务到队列,所以我们仍需要设计一种线程安全的方式。要么每个处理器的任务队列支持线程安全的插入操作(MPSC),要么就每个处理器有两个队列:非同步队列和线程安全队列。

640?wx_fmt=png


这便是 Seastar 所使用的策略。因为几乎完全避免了同步,所以性能非常高。但需要注意的是,这并不是灵丹妙药,因为无法确保任务负载都是完全一致统一的,处理器可能出现严重的负载不均衡,使得资源利用率低下。这通常产生的场景是任务被粘到了固定的、特定的处理器上。

众所周知,真实世界的任务负载并不是一致统一的,所以在设计通用调度器时要避免使用此种模型。

“任务窃取”调度器


通常来说,任务窃取调度器是建立在分片调度模型之上的,主要为了解决资源利用率低的问题。每个处理器都具有自己独占的任务队列,处于“可运行的”任务会被插入到当前处理器的队列中,并且只会被当前处理器所消费(执行)。但巧妙的是,当一个处理器空闲时,它会检查同级的其他处理器的任务队列,看看是不是能“窃取”一些任务来执行。这也是这种模型的名称含义所在。最终,只有在无法从其他处理器的任务队列那里获得任务时该处理器就会进入休眠。

640?wx_fmt=png


这几乎是“两全其美”的方法。处理器可以独立运行,避免了同步开销。而且如果任务负载在处理器间分布不均衡,调度器也能够重新分配负载。正是由于这样的特性,诸如 Go 语言、Erlang 语言、Java 语言等都采用了“任务窃取”调度器。

当然,它也是有缺点的,那就是它的复杂性。任务队列必须支持“窃取”操作,并且需要一些跨处理器同步操作。整个过程如果执行不正确,那“窃取”的开销就超过了模型本身的收益。

让我们来考虑一个场景:处理器 A 当前正在执行任务,并且此刻它的任务队列是空的;处理器 B 此时空闲,它尝试“窃取”任务但是失败了,因此进入休眠态。紧接着,处理器 A 所执行的任务产生出了20个(子)任务。目的是唤醒处理器 B。这进而就需要调度器在观察到任务队列中有新的任务时,向处于休眠态的处理器发出信号。显而易见,这样的场景下会需要额外的同步操作,但这恰恰是我们想要避免的。

综上所述:

  • 尽量减少同步操作总是好的

  • “任务窃取”是通用调度器的首选算法

  • 处理器间基本是相互独立的,但是“偷窃”操作时不可避免的需要一些同步操作


Tokio 0.1 调度器


  • 2
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值