Skip to main content

事件何时被发布?

执行事件是在执行层守护进程内部大致”随着事件发生”实时记录的:当执行层守护进程开始处理新区块时,您会大致在同一时刻看到一个 BLOCK_START 事件,随后大约 1 毫秒后开始出现第一笔交易(TXN_HEADER_START 事件)。大多数与交易相关的事件在其描述的交易完成之后不到一微秒即被记录。 一笔典型交易的执行会产生几十个事件,但大型交易可能产生数百个事件。TXN_EVM_OUTPUT 事件——在交易结束时立即记录——提供了后续将有多少与该交易相关事件的汇总信息(多少条日志、多少个 call frame 等),以便可以预分配用于存储后续事件数据的内存。例如在 Rust 中,此处常常调用 Vec::reserve。像 TXN_EVM_OUTPUT 这样的事件在文档中被称为”header event”(头事件):其内容描述了一些汇总信息,以及后续将会记录、包含更多细节的相关事件的数量。 所有这些事件都是在交易被”提交”到当前正在执行的区块时立即记录的。这发生在区块执行完成之前,不应与共识算法中无关的”提交”概念混淆。虽然执行层守护进程内部有复杂的推测执行优化,但交易的记录是在特定交易的所有工作完成后发生的。这被称为”交易提交”时间。 这与您在例如 Geth 实时事件 WebSocket 协议(我们的 RPC 服务器也支持)中看到的按区块推送的更新方式不同。在您看到某笔交易的事件时,区块的某些属性(其哈希、其状态根等)尚不可知,因为区块的其余部分还在执行中。如果您希望按区块获取更新,Rust SDK 包含一些工具,可以将事件重新聚合成完整的、以区块为单位的更新。 需要注意一点:虽然交易总是按索引顺序提交到区块中,但它们可能会以乱序方式被记录。也就是说,您必须假设组成交易 2 和交易 3 的执行事件集合可能会以任意顺序”混合在一起”。这是由于事件记录代码路径中的优化所致。 然而,对于某一特定交易(例如交易 3),与该交易相关的事件总是按相同顺序被记录:先是所有日志,然后是所有 call frame,最后是所有状态访问记录。每一类都是按_索引顺序_记录的,即日志 2 总是先于日志 3 被记录。 请参考下图:
需要注意几点:
  • 与文档中的大多数示意图不同,这里的事件以简化的”合并”形式显示;在真实事件中,其中一些信息存储在事件描述符(event descriptor)中,另一些存储在事件负载(event payload)中,但为使图示更简单而合并显示
  • 图中显示了两笔交易,交易索引分别为 0 和 1。虽然交易 0 在 EVM 中先完成,但其 TXN_EVM_OUTPUT 事件却在交易 1 的 TXN_EVM_OUTPUT _之后_被记录
  • 两笔交易的事件是交错的:有时下一条与交易 0 相关,有时与交易 1 相关,两者之间没有有意义的顺序
  • 尽管两笔交易之间是乱序的,但同一特定交易的所有相关事件之间始终保持相对顺序,即某笔交易的 log_index 总是按顺序被看到,如上所示
如果您想象每笔交易的所有事件都由不同线程记录,就很容易理解这一点。对于某一特定交易,其线程始终按顺序记录该交易的事件,但”交易线程”之间会彼此竞争,以非确定性的顺序进行记录。 这与实际情况类似,只不过交易是在纤程(fiber)而非完整线程上记录的。

序列号与生命周期检测算法

所有事件描述符都被打上从 1 开始递增的序列号。序列号是 64 位无符号整数,除非执行层守护进程重启,否则不会重复。0 不是有效的序列号。 另请注意,序列号对描述符数组大小取模等于_下一个_事件描述符将存放的数组索引。下面通过一个具体示例说明,其中描述符数组大小为 64。请注意数组中最后一个有效索引是 63,然后访问会回卷到数组开头的索引 0。
在此示例中:
  • 我们记录”上次看到的序列号”(last_seqno),初始值为 318;作为”上次”序列号意味着我们已经读完了该序列号的事件,该事件位于数组索引 61
  • 318 % 64 等于 62,因此我们将在该索引处查找潜在的下一个事件——_如果_它已经被产生
  • 观察到索引 62 处的项目序列号为 319,即上次看到的序列号加 1(319 == 318 + 1)。这意味着事件 319 已被产生,可以安全地从该槽位读取其数据
  • 当我们准备推进到下一个事件时,上次看到的序列号将递增到 319。和之前一样,我们可以在 319 % 64 == 63 处找到_下一个_事件(如果它已被产生)。该索引处的事件带有序列号 320,同样等于上次看到的序列号 + 1,因此该事件也是有效的
  • 第二次推进时,上次看到的序列号递增到 320。这一次,索引 320 % 64 == 0 处的事件_不是_ 321,而是一个更小的数字 256。这意味着下一个事件尚未写入,我们看到的是同一槽位中的旧事件。我们已经看完了当前可用的所有事件,需要稍后再次检查,等新事件被写入
  • 另一种情况是,我们可能会看到一个更大得多的序列号,比如 384320 + 64)。这意味着我们消费事件太慢了,慢到在此期间已经产生了 [321, 384) 范围内的 63 个事件。这些事件后来被覆盖,现在已经丢失。可以借助 event ring API 之外的其他服务来重放它们,但_在_ event ring API 本身之内没有办法恢复它们

