查看原文
其他

IC 内部结构:正交持久性

DFINITY 2022-07-07


正交持久性


我经常将互联网计算机(IC)描述为托管 WebAssembly 程序的分布式操作系统,我最喜欢的 IC 特性之一是正交(或“透明”)持久性,正交持久性会造成程序永远运行而不会崩溃或丢失状态的错觉,程序不需要显式地将状态持久化在文件中:运行时透明地处理持久性。


在正交持久性的早期研究中,状态修改看起来像数据库事务,程序员必须标记事务边界,以便系统可以识别安全的快照点。


Atkinson 等人的论文 “An Approach to Persistent Programming” 中在 ps-algol 中稍作修改的代码片段,注意显式调用 commitabort 过程。


为什么我们不能在每条指令后拍摄快照?这种粒度不仅效率低下,而且无助于我们从故障中恢复,如果程序由于输入错误而崩溃,那么在崩溃之前回滚指令是没有意义的。(强制性 Futurama 参考:该程序的行为就像 Fry 时间旅行到从吸血鬼国家大楼跳出后的那一刻。)


持久化编程比手动序列化有所改进,但感觉不够“正交”,我们可以做得更好。



Actors


Actors 是一种并发计算模型,将系统视为状态机的交互,IC 上运行的所有程序都是 Actors。


Actors 的伪代码


Actor 模型和正交持久性是天作之合,Actor 有一个自然的持久性边界:系统可以在它准备好处理下一条消息时抓取 Actor 状态的快照,程序员不需要 commit 显式调用来标记系统应该保持状态的执行点。


具有正交持久性的 IC 参与者的伪代码


IC 上参与者状态中最庞大的组件是 WebAssembly(Wasm)实例状态,一种简单的快照方法是在每个消息执行之前对表示状态的所有数据结构进行深度复制。


大多数状态数据结构的克隆成本很低,可变全局变量就是一个很好的例子。然而,快照和恢复 Wasm 内存带来了挑战:每个内存可以保存千兆字节的数据,在每个消息执行之前完全复制内存是非常昂贵的。


让我们看看 IC 实现如何应对这一挑战。


快照和增量


该实现将每个内存的内容划分为称为 pages 的 4096 字节块,当参与者执行消息时,系统会自动检测参与者修改或弄脏的内存页面。系统使用低级 UNIX API 来检测页面修改,由于大多数操作系统在 4096 字节的内存页面上运行,因此在副本中使用相同的页面大小是最自然的选择。


Actor 的内存快照是从页面索引到页面内容的映射,我们称这种数据结构为页面映射。有很多方法可以实现这种数据结构,指导当前实现的主要假设是每个消息执行只修改少量页面。这个假设在实践中成立:截至 2022 年 4 月,95% 的消息执行最多更改 7 个内存页。


如果预期的脏页数很少,自然会应用一些增量编码方案。因此,我们选择将页面映射表示为磁盘检查点文件和内存中页面增量的组合。检查点文件是一个平面二进制文件,其中包含系统在某些执行回合结束时创建的参与者内存的完整副本。


一旦创建,检查点文件是不可变的,页面增量是一个持久映射,其中包含自上次检查点以来被参与者弄脏的页面。


Actor 在几轮执行中的记忆快照,虚线页面表示从检查点继承的页面,虚线垂直线 - 消息执行边界,粗体形状 - 页面增量。请注意,系统会在快照之间共享脏页的内容。


运行时以下列方式使用页面映射:


  • 系统通过从检查点文件和运行的页面增量构建拼凑而成的“便签本”内存;


  • 系统在参与者状态上执行一条消息,并检测参与者在暂存器上弄脏的页面;


  • 如果消息执行成功,系统会将脏页的内容复制到正在运行的增量中;


  • 否则,系统会丢弃暂存器。


有时,系统会通过克隆原始检查点文件并将脏页刷新到新文件来创建新检查点,此过程加快了暂存器的构建并降低了内存压力。


检测内存写入


检测触及和脏页是正交持久性的另一个具有挑战性的方面,系统将 actor 的 WebAssembly 模块编译为本机代码,你怎么知道任意代码触及了哪些页面?这个问题有多种方法:


  • 比较执行前后的完整内存,这种方法很容易实现并且适用于所有平台,但它在相当大的内存上表现不佳,并且不会给我们访问页面;


  • 在将 WebAssembly 模块编译为本机代码之前对其进行检测,例如,我们可以在所有 loadstore 指令之前添加一个系统调用来将相应的页面标记为已访问或已脏,这种方法适用于所有平台,但会使执行速度减慢一个数量级;


  • 使用内存保护和信号处理程序在运行时检测内存读取和写入,这种方法在 UNIX 平台上运行良好并且相当有效,我们将在稍后更详细地讨论这种方法;


  • 将自定义文件系统后端实现为 FUSE 库,然后将内存映射 actor 内存作为虚拟文件,当参与者代码读取或写入内存映射文件时,操作系统将调用我们的库,从而允许我们执行所需的簿记,这种方法在支持 FUSE(Linux 和 macOS)的平台上效果很好,但它有一些管理上的缺点,例如,副本需要特权访问才能挂载虚拟文件系统;


  • 使用 Linux pmap 实用程序在消息执行后立即提取内存访问统计信息,这种方法非常有效,但并不总是产生确定性的结果。


信号处理器


内存保护 API 允许我们设置页面范围的读写权限,如果进程通过读取保护区域或写入保护区域违反了权限,则操作系统会向触发违规的线程发送一个信号(通常为 SIGSEGV)。


