Scipio:适用于Rust和Linux的每核线程机箱

2020-11-03 14:45:35

在降低云成本方面,优化代码中的瓶颈只能做到这一点。可能是时候重新考虑您的体系结构了。也许您正在寻找一种考虑到现代硬件和软件提供的功能的新体系结构。一种这样的体系结构被称为“每核线程”。最近的研究表明,每核线程架构可以将应用程序的尾部延迟提高高达71%。这听起来很棒,但是当您的应用程序开发人员必须调整到一种全新的工作方式并处理一组特定于此特定模型的神秘挑战时,开发人员生产力的损失很容易抵消每核线程的机器效率收益。

Datadog也不能幸免于这些问题。我们大规模运行各种数据存储区,这些数据存储区以非常高的吞吐量接收指标。我们也开始看到我们的一些组件的现有架构开始显示出局限性。度量数据在空间上的分布性非常高,看起来像是每核线程架构的主要候选者-但我们关心的是保持工作的可管理性。

本文将探讨每核线程模型及其优势和挑战,并介绍我们针对此问题的解决方案Scipio(您也可以在crates.io上找到它)。Scipio允许Rust开发人员以一种简单和可管理的方式编写按内核线程的应用程序。

我们知道,每核线程可以带来显著的效率提升。但这是什么呢?简而言之,任何中等复杂的应用程序都有许多需要执行的任务:它可能需要从数据库读取数据,通过机器学习模型提供数据,然后沿管道传递结果。其中一些任务自然是连续的,但许多任务可以并行完成。由于现代硬件不断增加可用于应用程序的核心数量,因此有效地使用它们以实现良好的性能数字非常重要。

要做到这一点,最简单且经过最多时间考验的方法是使用线程:对于每个内部任务,应用程序可能使用不同的线程。如果一个线程有可用的工作要做,它就会去做;否则,它将进入休眠状态,并允许下一个线程运行。

当多个线程需要操作相同的数据时,它们需要获取锁以保证每次只有一个线程取得进展。锁是出了名的昂贵,不仅因为锁定操作本身很昂贵,而且还因为它们增加了应用程序除了等待之外什么都不做的时间。

每次一个线程需要让路给另一个线程时,都会进行上下文切换。上下文切换非常昂贵,成本约为5微秒。这听起来并不昂贵,但是如果我们考虑到著名的Linux开发人员Jens Axboe最近发布了他的新IO内核基础设施的结果,其存储I/O时间不到4微秒,这意味着我们现在处于线程之间的上下文切换比I/O操作更昂贵的时刻!

并不是所有的线程编程都需要阻塞:最近,Go、Node.js等语言和框架充分发挥了异步编程的作用。甚至连C++都有期货和最近的协程作为其标准的一部分,我们今天的明星Rust也是如此。

异步编程是朝着正确方向迈出的一步,它允许程序员检查工作,而不是阻塞等待工作。但是对这些语言的异步支持通常仍然依赖于文件I/O等操作的线程池,以及应用程序内部各自线程中的单独任务。

每核线程编程完全消除了画面中的线程。每个内核或CPU都运行一个线程,并且通常(尽管不一定),这些线程中的每个线程都固定在一个特定的CPU上。由于操作系统调度程序不能移动这些线程,并且同一CPU中永远不会有另一个线程,因此不存在上下文切换。

仍然有来自硬件中断的上下文切换,以及可能共享机器的其他助手任务(如代理)。为了获得最高性能,操作员可以配置操作系统,使某些CPU不会分配给应用程序,而是专用于这些任务。

要利用单核线程的优势,开发人员应该使用分片:单核线程应用程序中的每个线程负责数据的一个子集。例如,可能是每个线程将从不同的Kafka分区读取,或者每个线程负责数据库中键的子集。只要两个线程从不分担处理特定请求的责任,任何事情都是可能的。随着对可伸缩性的关注成为标准而不是例外,分片通常已经以这样或那样的形式出现在现代应用程序中:在这种情况下,每个核心的线程成为最重要的樱桃。

现在明确分配给单个线程的每个异步回调也会运行到完成:因为没有其他线程,所以没有人可以抢占正在运行的请求:它要么完成,要么显式地和协作地产生结果。

这种模式最大的优点是永远不需要锁。想想看:如果只有一个执行线程,那么(对于该请求)不可能同时发生两件事。

以向缓存添加元素为例。在简单的线程化环境中,缓存更新可以从多个线程进行,因此需要获得如下所示的锁:

就其本身而言,分片已经带来了优势:通过将大缓存拆分成较小的部分,我们可以减少锁争用。现在可以同时访问Key 1和Key 3,每个分片都有自己的锁。但是,因为操作系统仍然可以将每个线程从CPU中移除,并且取代它的新线程可以访问与密钥3位于同一碎片中的密钥4,所以仍然需要持有一个锁来协调更新。

每核线程设计更进一步:我们知道对键3和键4的更新是序列化的。必须是这样的!如果它们在同一线程中运行,则我们要么在键3上操作,要么在键4上操作,而不是同时在这两个键上操作。只要我们在宣布任务完成之前完成更新,锁就会消失。正如我们在下图中看到的,每个缓存碎片的所有可能的更新任务都是自然序列化的,并且一次只运行一个(紫色)。因此,只要它在离开线程之前完成更新,就不需要锁。

我希望如此!每核线程数已经存在一段时间了。事实上,在我加入Datadog之前的许多年里,我一直在一个名为Seastar的C++每核线程框架中工作,Seastar是ScyllaDB NoSQL数据库背后的引擎。ScyllaDB设法利用每核线程模型来提供Apache Cassandra等现有数据库的更高效实现,因此我知道该模型也适用于我们的数据存储,同时保持复杂性可控。

然而,我并不打算在这里讨论语言火箭筒。对于这个特定的问题,我们有理由不选择C++,而选择了Rust。下一步是加强铁锈生态系统,这样我们就可以有一个类似的工具。如果您想了解更多关于我对C++和Rust在这一特定任务中的比较情况的看法,您可以查看我关于这个主题的文章。

以LSM树为例,它是现代数据库中常用的一种数据结构。数据在内存区域停留一段时间,然后写入不可变文件。有时需要将这些文件组合在一起,以防止读取变得过于昂贵。

其中一些操作可能相当昂贵且寿命很长-这就是传统上使用线程的原因。通过使用线程,应用程序可以依靠操作系统抢占长时间的任务,并确保重要任务不会匮乏。所有的锁定都被认为是要付出的公平代价。

但是,这在每个核心的线程应用程序中是如何工作的呢?Scipio允许应用程序创建不同的执行队列:

在上面的示例中,存在两个队列。创建任务时,可以在其中的任何一个中派生任务。除了名称之外,我们还可以为每个类指定两件事:

其延迟要求:Scipio在存在延迟敏感型任务时的行为有所不同,对它们的I/O操作进行优先排序。

其份额:在上面的示例中,两个类别的份额相等。Scipio有自己的内部调度器,它选择要运行的任务队列,并为每个任务队列提供与其份额成比例的时间。共享数量是另一个任务队列的两倍的任务队列,随着时间的推移,其运行时间将是另一个任务队列的两倍。在本例中,它们都应该使用50%的系统资源,因为它们拥有相同数量的共享。

Linux在现代云基础设施中占据主导地位。Linux最近在名为io_uring的新异步API的推动下,在其I/O能力方面迎来了一场革命。我过去曾详细地写过关于它的能力的文章。IO_INGING不仅能够处理文件I/O,还能够在单个通用API上处理网络套接字、计时器和许多其他事件。

通过从一开始就利用io_uring,Scipio可以重新审视Rust中的I/O应该是什么样子。让我们更深入地研究一下架构。

通常,普通线程化应用程序为整个应用程序注册单个IO,这可能会在添加或完成请求时引起争用。这是Ringbahn和Rio等其他Rust IO板条箱所采用的方法(在撰写本文时,Tokio使用普通线程池进行文件I/O)。

对于每个执行线程,Scipio注册它自己的一组独立的环,这些环可以无锁操作。套装?是!。每个线程使用的不是一个环,而是三个环,每个环扮演不同的角色。

一个正常的请求,如打开或关闭文件,从套接字发送或接收数据,将根据其延迟需求,在主或延迟环上进行。

当这些请求准备就绪时,它们将发送到io_uring的完成环中,Scipio可以使用它们。由于io_uring的体系结构,甚至不需要发出系统调用。这些事件存在于Linux和应用程序之间的共享内存区域中,并由环形缓冲区管理。

这两个戒指有什么不同?从本质上讲,每核线程模型在调度方面是协作的:如果任务可以在没有察觉的情况下从CPU中拖出,我们就不能使用无锁编程。因此,每当他们运行太长时间时,他们都必须自愿放弃控制权。

多长时间才算太长呢?要执行一些长期操作(比如大小未知的循环)的任务应该调用一个名为Year_if_Need()的Scipio函数。下面是一个例子:

//现在正忙着循环,请确保我们在必须时让步。loop{if*(lat_status.borrow()){Break;//Success!}Local::Year_if_Needed().aWait;}。