事件负载的生命周期:零拷贝与 memcpy API

由于描述符会被覆盖,当读取者仍在检查数据时,事件描述符可能被执行层守护进程覆盖。为处理这一情况,读取者 API 会对事件描述符进行拷贝。如果在拷贝操作期间检测到事件描述符发生变化,则报告一个 gap。拷贝事件描述符是快速的,因为它只有一个缓存行大小。 对于事件负载则不然,负载可能非常大。这意味着对事件负载执行 memcpy(3) 可能代价高昂,因此直接从负载缓冲区的共享内存段读取负载字节将更有利:即”零拷贝”API。这使用户面临一种可能:事件负载在使用过程中被覆盖。为此提供了两种解决方案:
  1. 一种简单的检测机制允许在任何时候检测负载覆盖:写入者跟踪仍然有效的最小负载偏移值(在应用取模运算之前)。如果事件描述符中的偏移值小于该值,则读取事件负载不再安全
  2. 还提供了一个 memcpy 风格的负载 API。它以如下方式使用上述检测机制:首先将负载拷贝到用户提供的缓冲区。返回之前,检查拷贝完成后生命周期是否仍有效。如果是,则拷贝期间未发生覆盖,因此拷贝必然有效。否则,拷贝无效
倾向使用零拷贝 API 的原因是它们做的工作更少。倾向使用 memcpy API 的原因是,如果后来发现事件负载在您处理时被覆盖破坏,通常不容易(或不可能)“撤销”您所做的工作。在这种情况下最合理的做法就是先将数据拷贝到稳定位置,如果拷贝无效,则根本不开始该操作。 零拷贝 API 的一个示例用户是 eventwatch 示例 C 程序,它可以将事件转换为打印字符串并发送到 stdout。格式化事件负载的十六进制转储这一昂贵工作使用原始负载内存执行。如果在字符串格式化过程中发生了覆盖,则十六进制转储输出缓冲区将是错误的,但这没关系:它直到最后才会被发送到 stdout。格式化完成后,eventwatch 会检查负载是否已过期,如果是,则向 stderr 写入错误而不是将格式化缓冲区写入 stdout 您是否应该拷贝取决于读取者的特性,即它处理”中止”的难易程度。

event ring 文件的位置

出于性能原因,我们更倾向于在 hugetlbfs 内存文件系统上创建 event ring 文件。在此类文件系统上创建的文件将由物理上连续的大页支持,在内部基准测试中性能提高约 15%。 但这可能带来一些麻烦:要求程序将文件放置在_特定类型_的文件系统上是不寻常的,这一要求增加了一些开销。实际上,这意味着系统管理员在设置 Monad 节点时必须执行额外的配置步骤,而 SDK 用户必须了解一些额外的概念。 问题包括:
  1. 必须在主机上某处挂载 hugetlbfs 文件系统;通常默认情况下(例如在 Ubuntu 默认安装中)不会预先存在 hugetlbfs 文件系统
  2. 配置 hugetlbfs 文件系统的人必须确保任何需要打开 event ring 文件的用户拥有相应权限
  3. 需要将 event ring 文件的路径(该路径将位于该文件系统中的某处)传递给所有需要打开它的程序;由于我们不知道管理员将文件系统挂载到何处,因此无法在文档或源代码中轻易硬编码其位置
为尽可能简化开发者体验,我们遵循三条约定。每条约定都增加了更多”便利默认行为”,以便对大多数用户来说一切都可以”开箱即用”,但您也可以随意忽略任何约定并按自己的方式行事。
event ring 库不要求 hugetlbfs 文件系统:它可以处理_任何_种类的常规文件。映射 event ring 共享内存段的 C 函数 —— monad_event_ring_mmap —— 只接受一个文件描述符,不知道也不关心该描述符来自何处。它的唯一约束是由 mmap(2) 系统调用本身施加的。这些约定是关于为如何设置挂载点添加合理默认值,以及提供在该位置查找 event ring 文件的辅助函数。您应该尝试使用它们,因为它们带来性能优势,但您也可以自由地以任何方式获取文件描述符,只要能与 monad_event_ring_mmap 一起使用即可。

约定 1:节点搭建指南中的 libhugetlbfs

为执行事件设置本地 Monad 节点的官方指南建议使用 libhugetlbfs libhugetlbfs 既是一个 C 库,也是使用该库、遵循特定配置约定的一组管理工具。其思想是标准化 hugetlbfs 文件系统挂载点和权限管理的一些规则。基本思想有三部分:
  1. 每个用户(或组,如果您愿意这样做)都有自己独立挂载的 hugetlbfs 文件系统。挂载点位于 /var/lib/hugetlbfs/user/<user-name> 下一个明确定义的位置1
  2. hugeadm 是系统管理员运行的程序,是列出 hugetlbfs 挂载、创建新挂载等任务的配置前端
  3. C 库 libhugetlbfs 帮助客户端程序”查找”当前用户有权访问的 hugetlbfs 挂载
