我们已经十次加速了Tokio调度程序

我们正在准备Tokio的下一个主要版本,这是Rust的异步运行时环境。 10月13日,发布了带有完全重写的任务调度程序的池请求 ,以合并到分支中。 结果将是巨大的性能改进和减少的延迟。 一些测试记录了十倍的加速度! 像往常一样,综合测试不能反映现实中的实际收益。 因此,我们还检查了调度程序中的更改如何影响实际任务,例如HyperTonic (破坏者:效果很好)。

为了准备新的计划程序,我花了一些时间搜索主题资源。 除了实际的实现之外,没有发现任何特殊之处。 我还发现现有实现的源代码很难导航。 为了解决这个问题,我们尝试尽可能简洁地编写Tokio sheduler。 我希望这篇关于调度程序实现的详细文章将对那些处在同一位置但未能成功找到有关此主题的信息的人有所帮助。

本文从对设计的高层审查开始,包括职位获取策略。 然后在新的Tokio计划程序中深入了解特定优化的详细信息。

考虑的优化:


如您所见,主要主题是“减少”。 毕竟,缺乏最快的代码!

我们还将讨论测试新的调度程序 。 没有锁,编写正确的并行代码非常困难。 最好是缓慢但正确地工作,而不是快速但有故障,特别是如果这些错误与内存安全性有关。 但是,最好的选择应该可以快速运行且没有错误,因此我们编写了并发测试工具loom

在深入探讨该主题之前,我要感谢:

  • @withoutboats和其他在Rust中使用async / await功能的人。 你做得很好。 这是一个杀手级功能。
  • @cramertj和其他开发std::task 。 这是对以前的巨大改进。 和伟大的代码。
  • Buoyant,Linkerd的创建者更重要的是我的雇主。 谢谢您让我在这项工作上花费大量时间。 如果有人对服务网格感兴趣,请查看Linkerd。 很快它将包括本文讨论的所有好处。
  • 争取实现如此好的计划程序实施。

喝杯咖啡,坐下。 这将是一篇长文章。

计划者如何工作?


Sheduler的任务是计划工作。 该应用程序分为工作单元,我们将其称为任务 。 当任务可以在执行中前进但在外部资源上锁定时,它可以继续执行或不再处于空闲模式,则认为该任务是可运行的。 任务是独立的,可以同时执行任意数量的任务。 调度程序负责在运行状态下运行任务,直到它们返回待机模式。 任务执行意味着为任务分配处理器时间-全局资源。

本文讨论了用户空间调度程序,即在操作系统线程(该线程又由内核级sheduler控制)之上工作。 Tokio调度程序执行Rust期货,可以将其视为“异步绿色线程”。 这是M:N的混合流模式,其中许多用户界面任务被多路复用到操作系统的多个线程上。

有多种不同的方式来模拟Sheduler,每种方法各有优缺点。 在最基本的级别上,可以将调度程序建模为运行队列和将其分解的处理器 。 处理器是在线程中运行的一段代码。 用伪代码执行以下操作:

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

当任务变为可行时,它将被插入到执行队列中。



尽管您可以设计一个在同一线程中存在资源,任务和处理器的系统,但是Tokio还是倾向于使用多个线程。 我们生活在计算机拥有许多处理器的世界中。 单线程调度程序的发展将导致铁负载不足。 我们要使用所有CPU。 有几种方法可以做到这一点:

  • 一个全局执行队列,许多处理器。
  • 许多处理器,每个处理器都有自己的运行队列。

一转,许多处理器


该模型具有一个全局执行队列。 任务完成后,它们将被放置在队列的尾部。 有几个处理器,每个处理器在一个单独的线程中。 每个处理器从队列的头开始执行一项任务,或者在没有可用任务的情况下阻塞线程。



