协程或者绿色线程是近年来经常讨论的话题。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;}
当任务变成“可运行”的,就被插入到队列中:
虽然我们可以设计成将资源、任务以及处理器都存在于一个单独线程中,但 Tokio 还是选择多线程模型。现代计算机都具有多个 CPU 以及多个物理核,使用单线程模型调度器会严重得限制资源利用率,所以为了尽可能压榨所有 CPU 或物理核的能力,就需要:
- 单一全局的任务队列,多处理器
- 多任务队列,每个都有单独的处理器
单队列+多处理器
这种模型中,有一个全局的任务队列。当任务处于“可运行的”状态时,它被插到任务队列尾。处理器们都在不同的线程上运行,每个处理器都从队列头取出任务并“消费”,如果队列为空了,那所有线程(以及对应的处理器)都被阻塞。
任务队列必须支持多个生产者和多个消费者。这里常用的算法就是使用侵入式链表,这里的侵入式表示放入队列的任务本身需要包含指向下(后)一个任务的指针。这样在插入和弹出操作时就可以避免内存分配的操作,同时插入操作是无锁,但弹出操作就需要一个信号量去协调多个消费者。
这种方式多用于实现通用线程池场景,它具有如下的优点:
- 任务会被公平地调度
- 实现相对简单明了
上面说得公平调度意味着所有任务是以“先进先出”的方式来调度。这样的方式在有一些场景下,比如使用 fork-join 方式的并行计算场景时就不够高效了。因为唯一重要的考量是最终结果的计算速度,而非子任务的公平性。
当然这种调度模型也有缺点。所有的处理器(消费者)都守着队列头,导致处理器真正执行任务所消耗的时间远远大于任务从队列中弹出的时间,这在长耗时型任务场景中是有益的,因为队列争用会降低。然而,Rust 的异步任务是被设计用于短耗时的,此时争用队列的开销就变得很大了。
并发和“机械共情”
读者们肯定听过“为xxx平台特别优化”这样的表达,这是因为只有充分了解硬件架构,才能知道如何最大化利用硬件资源,才能设计出运行性能最高的程序。这就是所谓的“机械共情”,这个词是由马丁汤普森最初提出并使用的。
至于现代硬件架构下如何处理并发的相关细节并不在本文讨论的范围内,感兴趣的读者也可以阅读文章末的更多参考资料部分。
通常来说,硬件不是通过提高速度(频率)而是为程序提供更多的 CPU 核来获取性能提升。每个核都可以在极短的时间内执行大量的计算,相较而言,访问内存之类的操作则需要更多时间。因此,为了使程序运行得更快,我们必须使每次内存访问的 CPU 指令数量最大化。尽管编译器可以帮助我们做很多事,但作为程序设计开发人员,我们需要谨慎地考虑数据在内存中的结构布局以及访问内存的模式。
当涉及到线程并发时,CPU 的缓存一致性机制就会发挥作用,它会确保每个 CPU 的缓存都保持最新状态。
所以显而易见,我们要尽可能地避免跨线程同步,因为它是性能杀手。
多处理器+多任务队列
与前面的模型对比,在这种方式下,我们使用多个单线程调度器,每个处理器都有自己独占的任务队列,这样完全避免了同步问题。由于 Rust 的任务模型要求任意线程都可以提交任务到队列,所以我们仍需要设计一种线程安全的方式。要么每个处理器的任务队列支持线程安全的插入操作(MPSC),要么就每个处理器有两个队列:非同步队列和线程安全队列。
这便是 Seastar 所使用的策略。因为几乎完全避免了同步,所以性能非常高。但需要注意的是,这并不是灵丹妙药,因为无法确保任务负载都是完全一致统一的,处理器可能出现严重的负载不均衡,使得资源利用率低下。这通常产生的场景是任务被粘到了固定的、特定的处理器上。
众所周知,真实世界的任务负载并不是一致统一的,所以在设计通用调度器时要避免使用此种模型。
“任务窃取”调度器
通常来说,任务窃取调度器是建立在分片调度模型之上的,主要为了解决资源利用率低的问题。每个处理器都具有自己独占的任务队列,处于“可运行的”任务会被插入到当前处理器的队列中,并且只会被当前处理器所消费(执行)。但巧妙的是,当一个处理器空闲时,它会检查同级的其他处理器的任务队列,看看是不是能“窃取”一些任务来执行。这也是这种模型的名称含义所在。最终,只有在无法从其他处理器的任务队列那里获得任务时该处理器就会进入休眠。
这几乎是“两全其美”的方法。处理器可以独立运行,避免了同步开销。而且如果任务负载在处理器间分布不均衡,调度器也能够重新分配负载。正是由于这样的特性,诸如 Go 语言、Erlang 语言、Java 语言等都采用了“任务窃取”调度器。
当然,它也是有缺点的,那就是它的复杂性。任务队列必须支持“窃取”操作,并且需要一些跨处理器同步操作。整个过程如果执行不正确,那“窃取”的开销就超过了模型本身的收益。
让我们来考虑一个场景:处理器 A 当前正在执行任务,并且此刻它的任务队列是空的;处理器 B 此时空闲,它尝试“窃取”任务但是失败了,因此进入休眠态。紧接着,处理器 A 所执行的任务产生出了20个(子)任务。目的是唤醒处理器 B。这进而就需要调度器在观察到任务队列中有新的任务时,向处于休眠态的处理器发出信号。显而易见,这样的场景下会需要额外的同步操作,但这恰恰是我们想要避免的。
综上所述:
- 尽量减少同步操作总是好的
- “任务窃取”是通用调度器的首选算法
- 处理器间基本是相互独立的,但是“偷窃”操作时不可避免的需要一些同步操作
Tokio 0.1 调度器
2018年3月,Tokio 发布了其第一版基于“任务窃取”算法的调度器。但那个版本的实现中有一些瑕疵:
首先,I/O 型任务会被同时操作 I/O 选择器(epoll、kqueue、iocp等)的线程所执行;更多与 CPU 绑定的任务会进入线程池。在这种情况下,活跃态线程的数量应该是灵活的、动态的,所以(适时得)关闭空闲态线程是合理的。但是,在“任务窃取”调度器上执行所有异步任务时,始终保持少量的活跃态线程是更合理的。
其次,当时采用了基于 Chase-Lev deque 算法的队列,该算法后来被证明并不适合于调度独立的异步任务场景。
第三,实现过于复杂。由于代码中过多得使用 atomic,然而大部分情况下,mutex 是更好地选择。
最后,代码中有许多细小的低效设计和实现,但由于早期为保证 API 的稳定性,导致了一些技术债。
当然,随着 Tokio 新版的发布,我们收获了很多的经验教训,偿还了许多技术债,这着实是令人兴奋的!
下一代的 Tokio 调度器
现在我们深入解析一下新调度器的变更。
新的任务系统
首先,重要的亮点并不属于 Tokio 的一部分,但对达成我们的成就至关重要:std 包含了由 Taylor Cramer设计的新的任务系统。该系统给调度系统提供了钩子(hooks),方便调度器执行 Rust 异步任务,并且确实做得很好,比之前的版本更轻巧灵活。
Waker结构由资源保存,用于表示任务可运行并被推送到调度程序的运行队列中。在新的任务系统中,Waker结构过去是更大的,但指针宽度为两个指针。减小大小对于最小化复制Waker值的开销以及在结构中占用较少的空间非常重要,从而允许将更多关键数据放入高速缓存行中。自定义vtable设计可实现许多优化,这将在后面讨论。
更好的任务队列
任务队列是调度程序的核心,是最关键的组成部分。最初的tokio调度器使用crossbeam的deque实现,即单生产者、多消费者deque。任务从一端入队,从另一端出队。大多数情况下,入队线程会出队它,然而,其他线程偶尔会出队任务来“窃取”。deque包含一个数组和一组追踪头部和尾部的索引。当deque满了时,入队数据将导致存储空间增长。会分配一个新的、更大的数组,并将值移到新存储区中。
双端队列增长的能力要付出复杂性和运行成本。入队/出队操作必须考虑到这种情况。此外,在队列增长时,释放原始数组会带来额外的困难。在垃圾收集语言中,gc会释放它。然而rust不带GC,这意味着程序需要负责释放数组,但线程可能正在并发访问内存。Crossbeam对此的答案是采用基于代的回收策略。虽然开销并不是非常大,但确实在代码热路径中的增加了不小的开销。每当进入和退出临界区时,每个操作都必须是atomic RMW(读修改写)操作,以向其他线程发出信号。
由于增长本地队列的相关成本不低,因此值得研究是否需要增长队列。这个问题最终导致了调度程序的重写。新调度程序的策略是对每个队列使用固定大小。当队列已满时,任务将被推送到一个全局的、多使用者、多生产者队列中,而不是增长本地队列。处理器需要检查这个全局队列,但检查的频率要比本地队列低得多。
早期实验过用有界mpmc队列代替了Crossbeam队列。由于push和pop都执行了大量的同步,因此没有带来太大的提升。关于窃取任务,需要记住的一个关键点是,在有负载的时候队列几乎没有争用,因为每个处理器只访问自己的队列。
在这一点上,我仔细阅读go源代码,发现它使用了一个固定大小的单生产者、多消费者队列。这个队列令只需要很少的同步就可以正常工作。我对该算法进行了一些修改,使之适用于tokio调度程序。值得注意的是,go实现版本中其原子操作使用顺序一致性(基于我对go的有限知识)。作为tokio调度器的一部分,该版本还降低了较冷代码路径中的一些复制。
该队列实现是一个循环缓冲区,使用数组存储值。原子整数用于跟踪头部和尾部位置。
- struct Queue { /// Concurrently updated by many threads. head: AtomicU32,
- /// Only updated by producer thread but read by many threads. tail: AtomicU32,
- /// Masks the head / tail position value to obtain the index in the buffer. mask: usize,
- /// Stores the tasks. buffer: Box<[MaybeUninit<Task>]>,}
入队由单独线程完成:
- loop { let head = self.head.load(Acquire);
- // safety: this is the **only** thread that updates this cell. let tail = self.tail.unsync_load;
- if tail.wrapping_sub(head) < self.buffer.len as u32 { // Map the position to a slot index. let idx = tail as usize & self.mask;
- // Don't drop the previous value in `buffer[idx]` because // it is uninitialized memory. self.buffer[idx].as_mut_ptr.write(task);
- // Make the task available self.tail.store(tail.wrapping_add(1), Release);
- return; }
- // The local buffer is full. Push a batch of work to the global // queue. match self.push_overflow(task, head, tail, global) { Ok(_) => return, // Lost the race, try again Err(v) => task = v, }}
请注意,在此push函数中,唯一的原子操作是使用Acquire顺序的load和具有Release顺序的store。没有读-修改-写操作(compare_and_swap,fetch_and等)或顺序一致性。因为在x86芯片上,所有load/store 已经是“原子的”。因此,在CPU级别,此功能不是同步的。使用原子操作会影响编译器,因为它会阻止某些优化,但也仅此而已。第一个load很可能可以通过Relaxed顺序完成,但是切换成Relaxed顺序没有明显的收益。
队列已满时,将调用push_overflow。此功能将本地队列中的一半任务移到全局队列中。全局队列是由互斥锁保护的侵入链表。首先将要移动到全局队列中的任务链接在一起,然后获取互斥锁,并通过更新全局队列的尾指针来写入所有任务。
如果您熟悉原子内存操作的细节,您可能会注意到上图所示的push函数可能会产生“问题”。使用Acquire顺序的原子load同步语义非常弱。它可能会返回老值(并发窃取操作可能已经增加了self.head的值),但是执行入队的线程会读到线程中的老值。这对于算法的正确性不是问题。在入队的代码路径中,我们只关心本地运行队列是否已满。鉴于当前线程是可以执行入队操作的唯一线程,则老值将使队列比实际更满。它可能会错误地认为队列已满并进入push_overflow函数,但是此函数包括更强的原子操作。如果push_overflow确定队列实际上未满,则返回w / Err并再次尝试push操作。这是push_overflow将一半运行队列移至全局队列的另一个原因。通过移动一半的队列,“运行队列为空”的误报率就会降低。
本地出对消息也很轻量级:
- loop { let head = self.head.load(Acquire);
- // safety: this is the **only** thread that updates this cell. let tail = self.tail.unsync_load;
- if head == tail { // queue is empty return None; }
- // Map the head position to a slot index. let idx = head as usize & self.mask;
- let task = self.buffer[idx].as_ptr.read;
- // Attempt to claim the task read above. let actual = self .head .compare_and_swap(head, head.wrapping_add(1), Release);
- if actual == head { return Some(task.assume_init); }}
在此函数中,存在单个原子load和一个带有release的compare_and_swap。主要开销来自compare_and_swap。
窃取功能类似于出队,但是self.tail的load必须是原子的。同样,类似于push_overflow,窃取操作将尝试窃取队列的一半,而不是单个任务。这这是不错的特性,我们将在后面介绍。
最后一部分是全局队列。该队列用于处理本地队列的溢出,以及从非处理器线程向调度程序提交任务。如果处理器有负载,即本地队列中有任务。在从本地队列执行约60个任务后,处理器将尝试从全局队列获取任务。当处于“搜索”状态时,它还会检查全局队列,如下所述。
优化消息传递模式
用Tokio编写的应用程序通常以许多小的独立任务为模型。这些任务将使用消息相互通信。这种模式类似于Go和Erlang等其他语言。考虑到这种模式的普遍性,调度程序尝试对其进行优化是有意义的。
给定任务A和任务B。任务A当前正在执行,并通过channel向任务B发送消息。通道是任务B当前阻塞在channel上,因此发送消息将导致任务B转换为可运行状态,并被入队到当前处理器的运行队列中。然后,处理器将从运行队列中弹出下一个任务,执行该任务,然后重复执行直到完成任务B。
问题在于,从发送消息到执行任务B的时间之间可能会有很大的延迟。此外,“热”数据(例如消息)在发送时已存储在CPU高速缓存中,但是到任务B被调度时,有可能已经从高速缓存中清理掉了。
为了解决这个问题,新的Tokio调度程序实现了特定优化(也可以在Go和Kotlin的调度程序中找到)。当任务转换为可运行状态时,它存储在“下一个任务”槽中,而不是将其入队到队列的后面。在检查运行队列之前,处理器将始终检查该槽。将任务插入此槽时,如果任务已存储在其中,则旧任务将从槽中移除,并入队到队列的后面。在消息传递的情况下,这将保证消息的接收者会被立马调度。
任务窃取
在窃取任务调度器中,当处理器的运行队列为空时,处理器将尝试从同级处理器中窃取任务。随机选择同级处理器,然后对该同级处理器执行窃取操作。如果未找到任务,则尝试下一个同级处理器,依此类推,直到找到任务。
实际上,许多处理器通常在大约同一时间完成其运行队列的处理。当一批任务到达时(例如,轮询epoll以确保socket就绪时),就会发生这种情况。处理器被唤醒,获取并运行任务。这导致所有处理器同时尝试窃取,意味着多线程试图访问相同的队列。这会引起争用。随机选择初始节点有助于减少争用,但是仍然很糟糕。
新的调度程序会限制并发执行窃取操作的处理器的数量。我们将试图窃取的处理器状态称为“正在搜索任务”,或简称为“正在搜索”状态。通过使用原子计数保证处理器在开始搜索之前递增以及在退出搜索状态时递减来控制并发数量。搜索程序的最大数量是处理器总数的一半。虽然限制相当草率,但依然可以工作。我们对搜索程序的数量没有硬性限制,只需要节流即可,以精度来换取算法效率。
处于正在搜索状态后,处理器将尝试从同级任务线程中窃取任务并检查全局队列。
减少跨线程同步
任务窃取调度程序的另一个关键部分是同级通知。这是处理器在观察新任务时通知同级的地方。如果其他处理器正处于休眠状态,则被唤醒并窃取任务。通知还有另一个重要责任。回顾使用弱原子顺序(获取/发布)的队列算法。由于原子内存顺序的工作原理,而无需额外的同步,因此无法保证同级处理器将知道队列中的任务被窃取。因此通知动作还负责为同级处理器建立必要的同步,以使其知道任务以窃取任务。这些要求使得通知操作代价高昂。我们的目标是在保证CPU利用率的情况下,尽可能少地执行通知操作。通知太多会导致惊群问题。
老版本的Tokio调度程序采用了朴素的通知方式。每当将新任务推送到运行队列时,就会通知处理器。每当该处理器并在唤醒时找到任务,它便会通知另一个处理器。这种逻辑会导致所有处理器都被唤醒从而引起争用。通常这些处理器中的大多数都找不到任务,然后重新进入休眠。
通过使用Go调度器中类似的技术,新调度器有显着改进。新调度器在相同的地方进行执行,然而仅在没有处于搜索状态的worker时才进行通知。通知worker后,其立即转换为搜索状态。当处于搜索状态的处理器找到新任务时,它会首先退出搜索状态,然后通知下一个处理器。
这种方法用于限制处理器唤醒的速率。如果一次调度了一批任务(例如,在轮询epoll以确保套接字就绪时),则处理器会收到第一个任务的通知,然后处于搜索状态。该处理器不会收到批处理中的其余任务的通知。负责通知的处理程序将窃取批处理中的一半任务,然后通知另一个处理器。第三个处理器将被唤醒,从前两个处理器中查找任务,然后窃取其中一半。这样处理器负载会平滑上升,任务也会达到快速负载平衡。
减少内存分配
新的Tokio调度程序对每个任务只需要分配一次内存,而旧的调度程序则需要分配两次内存。以前,Task结构如下:
- struct Task { /// All state needed to manage the task state: TaskState,
- /// The logic to run is represented as a future trait object. future: Box<dyn Future<Output = >>,}
然后,Task结构也将被置于Box中。自从旧的Tokio调度程序发布以来,发生了两件事。首先,std :: alloc稳定了。其次,Future任务系统切换到显式的vtable策略。有了这两个条件,我们就可以减少一次内存分配。
现在,任务表示为:
- struct Task<T> { header: Header, future: T, trailer: Trailer,}
Header和Trailer都是执行任务所需的状态,状态被划分为“热”数据(header)和“冷”数据(trailer),即,经常访问的数据和很少使用的数据。热数据放置在结构的头部,并保持尽可能小。当CPU取消引用任务时,它将一次性加载高速缓存行大小的数据量(介于64和128字节之间)。我们希望该数据尽可能有价值。
减少原子引用计数
最后一个优化在于新的调度程序如何减少原子引用计数。任务结构有许多未完成的引用:调度程序和每个唤醒程序都拥有一个句柄。管理此内存的方法是使用原子引用计数。此策略需要在每次克隆引用时进行一次原子操作,并在每次删除引用时进行一次相反的原子操作。当最终引用次数为0时,将释放内存。
在旧的Tokio调度程序中,每个唤醒器都有一个对任务句柄的引用计数:
- struct Waker { task: Arc<Task>,}
- impl Waker { fn wake(&self) { let task = self.task.clone; task.scheduler.schedule(task); }}
唤醒任务后,将调用task 的clone方法(原子增量)。然后将引用置入运行队列。当处理器执行完任务时,它将删除引用,从而导致引用计数的原子递减。这些原子操作虽然代价很低但是积少成多。
std :: future任务系统的设计人员已经确定了此问题。据观察,当调用Waker :: wake时,通常不再需要原来的waker引用。这样可以在将任务推入运行队列时重用原子计数。现在,std :: future任务系统包括两个“唤醒” API:
- wake带self参数
- wake_by_ref带&self参数。
这种API设计迫使调用者使用wake方法来避免原子增量。现在的实现变为:
- impl Waker { fn wake(self) { task.scheduler.schedule(self.task); }
- fn wake_by_ref(&self) { let task = self.task.clone; task.scheduler.schedule(task); }}
这就避免了额外的引用计数的开销,然而这仅仅在可以获取所有权的时候可用。根据我的经验,调用wake几乎总是通过借用而非获取引用。使用self进行唤醒可防止重用waker,在使用self时实现线程安全的唤醒也更加困难(其细节将留给另一个文章)。
新的调度程序端通过避免调用wake_by_ref中的clone来逐步解决问题,从而其和wake(self)一样有效。通过使调度程序维护当前处于活动状态(尚未完成)的所有任务的列表来完成此功能。此列表代表将任务推送到运行队列所需的引用计数。
这种优化的困难之处在于,确保调度程序在任务结束前不会从其列表中删除任何任务。如何进行管理的细节不在本文的讨论范围之内,有兴趣可以参考源代码。
使用Loom无畏并发
众所周知,编写正确的、并发安全的、无锁的代码不是一件容易事,而且正确性最为重要,特别是要尽力避免那些和内存分配相关的代码缺陷。在此方面,新版调度器做了很多努力,包括大量的优化以及避免使用大部分 std 类型。
从测试角度来说,通常有几种方法用来验证并发代码的正确性。一种是完全依赖用户在其使用场景中验证;另一种是依赖循环运行的各种粒度单元测试试图捕捉那些非常小概率的极端情况的并发缺陷。这种情况下,循环运行多长时间合适就成了另一个问题,10分钟或者10天?
上述情况在我们的工作中是无法接受的,我们希望交付并发布时感到十足的自信,对 Tokio 用户而言,可靠性是最为重要的。
因此,我们便造了一个“新轮子”:Loom,它是一个用于测试并发代码的工具。测试用例可以按照最朴实寻常的方式来设计和编写,但当通过 Loom 来执行时,Loom 会运行多次用例,同时会置换(permute)在多线程环境下所有可能遇到的行为或结果,这个过程中 Loom 还会验证内存访问正确与否,以及内存分配和释放的行为正确与否等等。
下面是调度器在 Loom 上一个真实的测试场景:
- #[test]fn multi_spawn { loom::model(|| { let pool = ThreadPool::new;
- let c1 = Arc::new(AtomicUsize::new(0));
- let (tx, rx) = oneshot::channel; let tx1 = Arc::new(Mutex::new(Some(tx)));
- // Spawn a task let c2 = c1.clone; let tx2 = tx1.clone; pool.spawn(async move { spawn(async move { if 1 == c1.fetch_add(1, Relaxed) { tx1.lock.unwrap.take.unwrap.send(); } }); });
- // Spawn a second task pool.spawn(async move { spawn(async move { if 1 == c2.fetch_add(1, Relaxed) { tx2.lock.unwrap.take.unwrap.send(); } }); });
- rx.recv; });}
上述代码中的 loom::model部分运行了成千上万次,每次行为都会有细微的差别,比如线程切换的顺序,以及每次原子操作时,Loom 会尝试所有可能的行为(符合 C++ 11 中的内存模型规范)。前面我提到过,使用 Acquire进行原子的加载操作是非常弱(保证)的,可能返回旧(脏)值,Loom 会尝试所有可能加载的值。
在调度器的日常开发测试中,Loom 发挥了非常重要的作用,帮助我们发现并确认了10多个其他测试手段(单元测试、手工测试、压力测试)所遗漏的隐蔽缺陷。
有的读者们可能会对前文提到的“对所有可能的结果或行为进行排列组合和置换”产生疑问。众所周知,对可能行为的简单排列组合就会导致阶乘式的“爆炸”。当然目前有许多用于避免这类指数级爆炸的算法,Loom 中采用的核心算法是基于“动态子集缩减”算法(dynamic partial reduction)。该算法能够动态“修剪”会导致一致执行结果的排列子集,但需要注意的是,即便如此,在状态空间巨大时也一样会导致修剪效率大幅降低。Loom 采用了有界动态子集缩减算法来限制搜索空间。
总而言之,Loom 极大地帮助了我们,使得我们更有信心地发布新版调度器。
测试结果
我们来具体看看新版 Tokio 调度器到底取得了多大的性能提升?
首先,在微基准测试中,新版调度器提升显著。
老版本
- test chained_spawn ... bench: 2,019,796 ns/iter (+/- 302,168) test ping_pong ... bench: 1,279,948 ns/iter (+/- 154,365) test spawn_many ... bench: 10,283,608 ns/iter (+/- 1,284,275) test yield_many ... bench: 21,450,748 ns/iter (+/- 1,201,337)
新版本
- test chained_spawn ... bench: 168,854 ns/iter (+/- 8,339) test ping_pong ... bench: 562,659 ns/iter (+/- 34,410) test spawn_many ... bench: 7,320,737 ns/iter (+/- 264,620) test yield_many ... bench: 14,638,563 ns/iter (+/- 1,573,678)
测试内容包括:
- chained_spawn测试会递归地不断产生新的子任务。
- ping_pong测试会分配一个一次性地(oneshot)通道,接着产生一个新的子任务,子任务在该通道上发送消息,原任务则接受消息。
- spawn_many测试会产生出大量子任务,并注入给调度器。
- yield_many 会测试一个唤醒自己的任务。
为了更接近真实世界的工作负载,我们再试试 Hyper 基准测试:
- wrk -t1 -c50 -d10
老版本
- Running 10s test @ http://127.0.0.1:3000 1 threads and 50 connections Thread Stats Avg Stdev Max +/- Stdev Latency 371.53us 99.05us 1.97ms 60.53% Req/Sec 114.61k 8.45k 133.85k 67.00% 1139307 requests in 10.00s, 95.61MB read Requests/sec: 113923.19 Transfer/sec: 9.56MB
新版本
- Running 10s test @ http://127.0.0.1:3000 1 threads and 50 connections Thread Stats Avg Stdev Max +/- Stdev Latency 275.05us 69.81us 1.09ms 73.57% Req/Sec 153.17k 10.68k 171.51k 71.00% 1522671 requests in 10.00s, 127.79MB read Requests/sec: 152258.70 Transfer/sec: 12.78MB
目前 Hyper 基准测试已经比 TechEmpower 更有参考性,所以从结果来看,我们很兴奋 Tokio 调度器已经可以冲击这样的性能排行榜。
另外一个令人印象深刻的结果是,Tonic,流行的 gRPC 客户端/服务端框架,取得了超过10%的性能提升,这还是在 Tonic 本身没有做特定优化的情况下!
结论
我非常高兴参与这项持续数月的大工程,它将成为 Rust 异步 I/O 发展的重大事件。同时我也对最终取得的结果感到满意,当然 Tokio 中仍然有可以继续优化和性能提升的空间,这不是终点。