Monad 节点的搭建指南告诉用户安装 libhugetlbfs 命令行工具,并为 monad 用户设置”用户挂载”。指南还建议所有用户都可以进入该目录,以便以非 monad 用户身份运行的数据消费者应用程序能够打开该文件。

约定 2:“默认” event ring 目录

event ring 库引入了”默认 event ring 目录”的概念。这是应创建 event ring 文件的默认目录,读取者应用程序应该在此查找它们。默认值可来自两处之一:
  1. 您可以手动提供 或
  2. 如果您不提供,库将使用一个约定位置
约定位置是一个名为 event-rings 的子目录,直接创建在 libhugetlbfs 返回的 hugetlbfs 挂载点下2,即:
如果您严格按照搭建指南操作,则应该是:
但根据您的系统设置,libhugetlbfs 可能返回不同的路径。例如,您可能看到类似:
这是因为 libhugetlbfs 会扫描 /proc/mounts 的内容,并且仅返回当前用户有访问权限的一条路径。如果用户可以访问多个 hugetlbfs 挂载怎么办?没有优先偏好某种路径的逻辑,只取决于它们在 /proc/mounts 文件中的相对顺序。

手动提供默认 event ring 目录

您可能希望在绕过 libhugetlbfs 的情况下使用这种”从默认目录打开”的配置习惯。两种理由:
  1. 如果您不希望 event ring 文件位于 hugetlbfs 文件系统上;通常这种情况发生在您想创建一个比 hugetlbfs 挂载点(或系统底层的大页池)所允许的更大的 event ring 文件时
  2. 如果您不想将 libhugetlbfs 作为项目的库依赖,此时您需要将 CMake 选项 MONAD_EVENT_USE_LIBHUGETLBFS 设置为 OFF

约定 3:event ring 文件名解析

“默认目录”概念在最后一个约定中被使用,它是一个”便利”API 调用,用于将用户输入的 event ring 文件名转换为程序尝试打开该文件的路径。 它允许用户指定诸如 xyz 之类的文件名,并将其翻译为完整(丑陋)的路径:
同时仍允许用户在需要时指定_任何_文件,包括不在默认目录中的文件。 以下是 C 函数 monad_event_ring_resolve_file 和 Rust 函数 EventRingPath::resolve 解析 event ring 文件输入的方式:
  • 如果提供的是”纯”文件名(即不包含 / 字符的文件名),则相对于所提供的 default_path 目录进行解析
  • 否则(即如果文件包含任何 / 字符),则相对于当前工作目录解析;如果 / 是第一个字符,则作为绝对路径解析
这类似于 UNIX shell 解析命令名的方式。不包含路径字符的”纯”名称是相对于 $PATH 环境变量中的条目解析的(即它会搜索默认命令目录)。存在路径分隔符字符会使输入被视为相对于当前目录的具体路径,从而禁用此”搜索”。这里也适用同样的熟悉原则。 此外:
  • 在 C 中,通常将哨兵值 MONAD_EVENT_DEFAULT_HUGETLBFS(它只是 nullptr 的别名)作为 default_path 参数传入;这会让 libhugetlbfs 决定默认 hugetlbfs 根路径是什么3;在 Rust 中这就是 EventRingPath::resolve
  • 您也可以提供自己的 default_path 值,它可以是任何文件系统的任何路径;如果您不希望依赖 libhugetlbfs,则必须这么做;在 Rust 中这是 EventRingPath::resolve_with_default_path
解析不会尝试打开文件:它只是标准化如何从两个输入构建路径字符串的约定。也就是说,它不会检查计算得出的文件路径是否存在。请记住,event ring 库本身只关心文件描述符,其任何 API(甚至”辅助”API)都不会尝试open(2)文件。它们只提供”合理默认”的方式来定位文件,供程序选择使用。如果您的主机需要以不同方式设置文件系统挂载,您可以自由这样做。

示例

下表展示了 C 函数 monad_event_ring_resolve_file 的行为。<cwd> 是进程的当前工作目录,<htlbfs>libhugetlbfs 返回的挂载点。 在 Rust 中,EventRingPath::resolve 的行为类似于 MONAD_EVENT_DEFAULT_HUGETLBFS 那几行,而 EventRingPath::resolve_with_default_path 接受一个显式的 basepath 参数,行为类似于最后三行。

Footnotes

  1. 也可以使用其他配置方案,参见 man hugeadm
  2. 具体路径可能因用户而异,由函数 hugetlbfs_find_path_for_size 决定
  3. 实际使用的函数是 event ring 库的实用函数 monad_event_open_hugetlbfs_dir_fd,它会追加 event-rings 子目录路径组件,如果不存在则创建它