执行线必须得到许多制造商和消费者的支持。 通常使用侵入式列表,其中每个任务的结构都包含指向队列中下一个任务的指针(而不是将任务包装在链接列表中)。 因此,可以避免用于推送和弹出操作的内存分配。 您可以使用没有锁定的push操作 ,但是为了协调使用者,pop操作需要使用互斥锁(从技术上讲,可以在不锁定的情况下实现多用户队列)。

但是,实际上,适当保护锁的开销不仅仅是使用互斥锁。

这种方法通常用于通用线程池,因为它具有以下优点:

  • 任务是合理计划的。
  • 相对简单的实现。 或多或少的标准队列与上述处理器周期接口。

关于公平(公平)计划的简要说明。 这意味着任务是诚实执行的:较早到达的人是较早离开的人。 通用规划人员试图做到公平,但也有例外,例如通过fork-join进行并行化,在这种情况下,计算结果的速度(而不是每个子任务的公正性)是一个重要因素。

这种模式有一个缺点。 所有处理器都从队列的开头申请任务。 对于通用线程,这通常不是问题。 完成任务的时间远远超过从队列中检索任务的时间。 当长时间执行任务时,队列中的竞争会减少。 但是,异步Rust任务有望很快完成。 在这种情况下,队列中争斗的间接费用将大大增加。

并发和机械同情


为了获得最佳性能,我们必须充分利用硬件功能。 软件的“机械同情”一词最早是由Martin Thompson使用的(其博客已不再更新,但仍然非常有用)。

在现代设备中实现并行性的详细讨论超出了本文的范围。 一般而言,铁提高生产率不是因为加速,而是由于引入了大量CPU内核(甚至我的笔记本电脑有六个!),每个内核都可以在很小的时间间隔内执行大量计算。 相对于CPU的执行时间,诸如访问缓存和内存之类的操作要花费更多的时间 。 因此,为了加速应用程序,您需要为每次内存访问最大化CPU指令数。 尽管编译器有很大帮助,但我们仍然必须考虑对齐和内存访问模式之类的问题。

分离的线程非常像单个隔离的线程那样独立工作, 直到多个线程同时修改同一缓存行(并发突变)或需要一致的一致性 为止 。 在这种情况下,将激活CPU缓存一致性协议 。 它保证了每个CPU的缓存的相关性。

结论是显而易见的:尽可能避免线程之间的同步,因为它很慢。

许多处理器,每个处理器都有自己的执行队列


另一个模型是几个单线程调度程序。 每个处理器都有自己的执行队列,并且任务固定在特定的处理器上。 这完全避免了同步问题。 由于Rust任务模型要求能够从任何线程对任务进行排队,因此仍应存在一种将任务输入调度程序中的线程安全方法。 每个处理器的执行队列都支持线程安全的推入操作(MPSC),或者每个处理器都有两个执行队列:不同步和线程安全。



此策略使用Seastar 。 由于我们几乎完全避免了同步,因此该策略的速度非常快。 但是她并不能解决所有问题。 如果工作负载不是完全均匀的,则某些处理器处于负载状态,而其他处理器处于空闲状态,这将导致资源的最佳利用。 发生这种情况是因为任务固定在特定的处理器上。 当在一个处理器上的程序包中计划一组任务时,即使其他任务处于空闲状态,它也可以单手满足峰值负载。

大多数“实际”工作负载不是同质的。 因此,通用规划人员通常会避开此模型。

作业捕获计划程序


具有工作窃取调度程序的调度程序基于分片调度程序模型,解决了硬件资源加载不完整的问题。 每个处理器都支持自己的执行队列。 已执行的任务被放置在当前处理器的执行队列中,并在其上工作。 但是,当处理器处于空闲状态时,它会检查姊妹处理器的队列,并尝试从那里获取内容。 处理器仅在无法从对等执行队列中找到工作后才进入睡眠模式。



在模型级别,这是“两全其美”的方法。 在负载下,处理器独立工作,避免开销同步。 如果处理器之间的负载分布不均,则调度程序可以重新分配它。 这就是为什么在GoErlangJava和其他语言中使用此类调度程序的原因。

