C++与铁锈:异步线程/内核的故事

2020-09-12 00:49:02

我最近发布了一个新的Rust库,该库旨在简化异步每核线程(Thread-per-Core)应用程序的编写任务:Scipio。我打算用它来驱动我正在为我目前的雇主Datadog编写的新一代存储密集型系统。

但我并不是这类系统的新手:在过去的7年多时间里,我一直在为ScyllaDB工作,这是一个NoSQL数据库,它成功地将性能持续提高了5到10倍,这在很大程度上是通过利用基于Seastar C++异步每核线程框架的每核线程架构。

部分原因是我有幸后来开始学习了很多经验教训,Scipio在某些方面与Seastar不同。简单谈一谈:它对它的应用程序不那么固执己见,因为我试图将它定位为一个库,而不是一个框架。它允许应用程序在启动时动态更改延迟需求,而不是静态更改,等等。但总的来说,Seastar和Scipio非常相似。(另一个不同之处在于,Seastar是一个7年前的成熟框架,而Scipio对于初始版本来说几乎不够用)。

真正最大的区别在于Seastar是用C++编写的,而Scipio是用Rust编写的。因此,如果不主要讨论语言上的差异,就不可能对它们进行比较。比较语言听起来像是交换个人喜好的徒劳练习,我相信很多人以前都用Rust和C++编写过。但我相信这仍然很有趣:虽然我确信有很多文章笼统地谈论C++与Rust,但将焦点缩小到如何应用于特定用例往往是有价值的。

写一篇文章是困难的,因为你永远不知道谁在读,也不知道是在什么背景下读的。因此,我想首先非常清楚地说明两件事:

我热爱Seastar,我为它倾注了多年的心血。出于各种原因(我不会在这里详细介绍),C++不是我的选择。但是,如果您喜欢C++并且想要编写单核线程应用程序,请看一下Seastar。我保证你会喜欢的。如果你认为我在猛烈抨击它,那你就大错特错了。

对我来说,这段经历中最疯狂的部分是,当我开始做这件事的时候,我根本不知道铁锈。老实说,几个月后你能真正知道多少,所以从某种意义上说,我还在学习。如果你是经验丰富的Rustacean,请记住这一点。有些事情对你来说可能是显而易见的,但对我来说却完全是个惊喜。但这些正是我将在这里主要谈论的事情。

从一开始,与我的C++体验相比,Rust有一些我绝对喜欢的地方:

拉斯特对款式固执己见。我从来不喜欢人们浪费时间讨论代码样式,但同时我也同意一致的代码库更容易阅读。Ruust附带了一个可以为您格式化代码的工具,虽然您不必使用它,但是通过使用它,您可以使用与社区其他成员相同的样式进行编码,故事到此结束。

铁锈有一个非常好的模块系统。货物实在是太棒了,根据我的经验,我几乎不会改变它。

但让我们面对现实吧,这一切都很肤浅。为了让这个讨论更深入一些,我想用一个具体的例子来框定它。

“THEN”是Seastar处理未来结果的方式,但它不处理异常。“THEN_WARTED”为您提供了处理异常的机会。

在此修复之前,代码将无法正确处理异常,并且文件对象将在不关闭的情况下被销毁。RAII对于需要异步销毁的东西的使用有限,这是Rust共享的限制。如果不是Seastar文件有预读机制,这可能只是另一个错误。即使在文件关闭后,其中一些请求仍在进行中。当它们返回时,它们将写入现在已释放的内存区。

触发这一漏洞的情况极为罕见。事实上,据我所知,这只发生在一个用户身上,直到今天,我仍然不知道他们有什么特别之处。它几乎一直潜伏在那里。然而,一旦错误确实找到了一组正确的环境来表现自己,它就会每隔几个小时发生一次。

不幸的是,我不能详细说明情况,但是这个简单的bug是我曾经遇到过的最难处理的错误之一,如果不是最难处理的话。除了这个用户,没有其他人可以复制它,尽管它发生了很多次,但每次我们试图检测它时,它的发生都略有不同。基本上不可能对任何代码路径进行推理,因为根据定义,由于损坏,它将导致代码做一些没有人预料到的事情。在观察了几天的核心转储之后,我们确实发现,在某个地方设置为0的一段内存会神奇地翻到1,如下线所示。但是,如果你认为这有帮助,思考一下后续的问题:好的,是谁翻的?

找到并修复它是一项为期一周的多人工作,我只声称这是部分的,如果不是最低限度的功劳的话。大部分功劳都归功于阿维。如果您不知道Avi(ScyllaDB和KVM虚拟机管理程序的创建者),他是一个非常优秀的工程师,我有时甚至怀疑他是否真的是人类。他在这件事上挣扎了很久。

但考虑到它周围的环境,一个基本上不可能在生产负荷下重现和发生的错误,我可以有把握地说,这是我职业生涯中最糟糕的一周。别误会我的意思:在Seastar,这样的问题极其罕见。事实上,他们很少被告知犯这些错误是多么容易,这证明了Scylla的团队是多么有才华。但是,嘿,错误是会发生的,我们都会写错误,而异步代码就是硬…。对吗?