信号处理  API 允许我们拦截这些信号并检查导致它们的地址,我们可以通过结合这些 API 来构建一个健壮、高效的内存访问检测机制。


内存访问检测状态机的伪代码


让我们通过一个简单的例子来看看状态机的运行:一个 actor 存储一个整数数组,它需要在数组中找到一个特定的整数并将其替换为另一个。


触发内存访问机制的函数示例


为简单起见,我们假设数组从第 0 页开始,要替换的整数位于 2000 数组中的位置。在执行开始之前,整个内存都有保护标志 PROT_NONE,当参与者执行 replace 函数时,会发生以下事件:


  • Actor 访问 array[0],由于对应的内存页是读保护的,加载指令会产生一个 SIGSEGV 信号,系统调用将页面标记为已触摸的信号处理程序并将内存保护设置为 PROT_READ,上下文切换回参与者代码,现在可以成功地重复读取操作;


  • Actor array[1] 通过 array[1023] 不间断地访问数组元素(一个 4096 字节的内存页可以容纳 1024 个 32 位整数);


  • Actor 访问 array[1024],该元素位于下一页上,该页面仍处于读保护状态,系统的行为与步骤 1 相同;


  • Actor array[1025] 通过 array[2000] 不间断地访问元素;


  • Actor 写信给 array[2000],对应的内存页是写保护的,所以 store 指令产生一个 SIGSEGV 信号,上下文切换到信号处理程序,信号处理程序将页面标记为脏页并通过将保护标志设置为 PROT_READ | PROT_WRITE 来移除写保护,actor 代码恢复并重复写入,这次没有中断。


replace 函数执行期间的内存映射状态,矩形代表内存页,空心箭头代表读取,实心箭头代表写入。


信号处理方法在实践中非常有效,我们可以进一步优化它:


  • 我们可以在 actor 第一次接触它时懒惰地填充它,而不是预先构建完整的 actor 内存,这种优化最适合具有大量页面增量的碎片化内存;


  • 顺序内存访问是一种值得优化的普遍模式,我们可以一次删除多个后续页面的读取保护,无需将执行上下文切换到每个页面的信号处理程序,这种优化使机制不太精确,但可以显著提高执行速度;


  • 我们可以推测性地从多个后续页面中删除写保护以加速顺序写入,我们稍后可以将脏页的内容与快照进行比较,以验证我们的推测。


幸存升级


正交持久性会造成程序永远运行而不会中断的错觉,但是,如果我们想修复该程序中的错误或添加新功能怎么办?我们可以在不丢失程序状态的情况下进行升级吗?


乍一看,在不更改其内存内容的情况下交换参与者的 WebAssembly 模块似乎是安全的,不幸的是,这在实践中很少奏效。


  • Actor 代码需要特定的内存布局,更改数据类型定义会影响布局,新版本的代码将无法理解旧内存;


  • 内存可能包含指向函数的指针,更改代码可能会使这些指针无效;


  • 除了内存布局之外,代码可能对数据有其他期望,例如,新版本的代码可能对哈希表使用不同的哈希函数,代码交换后,哈希表查找可能会开始返回无效结果。


解决此问题的唯一已知方法是依靠稳定的数据表示,有一个提案允许 WebAssembly 程序访问多个不相交的内存。Andreas Rossberg 建议使用多内存特性来改进具有内存生命周期的持久性模型。例如,参与者可以使用默认内存(索引为零的内存)作为中期存储,这取决于编译器的内存表示。


Actor 可以使用其他内存作为具有稳定(理想情况下,独立于语言)数据布局的长期存储,系统会在升级过程中清除临时内存,但保留长期存储。(Andreas 还提出了具有单条消息执行生命周期的短期记忆,这样的记忆开辟了有吸引力的优化机会,但我们没有足够的压力来实施它们。)


由于 IC 需要在多内存提案实施之前支持升级,因此运行时通过稳定内存 API 模拟一个额外的内存。该 API 有意模仿 WebAssembly 内存指令,以促进未来向多内存模块的迁移,在内部,主存储器和稳定存储器共享表示。


目前,大多数参与者在升级前挂钩中将其整个状态序列化到稳定内存中,并在升级后挂钩中将其读回。人们一直在努力构建工具,以便更容易地将稳定内存用作主要数据存储。


结论


我们研究了正交持久性的经典方法及其与参与者模型的协同作用,我们了解了页面映射、IC 副本用于有效存储参与者内存的多个版本的数据结构,以及 SIGSEGV 基于内存访问检测系统。


最后,我们看到正交持久化并不是状态持久化的最终解决方案,以及为什么我们需要更好的工具来处理程序升级。



参考


IC 复制代码是开源的,如果您想阅读实现本文想法的代码,这里有一些提示:


  • 执行单个消息的函数

  • 页面地图模块

  • 简单和优化的信号处理程序


在 smartcontracts.org 上开始构建,并在 forum.dfinity.org 加入开发者社区。



作者:Roman Kashitsyn

翻译:Catherine



-              -


互联网计算机更新:InfinitySwap 介绍、DSCVR 用户和 Jelly NFT 市场

3D Moonwalker NFT

InfinitySwap 的 IS20 代币标准:去中心化和可互操作





你关心的 DFINITY 内容
技术进展 | 项目信息 | 全球活动


长按关注 DFINITY 微信公众号

随时答疑解惑


*添加小助手微信 comiocn 进交流社群


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存