缺点是这种方法要复杂得多。 队列算法必须支持作业捕获,并且为了平稳执行,需要在处理器之间进行一些同步。 如果未正确实施,则捕获的开销可能会超过收益。

考虑这种情况:处理器A当前正在执行任务,并且有一个空的执行队列。 处理器B空闲; 他尝试捕获某些任务,但失败了,因此进入睡眠模式。 然后从处理器A的任务中产生20个任务。 理想情况下,处理器B应该醒来并抓住其中的一些。 为此,有必要在调度程序中实施某些启发式方法,其中处理器向睡眠中的对等处理器发信号通知其队列中出现新任务。 当然,这需要附加的同步,因此最好将此类操作最小化。

总结:

  • 同步越少越好。
  • 作业捕获是通用计划人员的最佳算法。
  • 每个处理器都独立于其他处理器工作,但是需要一些同步才能捕获工作。

Tokio 0.1 Scheduler


Tokio的第一个工作调度程序已于2018年3月发布。 这是第一次尝试,基于一些错误的假设。

首先,Tokio 0.1调度程序建议如果处理器线程空闲一定时间,则应将其关闭。 调度程序最初是作为Rust线程池的“通用”系统创建的。 当时,Tokio运行时仍处于开发的早期阶段。 然后,模型假设I / O任务将在与I / O选择器相同的线程上执行(epoll,kqueue,iocp ...)。 更多的计算任务可以定向到线程池。 在这种情况下,假定活动线程数的灵活配置,因此禁用空闲线程更有意义。 但是,在具有作业捕获功能的调度程序中,模型切换为执行所有异步任务,在这种情况下,始终将少量线程保持在活动状态是有意义的。

其次,在那里实施了双向横梁生产线。 此实现基于双向Chase-Lev系列 ,由于以下原因,它不适合计划独立的异步任务。

第三,实施起来太复杂了。 部分原因是因为这是我的第一个任务计划程序。 另外,当我在分支中使用原子时,我太急躁了,因为互斥体本来就可以了。 一个重要的教训是,互斥锁通常最有效。

最后,在初始实施中存在许多小的缺陷。 在早期,异步Rust模型的实现细节有了很大的发展,但是库始终使API保持稳定。 这导致一些技术债务的积累。

现在,Tikio即将发布第一个主要版本-我们可以偿还所有这些债务,也可以借鉴多年来的发展经验。 这是一个激动人心的时刻!

下一代Tokio Scheduler


现在是时候仔细看看新调度程序中发生了什么变化。

新任务系统


首先,重要的是要突出什么不是 Tokio的一部分,但是对于提高效率至关重要:这是最初由Taylor Kramer开发的std 新任务系统 。 该系统提供了调度程序必须执行的钩子才能执行异步Rust任务,并且该系统的设计和实现确实一流。 它比以前的迭代更轻巧,更灵活。

资源中的Waker结构表明存在一个可行的任务,应该将其放置在调度程序队列中。 在新的任务系统中,这是一个两指针结构,而之前它要大得多。 减小大小对于最大程度地减少在不同位置复制Waker值的开销很重要,并且它占用的结构空间更少,从而使您可以将更多重要数据压缩到缓存行中。 vtable设计进行了许多优化,我们将在后面讨论。

选择最佳队列算法


执行队列位于调度程序的中心。 因此,这是最重要的组件。 原始的Tokio调度程序使用两路横梁队列 :单源实现(生产者)和许多使用者。 一项任务放在一端,而值从另一端检索。 在大多数情况下,线程从队列末尾“推送”值,但有时其他线程会拦截工作,执行相同的操作。 双向队列由一个数组和一组跟踪首尾的索引支持。 当队列已满时,引入队列将导致存储空间增加。 分配了一个更大的新数组,并将这些值移到新的存储中。