来自这一传统,我和铁锈在一起的头几周令人难以置信地沮丧到绝望的地步。作为一个一生都在性能导向系统中徘徊的人,我不会开始不必要地复制对象。拉斯特对借阅检查器有很好的规定,以保证安全,所以我想用它。共享数据所需支付的最低成本是引用,所以引用就是我想要使用的!

在Rust中,借用检查器根据一条简单的规则仔细检查您的所有引用:一次只能有一个可变引用,并且不能同时出现可变引用和不可变引用。

借用检查器可以很好地处理在可预测时间发生的事情。但是,一旦开始实现异步调用或稍后将由io_uring使用并存储在堆上数组中的请求队列,事情就会变得更加复杂。以下面的代码为玩具示例:

它创造了一个执行者,然后推动它的未来走向完成。未来是一个闭包的返回值,该闭包产生一个新的异步任务,该任务只触发一个计时器。该计时器的参数在引用b之后。这对我来说看起来是完全安全的,因为a,b引用的数据的所有者只有在程序结束时才会超出范围,因为对run()的调用正在阻塞(让我们假设它无论出于什么原因都不能是静态的)。

虽然我们可以推断a和b的生存期,但因为涉及到一个异步函数,并且我们无法控制它何时执行,借用检查器不能保证它的生存期足够长。

在我的脑海里,我必须做些什么,才能让借阅检查员知道我所做的一切都是正常的。发现终生注释,起初令人耳目一新,这是我在最初几周可能遇到的最糟糕的事情。它所做的一切只是让我的心充满了虚假的希望,只是后来粉碎了我的梦想。阿!。一旦我最终掌握了这些注释,我会想办法让借阅检查员知道一切都很好,…。(剧透提醒:我没有)。

就在那时,我想起了上面的故事,并意识到:我的代码真的安全吗?角落里的箱子呢?如果程序结束的时间比我预期的要早,并且发生了我没有预料到的崩溃,那么在我不知道的标准中有一些奇怪的析构函数排序规则,并且内存最终会将垃圾写入重要的文件,那该怎么办呢?如果我调用的子函数中有一个我甚至不记得的文件怎么办?

作为一名工程师是(或者至少应该是)一种谦卑的经历:所有我认为我肯定知道的事情,…。我以为我处理过的所有案件都是…。但我没有。

我们可以用一种方式重写此代码,使(编译时)借用检查器甚至不起作用:

RC是Rust对引用计数的说法。由于现在对数据进行了引用计数,因此可以保证数据存在足够长的时间。然而,引用计数的对象是不可变的,因此没有人会在他们的眼皮底下看到它的值的变化。为了能够更改其内容(在本例中我们不这样做,但是遗憾的是…)。,我们使用RefCell。参照单元不允许您使用借用检查器,但它会将检查移动到运行时。在铁锈中,这种模式被称为内部可变性。

即使此代码在运行时会因为违反借用检查器规则而失败,我们仍然处于优势地位:我们将以一种可预测的、可重现的方式失败,而不是细微的数据损坏。

但这才是异步+Thread-per-Core模型真正闪亮的地方:因为该数据是线程本地的,并且只有一个线程,所以绝对不会同时发生任何其他事情。这是不可能的。我们只会在定义良好的点上交付控制权,如果没有准备好,将来可能会推迟(此代码中对.await的调用)。

只要我们不用借来的任何东西调用.aWait(称为挂起点),这将总是有效的。我的Rust愿望清单的首要任务是将其移到编译检查时间,在那里编译器可以保证我的RefCell借用永远不会超过挂起点,然后可以取消运行时成本。拜托了,好吗?

您可以争辩说,我现在付出的代价是增加引用计数(以及运行时检查RefCells,这有望在将来避免)。事实上,我花了很长时间试图找到绕过它的方法,正是因为我想避免这一成本以获得最高性能。

但当我不再将其视为成本,而开始将其视为一项投资时,我的想法突然改变了:如果我可以回到过去,我会愿意支付增加引用计数的性能计数,以避免所有内存损坏问题的痛苦吗?地狱是的。有人说,税收是我们生活在文明社会的代价。也许你同意,也许你不同意,但我会说引用计数是我们生活在一个文明的异步世界中所付出的代价。

比引用计数更昂贵的一个事实是,考虑到我的数据被移到堆中,而不是驻留在堆栈中。但这只是这个玩具示例中的一个问题。如果您有通过异步函数传递的数据,我怀疑它是否位于堆栈中。也许它生活在其他结构中,在这种情况下,铁锈很可能会让你通过整个外部结构。丑陋。但还是值得的。

生锈是百分之百安全的吗?显然不是。这是真实的生活,而不仅仅是幻想。我们都陷入了泥石流,无法逃避现实:编译器本身可能会崩溃,有些事情您确实需要调用Rust的不安全关键字。但在实践中,我发现这样做效果很好。不安全的使用大多封装在程序的一小部分中,这使得对问题来源的推理变得更容易,在锈蚀生态系统中,它们通常只存在于核心库和基础设施(如Scipio)中,但应用程序本身将远离它,更喜欢使用构建在不安全之上的库的抽象。

最后,显然可以在C++中使用引用计数。您基本上可以用C++做任何事情(这是C++问题的一部分)。但是在Rust中,对于任何适度复杂的异步项目,您都必须这样做。