铁锈并发:消息传递错误的原型

2020-06-22 19:52:12

只要有机会,我就会赞扬消息传递和事件循环在Rust中构建并发工作流的优点。然而,在我睁大眼睛的热情中,我会让它听起来几乎不会出错。

所以今天,让我们来了解一些关于错误消息传递代码原型的细节。

我认为这个错误适用于几乎所有的消息传递代码(例外情况可能是使用同步绑定通道)。

尽管我认为原生线程和横梁通道比异步/未来生态系统中的等效通道更容易使用(“您的意思是没有满足Iml Futurestraint边界是什么意思?这是来自!^@#%期货板条箱的频道!“15分钟后:”啊,OK…“)。,我认为消息传递和事件循环的强大之处在于它们在底层并发机制上的逻辑抽象方式。

您需要的是“代表通道/源或事件的东西”,以及“代表执行线程的东西”,仅此而已。

这个故事是根据真实事件改编的。在某些情况下,出于戏剧性目的改变了事件、人物和时间表。某些角色可能是复合体,也可能完全是虚构的。

好的,首先,我们使用的是铁锈。因此,这意味着我们可以完全无所畏惧地使用™来实现并发,例如:

通过利用所有权和类型检查,许多并发错误在RUST中是编译时错误,而不是运行时错误。(来源:https://doc.rust-lang.org/book/ch16-00-concurrency.html)

虽然这是真的,但从您不能意外地在线程之间共享某些变量的意义上讲,这并不妨碍您编写有错误的并发业务逻辑。

因此,Rust编译器在遇到特定类别的错误时“会支持您”,但实际上不能向您展示如何确保您的逻辑是您可以推理的东西,并且显示确定性的结果(在一定程度上)。

那么,有什么可以呢?嗯,您可以尝试使用锁,尽管这可能需要将它们与condvar配对,或者,如果可以的话,选择“更简单”的方法并使用以下组合:

这两者本质上是联系在一起的:“运行事件循环”的组件只是碰巧由循环驱动的“执行线程”,在该循环中,消息在一个或多个(通过选择)通道上顺序接收。

非本机线程的“执行线程”的一个例子是Chromium的序列。我认为这真的是一个很棒的名字,因为它准确地传达了这个概念。

神奇之处在于让多个这样的“事件循环”相互通信,使您能够构建具有确定性特征的复杂(但希望尽可能简单)并发工作流。

当“事件循环”处理给定消息时,它只对其唯一拥有的数据进行操作。

第2版,应该注意的是,将共享状态与事件循环相结合通常会导致微妙的灾难。原因是,虽然共享状态可以被锁有效地“保护”,但锁本身并不提供与消息传递的任何类型的同步。因此,虽然看起来似乎可以工作,但您的逻辑很可能会被有关消息和锁之间(不存在的)同步的错误假设从根本上破坏。

修复方法通常是,对于给定的工作流,要么只使用消息,要么只使用锁(然后将它们与condvar配对以进行信令)。

最后,当我编写“针对给定工作流”时,这仍然允许工作流相互嵌套,其中每个“级别”可以使用不同的模型。例如,您可以有一个涉及通信事件循环和消息的顶级工作流,然后这些组件中的一个仍然可能在内部拥有嵌套的工作流,然后这些工作流可能会涉及到一些锁和condvar(例如这里的示例)。

那么这种决定论什么时候崩溃呢?让我们在下一节来看一看。

让我们从一个真实的例子开始,然后试着把它变成更抽象的东西。

所以,当我在Servo做这个公关时,我惊讶于一些间歇性的测试失败,它在更改之后出现(顺便说一句,不是公关的最终更改,假设它出现在提交之间)。

目标是让脚本将数据块流式传输到Net,然后在完成后或出现错误时关闭工作流。

工作流是“基于拉取的”,它是网络请求下一个块,然后脚本尝试从一个源读取它并转发它。

在脚本方面,它涉及两个线程:一个用于接收IPC消息,称为“IPC路由器线程”,另一个用于在运行时运行代码,其中“从源读取数据”涉及在此运行时运行代码。我们称它为“脚本线程”。

当从源读取数据时,可能会发生三件事:我们得到一大块数据,我们得到一个“完成”标志,或者我们得到一个错误。

因此,在PR之前,“IPC路由器线程”将在一种“事件循环”中简单地从网络组件接收请求大块数据的消息。然后,“脚本线程”将从路由器线程接收一条消息,然后运行一些用户提供的代码,以便“尝试读取块”。

当操作的结果可用时,“脚本线程”将直接将结果发送到NET,该结果可能是一块数据,也可能是一个“流完成”信号。

此外,在“完成”的情况下,“脚本线程”将触发工作流的“关闭”,首先将其发送者放到net,然后还向“IPC路由器线程”发送一条消息,告诉它也将其发送者放到net。当时的想法是,这将断开网络中相应的接收器的连接,这将导致丢弃用于请求数据块的唯一发送器,然后这将断开“IPC路由器线程”上的接收器。

因此,这实际上工作得很好(除了一些例外),但是当我添加一些代码来区分“完成”和“错误”情况时,出现了问题。

基本上,“Done”和“Error”都应该导致工作流关闭,但是“Error”情况需要首先执行某些操作以将错误传播到Net中的其他位置。

我最初对传播错误的看法是,在“错误”的情况下,“脚本线程”遵循以下顺序:

像以前一样关闭工作流,通过一条消息到“IPC路由器线程”,然后它会将这个特定工作流的关闭传播到NET。

错误通过断言错误将传播的测试来显示,断断续续地失败。

这实际上花了几个打印时间!很快问题变得清晰起来:我期待来自两个不同线程的两条消息能够神奇地同步它们的顺序。

当您像这样阅读它时,这一点非常清楚,但它可能会在业务逻辑中悄悄上升,而不会被发现,直到测试开始间歇性失败。

这就是“脚本线程”和“IPC路由器线程”正在做的事情:

脚本将“ERROR”消息发送到Net,期望该消息得到处理并传播错误。

脚本将“完成”消息发送到“IPC路由器线程”,期望该线程随后发送一条消息以关闭到网络的工作流。

结果:两条消息,一条“错误”和一条“关闭”,从两个不同的线程发送到NET,但是逻辑期望“错误”首先由NET接收和处理。

是的,所以如果使用同步有界通道,就不会遇到这个问题。然而,这也可能会极大地降低各种组件的吞吐量。另一篇帖子的主题…

另一方面,正如matklad所指出的,问题的“最后关机”部分可以通过不发送明确的“关机”消息来防止,而是依赖于丢弃发送者来发出关机信号。在这种情况下,无论线程是如何调度的,只要“脚本线程”保持其发送方,并且/或者已经发送了“错误”消息,那么在“断开连接”信号传输到接收方之前,将保证在某个时刻处理该消息。

然而,这是关于消息传递错误的“原型”,在无数的情况下,您可能需要执行操作1和2,其中2不是“关闭”,并且错误可能会出现。当2为“关闭”时,您可以依靠丢弃发送器进行信令来防止此类错误。

这很狡猾,原因是“脚本线程”实际上会“按顺序”发送消息,但不会发送到同一线程,并且“IPC路由器线程”发送的“SHUTDOWN”消息会间歇性地在“脚本线程”发送的“错误”消息之前收到。

即使“IPC路由器线程”在接收到由“脚本线程”发送的第二条消息之后才会发送“Shutdown”消息,但是这里的“第二条”消息是没有意义的,因为这些不是去往同一线程的消息,因此不能期望排序。

是的,我知道,这在某种程度上是一种轻率的方式来编写一些代码。但是几乎所有的消息传递错误都可以追溯到类似的东西。让它们偷偷摸摸的是业务逻辑的复杂性,这可能会隐藏其中内置的各种虚构的排序假设。

嗯,很高兴有测试可以捕捉到这些东西,但是你必须幸运地从一开始就有这些测试。如果这可以潜入业务逻辑,也意味着它不能被测试覆盖。Serve很幸运能够依赖于共享网络平台测试来实现对各种逻辑…的广泛覆盖。

接下来,让我们看看如何才能让这场混乱恢复一些秩序。

因为两个消息被发送到运行两个不同的“事件循环”的两个不同的“执行线程”,所以顺序被打破。

因此,这个“并行队列”本质上是运行“事件循环”的“执行线程”的原型,在本例中定义为已排入队列的持续运行的算法步骤(实际上类似于在通道上发送消息)。

这很有用,因为它清楚地表明,如果您将两组步骤排队到两个不同的并行队列,它们将独立运行,因此不会以任何方式同步它们的顺序(这就像在两个通道上向两个不同的线程发送两条消息一样)。

此外,如果您有两个不同的并行算法,它们都将步骤排队到同一并行队列中,那么您也无法知道哪组步骤将“首先”运行(这就像是让两个线程在同一通道上向同一第三个线程发送两条消息)。

只有在将步骤排队到相同的并行队列时,您才能从相同的算法获得排序保证。

这两条消息需要通过网络按此顺序处理。然而,它们是由脚本从两个不同的线程发送的,“脚本线程”发送潜在的“错误”,然后它将“关闭”发送到第二个“IPC路由器线程”,然后第二个“IPC路由器线程”在关闭其工作流的本地部分后,也将其发送到网络组件。

但是我们总是希望Net组件首先处理任何潜在的“错误”消息。

因此,使用“并行队列”时,我们需要确保“错误”和“完成”步骤从单个算法按顺序排队到同一队列。

使用Rust通道和运行“事件循环”的某种“执行线程”,我们需要在同一通道上从同一线程发送两条消息。

因此,解决方案是使用一个运行“事件循环”的“执行线程”来序列化“错误”和“停止”操作,让我们称其为“并行队列”,然后从该并行队列中,我们可以将进一步的步骤排入另一个并行队列(NET中的队列)。

简单地说:我们需要两条消息都通过IPC路由器线程,然后IPC路由器线程将与Net通信。换句话说,我们不能让“脚本线程”发送两条消息,一条到NET,另一条到IPC路由器线程,然后在IPC路由器线程中向NET发送另一条消息,并期望在“IPC路由器线程-&>NET”消息之前处理“脚本线程-&>NET”。

因此,我们重新构造操作,其中“脚本线程”只是向“IPC路由器线程”发送“错误”或“完成”消息,然后该线程负责以下任一任务:

答:如果出现错误,首先将“错误”发送到网络,然后关闭工作流,或者。

在“IPC路由器线程”中,当从“脚本线程”接收到“错误”或“完成”消息时,适当的操作顺序如下:

然后,IPC路由器线程上的STOP_READING操作将在以下位置发送“ERROR”或“DONE”消息:

最后,在NET中,消息将以传播潜在错误的方式进行处理:

所以,对于现在还没有离开的少数几个人,让我们来回顾一下:

一个并行队列将两组算法步骤排队到两个不同的并行队列上,同时一直期望保留某种排序。

一个并行队列将单个算法步骤集合入队到单个另一个并行队列上,然后使这些步骤在运行时导致本地状态改变,随后将另一组步骤入队到又一个单个并行队列上(这在被处理时也将导致局部状态改变)。

结果是实际的确定性行为,其中任何错误都将在流工作流关闭之前传播。

好的,我认为这篇文章的英文行数/代码行数是最高的,这真的是放大了一些东西,这些东西很容易被认为是一个不相干的小错误,但我认为这实际上是您在消息传递代码中遇到的几乎每一个错误的基础。