通过复杂性和开销来实现增长的能力。 推/弹出操作应考虑到这种增长。 另外,释放原始阵列还充满了其他困难。 在垃圾回收(GC)语言中,旧数组将超出范围,最终GC将清除它。 但是,Rust出厂时没有GC。 这意味着我们自己负责释放数组,但是线程可以尝试同时访问内存。 为了解决这个问题,横梁采用了基于时代的填海策略。 尽管它不需要大量资源,但会给主路径(热路径)增加不小的开销。 现在,每个操作都应在关键部分的入口和出口执行原子RMW(读-修改-写)操作,以向其他线程发出内存正在使用且无法清除的信号。

由于执行队列增长的开销很大,因此有必要思考:支持这种增长真的必要吗? 这个问题最终促使我重写了计划程序。 新策略是为每个进程设置固定的队列大小。 当队列已满时,任务将移至具有多个使用者和多个生产者的全局队列,而不是增加本地队列。 处理器将定期检查此全局队列,但频率要比本地队列低得多。

作为第一个实验的一部分,我们用mpmc代替了crossbeam 。 由于推送和弹出的同步量较大,因此未导致显着改善。 捕获工作的关键是负载下的队列几乎没有竞争,因为每个处理器只能访问自己的队列。

在此阶段,我决定仔细研究Go资源-发现它们使用了一个制造商和几个消费者的固定队列大小,并且同步最少,这非常令人印象深刻。 为了使算法适合Tokio调度程序,我进行了一些更改。 值得注意的是,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顺序加载和使用“ Release顺序保存。 与以前一样,没有RMW操作( compare_and_swapfetch_and ...)或顺序顺序。 这很重要,因为在x86芯片上,所有下载/保存已经是“原子的”。 因此,在CPU级别, 该功能将不会被同步 。 原子操作会阻止编译器中的某些优化,仅此而已。 最有可能的是,可以使用“ Relaxed订购”安全地执行第一次load操作,但是替换操作不会带来任何明显的开销。

队列已满时,将push_overflow此功能将一半任务从本地队列移到全局队列。全局队列是受互斥锁保护的侵入性列表。移至全局队列时,首先将任务链接在一起,然后创建一个互斥锁,并通过更新指向全局队列尾部的指针来插入所有任务。这节省了很小的临界区尺寸。

如果您熟悉原子存储顺序的详细信息,您可能会注意到上面显示的功能可能存在“问题” push。原子load排序操作Acquire相当弱。它可以返回过时的值,即并行捕获操作可能已经增加了该值self.head,但是在流缓存中push旧值将保留,因此不会注意到捕获操作。这与算法的正确性无关。在主要(快速)方式中,push我们只关心本地队列是否已满。由于只有当前线程才能推送队列,所以过时的操作load只会使队列看上去比实际队列更满。它可能会错误地确定队列已满,并导致push_overflow,但是此功能包括更强大的原子操作。如果push_overflow确定队列实际上未满,则返回w / Err,然后操作push再次开始。这是另一个原因push_overflow将执行队列的一半移到全局队列。在此运动之后,这种误报的发生频率要低得多。

本地pop(从队列所属的处理器)也很容易实现:

 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和一个compare_and_swaps Release主要的开销来自compare_and_swap

功能steal类似于pop,但self.tail原子负荷必须从转移。另外,类似地push_overflow,该操作steal尝试假装为队列的一半而不是单个任务。这对性能有很好的影响,我们将在后面讨论。

最后缺少的部分是对全局队列的分析,该队列接收溢出本地队列的任务,以及将任务从非处理器线程传输到调度程序的信息。如果处理器处于负载状态,即本地队列中有任务,则处理器将在本地队列中每隔60个任务后尝试将任务从全局队列中拉出。当它处于下面描述的“搜索”状态时,它还会检查全局队列。

简化消息模板


Tokio应用程序通常包含许多小的独立任务。它们通过消息彼此交互。这样的模板类似于Go和Erlang等其他语言。考虑到模板的通用性,计划者对其进行优化是有意义的。

