一个不能编译的数据竞争(在Rust中)
我如何教Rust的类型系统拒绝我自己的并行Redux数据竞争,通过一次错误的开始和一次思维的改变。 我花费了许多夜晚追逐的一个错误类别,比我愿意记住的还要多。那种只在负载下发生,在您附加调试器时消失,并且需要三个工程师一个周末才能解决的数据竞争。Rust的借用检查器在值层次上防止了大多数数据竞争。但并非所有,并且绝对不是我在这里感兴趣的问题:编译器能否拒绝构建一个可能让两个化简器写入同一状态段的并行化简器管道?答案是肯定的。本文是我在ruxe(我的Redux风味的Rust学习库)中实现这一目标的故事。 Redux是什么?Redux是一种状态管理模式。它在前端JavaScript中变得著名,但其形状更为普遍;任何通过离散事件改变状态的系统都符合这一模型。流程图LR 用户([用户代码])--> |调度事件| 商店 商店 --> |状态 + 事件| 化简器 化简器 --> |新状态| 商店 商店 --> |读取| 用户 有三个规则使Redux成为它的样子。第一:单一真相来源。状态由商店拥有,其他地方没有。第二:状态在外部是不可变的。你不能直接操作它。你调度描述发生的事情的事件(JS世界称之为“动作”)。第三:状态转换通过一个纯化简器,即一个函数(状态,事件)→新状态。相同的输入,相同的输出,没有副作用。第三个规则使Redux著名的可调试性。记录事件流,重放它,您每次都得到相同的最终状态。时间旅行调试。通过生产痕迹进行崩溃诊断。可重现的错误。任何曾经尝试调试没有该属性的状态UI的人都知道为什么人们不断重新发明Redux。 我为什么关心 在我的日常工作中,我在工业现场上开发能源管理系统。整个堆栈中的一个模块使用类似Redux的Python模式:控制层,其中多个控制器并发运行并需要对工厂状态的共享一致视图。事件流入,化简器管道计算新状态,控制器从中读取。数字加起来比你想的快。一个典型的现场有数十个设备,每个设备按自己的周期轮询,一些周期降低到50ms。每个设备可以每次读取发布几十个寄存器。这是一个持续的、高流量的事件流,而纯Python Redux很难跟上。我们围绕这一点进行了工作。我们缓存读取,并以比底层遥测更粗的频率触发减少。这有效,但模式变得不那么纯净:状态不再与最新的遥测保持同步,我们牺牲了部分最初使Redux有吸引力的特性。分析告诉我们时间实际上花费在哪里:化简阶段。工厂有许多独立的子系统。太阳能、电池、计量、网格控制器等。每个子系统持有全局状态的一部分,每个子系统都有自己的化简器,只触摸自己的部分。流程图LR 事件[事件] --> R1[太阳能化简器] --> S1[太阳能切片] 事件 --> R2[电池化简器] --> S2[电池切片] 事件 --> R3[计量化简器] --> S3[计量切片] 这里是开始一切的观察:化简器是独立的。太阳能化简器从未触碰电池切片。电池化简器从未触碰计量切片。每个事件都通过它们的单次传递。串行运行它们会使延迟增加N倍。并行运行它们会得到max(延迟_i)。所以:使化简器并行化。外部的事件流相同,确定的结果相同,只需每次调度减少N倍的实际时间。 有一个问题。并行加共享状态等于数据竞争。 如果一个化简器意外写入另一个切片,您就会遇到经典的并发错误。非确定性输出,生产中的海森堡错误,您将在临终时记得的调试会话。 在大多数语言中,这就是类型系统放弃的地方。 C++给你提供互斥锁和原子操作,并祝你好运。高级语言提供同步原语和内存模型,但避免竞争的负担留给开发者;编译器不强制执行任何规则,团队的纪律才是。 Rust的类型系统可以直接编码这一特性。编译器本身可以拒绝构建会发生竞争的代码。这就是我在ruxe中设定的目标。 头脑模型:切片和不相交性 两个概念支撑着接下来的所有内容。在我们进一步讨论之前,有必要清楚地定义。切片是状态的子字段。如果您的状态包含一个计数器,一个用户和一个通知字段,那么它们是三个切片。切片化简器是一个只看到其切片的化简器。它不能触及其他切片。类型系统禁止它:函数签名仅暴露&切片,其他什么都没有。流程图LR 子图状态 SA[计数器] SB[用户] SC[通知] 结束 R1[计数器化简器] -.触碰.-> SA R2[用户化简器] -.触碰.-> SB R3[通知化简器] -.触碰.-> SC 我们想要的属性是不相交性
本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。
☕请我喝杯咖啡