此代码使用循环,直到某个条件成立,这可能需要很长时间。如果用户不调用Year_If_Needed(),其他任务可能会变得饥肠辘辘。该函数直接利用了Iouring的体系结构。让我们回想一下循环缓冲区应该是如何操作的:

应用程序使用缓冲区头部的事件,并在完成后移动其位置。Linux将事件发布到缓冲区的尾部,并在完成后类似地移动其位置。因为这发生在共享内存区,所以我们可以随时知道环中是否有挂起的事件。这也非常便宜:我们所需要做的就是读取两个整数并比较它们,这不会给这些循环增加很大的开销。

但是我们的Year_if_Need()实现只考虑了延迟环。例如,应用程序可以侦听两个套接字:一个套接字用于必须以良好的延迟尽快提供服务的查询,另一个套接字用于吞吐量更为重要的查询。

如果查询到达面向吞吐量的套接字,其他正在运行的任务将不会立即放弃。当查询确实控制了CPU时,它将拥有更长的时间。随着时间的推移,Scipio的调度器将确保每个类运行相当长的时间,但是每个时间块都会更长。

但是,如果查询到达面向延迟的套接字,其他任务就会知道并产生结果。

细心的读者会注意到图中主环和延迟环之间的链接。虽然有一些实现细节,但这是此体系结构之上的樱桃。当没有剩余的工作要做时,执行器所在的线程进入休眠状态。可以通过阻塞IO进入睡眠状态:当发生事件时,它将自动唤醒。但是,根据定义,阻塞调用将阻塞,不会执行任何其他操作。所以只能等待其中一个戒指。

Io_uring支持的众多操作之一是轮询,它通知我们任何文件描述符中的活动。因为io_uring本身有一个文件描述符,所以也可以对其进行轮询。因此,在Scipio发出对主环的阻塞调用之前,它会注册延迟环的文件描述符,以便轮询到主环上。如果延迟环中有任何事件,它将在主环中生成活动,进而唤醒主环。

最后一个戒指是投票戒指。它用于来自NVMe设备的读写请求。通常,存储I/O请求在准备就绪时会生成中断,导致Linux停止正在处理它们的操作,这会生成另一个上下文切换。

通过轮询环的请求不会生成中断,而是依赖于Scipio在自己的时间显式轮询或在内核准备就绪时请求内核。这进一步降低了上下文切换的代价,对于可能生成小请求的工作负载尤其重要。例如,如果用户想要在时间序列中的特定时间点生成警报,其大小不超过几个字节。

因为此环中的请求不会生成中断,这意味着如果有挂起的I/O请求尚未完成,我们将无法进入睡眠状态。所以它不需要和其他戒指联系起来。库伯内斯也可以吗?

Linux在现代数据中心中无处不在,以至于我们可以利用仅限Linux的API(如io_uring)来实现像Scipio这样的东西。但另一项正在缓慢但肯定地达到这一地位的技术是Kubernetes。Kubernetes是一个灵活的抽象,其中pod可以在任何地方运行。这就回避了一个问题:每核线程架构在Kubernetes上能做得很好吗?

答案是肯定的:每核线程应用程序可以在任何Kubernetes基础设施上运行。但是,最佳性能将来自将应用程序与底层硬件中可用的物理核心相匹配。要有效地做到这一点,请执行以下操作:

在运行有状态集时,许多组织已经完成了其中的大部分工作,这就是对可靠和一致性能的需求所在。

随着硬件变得更快、功能更丰富,使应用程序与新技术保持一致以充分利用硬件提供的功能非常重要。需要为可伸缩性进行分片的现代应用程序是使用按核线程体系结构的主要候选者,在这种体系结构中,每个CPU将独家控制数据集的一个片段。

每核线程体系结构对现代硬件很友好,因为它们的本地特性有助于应用程序利用这样一个事实,即处理器附带越来越多的核心,而存储变得更快,现代NVMe设备的响应时间与操作系统上下文切换大致相当。

尽管有这些优点,每核线程架构可能会令人望而生畏且复杂,这就是我们编写Scipio的原因。Scipio构建在Rust的本地异步支持和Linux创新的基于事件的Iouring API之上,以构建易于使用的每核线程库。

Scipio是一个开源项目,可以在Github和crates.io上获得。如果你发现它的用处,我们很乐意听听!我们现在在Zulip聊天上有了社区。如您所见,Datadog正在挑战现代数据中心的面貌。如果您对这类问题感兴趣,我们一直在寻找有才华的工程师加入我们!