假设给定了任务A和B,任务A现在正在执行,并通过传输通道向任务B发送消息。通道是任务B当前处于锁定状态的资源,因此发送消息的操作将导致任务B转换为可执行状态-并将其放置在当前处理器的执行队列中。然后,处理器将从执行队列中推断出下一个任务,执行该任务,并重复此循环,直到到达任务B。

问题是在发送消息和完成任务B之间可能会有很大的延迟。另外,热数据(例如消息)存储在CPU缓存中,但是到任务完成时,很可能会清除相应的缓存。

为了解决这个问题,新的Tokio调度程序实现了优化(与Go和Kotlin调度程序一样)。当任务进入可执行状态时,它不会放在队列的末尾,而是存储在特殊的“下一个任务”插槽中。处理器始终在检查队列之前检查此插槽。如果在插入插槽时已经有旧任务,则将其从插槽中删除并移至队列末尾。因此,几乎无延迟地完成发送消息的任务。



油门捕捉


在作业捕获调度程序中,如果处理器执行队列为空,则处理器将尝试从对等CPU捕获任务。首先,选择一个随机的对等CPU,如果没有找到任务,则搜索下一个,依此类推,直到找到任务。

实际上,几个处理器通常大约在同一时间完成对执行队列的处理。当作业包到达时(例如,当epoll进行轮询以了解套接字的准备情况)。处理器唤醒,接收任务,启动它们并完成。这导致所有处理器同时尝试捕获其他人的任务的事实,即许多线程试图访问相同的队列。发生冲突。起点的随机选择有助于减少竞争,但是情况仍然不是很好。

要变通解决此问题,新的调度程序限制了执行捕获操作的并行处理器的数量。我们称处理器试图捕获其他人的任务的状态简称为“工作搜索”或“搜索”(稍后会详细介绍)。使用原子值执行这种优化int,处理器在开始搜索之前增加,而在退出搜索状态时减少。在搜索状态下,尽可能多的可以是处理器总数的一半。即,设置了近似极限,这是正常的。我们不需要对搜索中的CPU数量进行硬性限制,仅需节流即可。为了算法效率,我们牺牲了准确性。

进入搜索状态后,处理器尝试从对等CPU捕获工作并检查全局队列。

减少线程之间的同步


调度程序的另一个重要部分是通知对等CPU新任务。如果“兄弟”睡着了,他醒来并捕获任务。通知扮演另一个重要角色。回想一下,队列算法使用弱原子排序(Acquire/ Release)。由于内存是原子分配的,因此无法保证对等处理器将在没有其他同步的情况下看到队列中的任务。因此,通知也对此负责。因此,通知变得昂贵。目的是最大程度地减少它们的数量,以免使用CPU资源,即处理器有任务,而兄弟无法窃取它们。通知数量过多会导致雷电群问题

最初的Tokio计划者对通知采取了幼稚的方法。每当将新任务放入执行队列时,处理器就会收到通知。每当通知CPU并在唤醒后看到任务时,便通知另一个CPU。这种逻辑很快导致所有处理器醒来并寻找工作(引起冲突)。通常,大多数处理器都找不到工作,然后再次入睡。

新的调度程序大大改善了这种模式,类似于Go调度程序。通知将像以前一样发送,但是仅当搜索状态中没有CPU时才发送(请参阅上一节)。处理器收到通知后,立即进入搜索状态。当处于搜索状态的处理器找到新任务时,它首先离开搜索状态,然后通知另一个处理器。

此逻辑限制了处理器唤醒的速度。如果立即计划整个任务包(例如,何时epoll轮询套接字的就绪状态),则第一个任务将向处理器发出通知。他现在处于搜索状态。程序包中剩余的计划任务将不会通知处理器,因为至少有一个CPU处于搜索状态。这个通知的处理器将捕获批处理中的一半任务,然后通知另一个处理器。第三个处理器唤醒,找到前两个处理器之一的任务,并捕获其中一半。这样可以平稳地增加工作CPU的数量,并实现快速的负载平衡。

减少内存分配


新的Tokio调度程序仅需要为每个生成的任务分配一个内存,而旧的则需要两个。以前,任务结构如下所示:

 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。很长一段时间以来,我都想修复这个关节(我于2014年首次尝试)。自旧的Tokio规划师以来,有两件事发生了变化。一是稳定下来std::alloc。其次,未来的任务系统已切换到明确的vtable策略。最终,正是缺少了这两件事来摆脱每个任务的低效双内存分配。

现在,该结构Task以以下形式表示:

 struct Task<T> { header: Header, future: T, trailer: Trailer, } 

对于任务必要和HeaderTrailer的,但它们是在“热”的数据(头)之间划分和“冷”(尾部),米。E.约之间的数据经常被访问和那些很少使用。热点数据放置在结构的顶部,并尽可能少地存储。当处理器取消引用任务指针时,它将立即加载缓存行(从64到128字节)。我们希望这些数据尽可能相关。

减少原子链接计数


我们在本文中讨论的最后一个优化是减少原子链接的数量。有很多关于任务结构的参考,包括调度程序和每个唤醒程序。管理此内存的一般策略是原子链接计数。每次克隆链接和删除链接时,此策略都需要原子操作。当最后一个链接超出范围时,将释放内存。

在旧的Tokio调度程序中,调度程序和所有唤醒程序都包含一个指向任务描述符的链接,大约是:

 struct Waker { task: Arc<Task>, } impl Waker { fn wake(&self) { let task = self.task.clone(); task.scheduler.schedule(task); } } 

任务唤醒时,链接被克隆(原子增量发生)。然后将链接放置在执行队列中。当处理器接收任务并完成其执行时,它将丢弃该链接,从而导致原子减少。这些原子操作(增加和减少)加起来。

任务系统的开发人员先前已确定了此问题std::future他们注意到,在呼叫时,通常不再需要Waker::wake原始链接waker这使您可以在将任务移至执行队列时重用原子链接计数器。std::future现在,任务系统包括两个“唤醒” API调用:


这样的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); } } 

当您有责任唤醒时,这才避免了其他链接计数的开销。根据我的经验,几乎总是建议您醒来&self。唤醒会self阻止唤醒程序的重用(在资源发送大量值(即通道,套接字等)的情况下很有用)。同样,在这种情况下self,实现线程安全唤醒更加困难(我们将在另一篇文章中保留详细信息)。

新的计划程序self通过避免in中的原子增量来解决“唤醒的问题wake_by_ref,从而使其与wake(self)为此,调度程序会维护一个当前活动(尚未完成)的所有任务的列表。该列表表示将任务提交到执行队列所需的参考计数器。

这种优化的复杂性在于,调度程序在收到保证将再次将任务放入执行队列之前,不会从列表中删除任务。该方案的实现细节不在本文讨论范围之内,但我强烈建议您查看源代码。

Loom的粗体(不安全)并发


没有锁,编写正确的并行代码非常困难。最好是缓慢但正确地工作,而不是快速但有故障,特别是如果这些错误与内存安全性有关。但是,最好的选择应该是快速运行且没有错误。新的调度程序进行了一些相当积极的优化,并且std为了专业化而避免了大多数类型。通常,其中有很多不安全的代码unsafe

有几种测试并行代码的方法。其中之一是让用户代替您进行测试和调试(这是肯定的诱人选择)。另一种方法是编写循环运行并可以捕获错误的单元测试。甚至可以使用TSAN。当然,如果他发现错误,则不重新启动测试周期就无法轻松地重现该错误。另外,此周期需要多长时间?十秒?十分钟?十天?以前,您必须在Rust中测试并行代码。

我们发现这种情况是不可接受的。当我们发布代码时,我们希望(尽可能)充满信心,尤其是在没有锁的并行代码的情况下。 Tokio用户需要可靠性。

因此,我们开发了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成为开发新计划者的宝贵工具。他捕获了十多个通过所有单元测试,手动测试和负载测试的错误。

精明的读者可能会怀疑织机检查“所有可能的排列”,他是对的。天真的排列会导致组合爆炸。任何不平凡的测试将永远不会结束。这个问题已经研究了很多年,并且已经开发了许多算法来防止组合爆炸。织机基本基于算法与偏序动态减少(动态局部级减少)。该算法消除了导致相同结果的置换。但是状态空间仍然可以增长到无法在合理的时间量(几分钟)内进行处理的程度。 Loom允许您使用带有局部排序的动态缩减功能进一步限制它。

一般情况下,使用织机我感谢广泛的测试,现在很多在调度的正确性更有信心。

结果


因此,我们研究了什么是调度程序以及新的Tokio调度程序如何实现了巨大的性能提升……但是又是什么样的增长呢?鉴于新调度程序仅是开发的,因此在现实世界中尚未对其进行完整的测试。这就是我们所知道的。

首先,新的调度程序在微基准测试中要快得多:

旧计划者


 测试chained_spawn ...工作台:2,019,796 ns / iter(+/- 302,168)
测试ping_pong ...工作台:1,279,948 ns / iter(+/- 154,365)
测试spawn_many ...基准:10,283,608 ns / iter(+/- 1,284,275)
测试yield_many ...基准:21,450,748 ns / iter(+/- 1,201,337) 

新计划者


 测试chained_spawn ...工作台:168,854 ns / iter(+/- 8,339)
测试ping_pong ...工作台:562,659 ns / iter(+/- 34,410)
测试spawn_many ...工作台:7,320,737 ns / iter(+/- 264,620)
测试yield_many ...基准:14,638,563 ns / iter(+/- 1,573,678) 

该基准测试包括以下内容:

  • chained_spawn 递归产生新任务,即生成一个任务,该任务又生成另一个任务,该任务又生成一个任务,依此类推。
  • ping_pong选择一个频道oneshot并产生一个在该频道上发送消息的任务。原始任务正在等待消息。这是最接近“真实世界”的测试。
  • spawn_many 检查调度程序中任务的执行情况,即从其上下文外部生成任务。
  • yield_many 检查是否有独立的任务唤醒。

基准之间的差异非常明显。但是,这将如何反映在“现实世界”中?很难肯定地说,但是我尝试运行Hyper基准测试

这是最简单的Hyper服务器,其性能是使用来衡量的wrk -t1 -c50 -d10

旧计划者


 正在运行10s测试@ http://127.0.0.1 {000
  1个线程和50个连接
  线程统计平均值Stdev Max +/- Stdev
    延迟371.53us 99.05us 1.97ms 60.53%
    要求/秒114.61k 8.45k 133.85k 67.00%
  1139s在10.00s中的请求,95.61MB读取
请求/秒:113923.19
传输/秒:9.56MB 

新计划者


 正在运行10s测试@ http://127.0.0.1 {000
  1个线程和50个连接
  线程统计平均值Stdev Max +/- Stdev
    延迟275.05us 69.81us 1.09ms 73.57%
    要求/秒153.17k 10.68k 171.51k 71.00%
  150.00671在10.00s中的请求,读取127.79MB
请求/秒:152258.70
传输/秒:12.78MB 

更改调度程序后,我们发现每秒的请求数量增加了34%!第一次看到此消息时,我感到非常高兴,因为我预计最多可以增加5-10%。但是后来我感到难过,因为此结果还表明旧的Tokio调度程序不是很好。然后我想起Hyper 已经是TechEmpower评级领导者。有趣的是,新计划者将如何影响评分。

Tonic,gRPC客户端和服务器,新的调度程序加速了约10%,考虑到Tonic尚未完全优化,这是非常令人印象深刻的。

结论


经过几个月的工作,我真的很高兴最终完成这个项目。这是Rust异步I / O的一项重大改进。我对所做的改进感到非常满意。Tokio代码中仍有许多优化空间,因此我们还没有完成性能改进。

我希望本文中的材料对尝试编写任务计划程序的同事有用。

Source: https://habr.com/ru/post/zh-CN472242/


All Articles