跳转到主要内容

event ring 文件和内容类型

event ring 由四个共享内存段组成。其中两个 —— 事件描述符数组和负载缓冲区 —— 在概述文档中已描述。第三个共享内存段包含描述 event ring 元数据的头部。第四个(“context area”)是执行事件不需要的特殊功能。 共享内存段通过 mmap(2) 映射到进程的地址空间。这意味着 event ring 的数据结构存放在某个文件中,通过创建该文件的共享内存映射来获得对它的共享访问。 大多数时候,event ring 是位于名为 hugetlbfs 的特殊内存文件系统上的常规文件。hugetlbfs 类似于 tmpfs 内存文件系统,但支持创建由大页支持的文件。使用大页只是一种优化:event ring 文件可以创建在任何文件系统上。如果告知执行层守护进程在不支持 hugetlb mmap 的文件系统上创建 event ring 文件,它会记录一条性能警告但仍会创建文件。要了解 hugetlbfs 及其使用方式,请阅读此页面。

event ring 配置

要使用执行事件,必须使用如下命令行参数启动执行层守护进程:
若无此命令行参数,执行将不会发布任何事件。此命令行参数(以及挂载 hugetlbfs 文件系统)不属于执行层守护进程的默认配置说明。一份单独的指南涵盖了挂载 hugetlbfs 文件系统以及修改 systemd 单元配置文件中的命令行。 请注意,配置字符串是可选的;如果传递不带参数的 --exec-event-ring(这是推荐做法),则等同于传递 --exec-event-ring monad-exec-events,其中 monad-exec-events 是默认的执行 event ring 文件名。 event ring 配置字符串具有以下形式:
换句话说,配置字符串由三个用 : 分隔的字段组成;第一个字段是必需的,但后两个是可选的。以下是仅带第一个字段的命令行参数示例:
以下是包含所有三个字段的另一个示例:
第一个字段是 event ring 文件的名称。执行层守护进程以两种不同方式解释此字段:
  • 如果它纯粹是一个文件名 —— 即路径不包含任何 / 字符 —— 则将其解释为存在于默认 event ring 文件目录中的文件;这是由 API 函数 monad_event_open_ring_dir_fd 返回的目录;它使用 libhugetlbfs 定位最合适的 hugetlbfs 文件系统挂载点,并自动在其下创建一个名为 event-rings 的子目录(如果尚不存在)(有关更多信息,请参见此处);event ring 文件将在该 event-rings 子目录中创建
  • 如果路径有多个路径组件 —— 即包含至少一个 / 字符 —— 则将按原样使用此路径,_即使_它不驻留在 hugetlbfs 文件系统上;下面解释了有人可能希望这样做的理由
“shift”参数是决定 event ring 大小的 2 的幂指数。1 <descriptor-shift> 为 21 意味着 ring 的事件描述符数组中将有 2^21 个描述符。这意味着在描述符环形缓冲区回卷并覆盖旧的事件描述符之前,可以写入大约 200 万个事件。 <payload-buffer-shift> 为 29 意味着负载缓冲区数组中将有 2^29 字节。这意味着在负载缓冲区回卷并覆盖旧事件的负载之前,可以记录相当于 512 MiB 的事件负载。 如果未指定 event ring 大小参数,则使用默认值。您为什么要将这些值从默认值调高?如果您的读取程序崩溃但执行未崩溃,那么在您的程序未运行期间您很可能会错过一些事件。您的应用程序也许不关心旧区块的丢失事件,但如果关心,您需要以某种方式检索它们。 如果没有过去太长时间,很有可能您错过的事件仍然在 event ring 内存中,即还未被覆盖。默认大小足以容纳 10k TPS 下几分钟的区块。增大这些值使您能够回溯到更远的过去。 请注意,大页池的大小是固定的。可以通过修改系统的配置来更改池大小,参见此处对 /proc/sys/vm/nr_hugepages 的讨论。 如果 event ring 创建在 hugetlbfs 挂载上,且其大小超出可用大页数量,则执行层守护进程将退出并显示报告”no space left on device”错误的错误消息。例如,此处我们尝试分配一个具有 1TB 负载缓冲区的 event ring:
如果您恰好有几 TB 可用主内存,您可以传递一个包含 / 字符的文件路径,指向 tmpfs 挂载上的文件,这样也能工作,例如 --exec-event-ring /my-giant-tmpfs/monad-exec-events。 如果您需要回溯特别远 —— 或如果因执行本身崩溃而错过事件 —— 则需要使用其他地方描述的替代恢复方法。2

event ring 文件格式

event ring 文件格式很简单:所有四个部分按顺序排列并对齐到大页边界,头部描述每个部分的大小。
描述符数组只是 struct monad_event_descriptor 对象的数组,负载缓冲区是扁平字节数组(即类型为 uint8_t[])。头部结构定义如下:

事件内容类型

需要 content_type 头字段是因为 event ring 库(读取和写入 API)执行非结构化 I/O:函数读取和写入原始 uint8_t[] 事件负载,事件描述符包含普通的 uint16_t 数字事件代码。与 UNIX 的 read(2) 和 write(2) 文件 I/O 系统调用非常相似,event ring API 函数本身并不知道它们所处理数据的格式。这也是事件描述符中 event_type 字段是通用整数类型 uint16_t 而不是 enum monad_exec_event_type 的原因。 这里的假设是读取者和写入者都知道所处理数据的二进制格式,并在需要时通过类型强制转换将原始数据视为该格式,例如:
我们需要某种错误检测机制来确保这样做是安全的。event ring 文件头包含一个”content type”枚举常量,说明它包含哪种事件数据:
执行事件始终记录到 content_type 等于 MONAD_EVENT_CONTENT_TYPE_EXEC 的 ring 中。

二进制 schema 版本控制:schema_hash 字段

如果 content_type 等于 MONAD_EVENT_CONTENT_TYPE_EXEC,那么我们知道一个 ring 应该包含执行事件,但如果事件负载定义发生变化怎么办?或如果 enum monad_exec_event_type 中的枚举常量发生变化怎么办? 假设某用户使用某个特定版本的 exec_event_ctypes.h(定义执行事件负载和事件类型枚举的文件)编译了他们的应用程序。 现在想象一段时间后,该用户部署了新版本的执行节点,它使用不同版本的 exec_event_ctypes.h 编译,导致事件负载的内存表示形式不同。 如果读取者忘记用新头文件重新编译他们的应用程序,就可能误解事件负载中的字节,假设它们按其旧(编译时)版本的 exec_event_ctypes.h 中的旧布局排列。 为防止此类错误,所有事件负载的二进制布局通过一个哈希值汇总,只要对该内容类型的任何事件负载进行了更改,该哈希值就会变化。除负载更改外,对 enum monad_exec_event_type 的任何更改也会生成新哈希。 此机制称为”schema hash”,哈希值以全局只读字节数组形式存在于库代码内部(定义于 exec_event_ctypes_metadata.c)。 如果此数组中的哈希值与 event ring 文件头中的哈希值不匹配,则二进制格式不兼容。 一个名为 monad_event_ring_check_content_type 的辅助函数用于检查 event ring 文件是否具有预期的内容类型,以及该内容类型的预期 schema 哈希。以下是 eventwatch.c 示例程序中调用它的示例:
如果 event ring 类型不是 MONAD_EVENT_CONTENT_TYPE_EXEC,或者文件头中的 schema_hash 与全局数组 uint8_t g_monad_exec_event_schema_hash[32] 中的值不匹配,则此函数将返回 errno(3) 域代码 EPROTO。

事件描述符详解

二进制格式

事件描述符定义如下:

流标签:执行 event ring 中的 content_ext 字段

对于每种内容类型,我们可能希望直接在事件描述符中发布额外数据,例如,如果该数据对每种负载类型都是通用的,或者它可以帮助读取者快速过滤掉他们不感兴趣的事件而无需检查事件负载。这些额外数据存储在 content_ext(“content extensions”)数组中,其含义由 content_type 定义。 对于执行 event ring,有时会填充 content_ext 数组的前三个值。数组中每个索引处的值具有下列枚举类型描述的语义含义,该枚举类型定义于 exec_event_ctypes.h:
例如,如果我们有一个事件描述符
其内容通过对 monad_event_iterator_try_next 的调用初始化,那么 event.content_ext[MONAD_FLOW_TXN_ID] 将包含该事件的”交易 ID”。交易 ID 等于交易索引加一,如果事件没有关联交易(例如新区块的开始),则为零。 “flow”标签背后的思想是用其所属的上下文来标记事件。例如,当交易访问特定的账户存储键时,会发出 STORAGE_ACCESS 事件。 通过查看 STORAGE_ACCESS 事件描述符的 content_ext 数组,读取者可以判断它是(1)由索引为 event.content_ext[MONAD_FLOW_TXN_ID] - 1 的交易进行的存储访问,以及(2)访问的账户索引为 event.content_ext[MONAD_FLOW_ACCOUNT_INDEX](该索引与之前已经看到的 ACCOUNT_ACCESS 事件系列相关)。 使用流标签有两个原因:
  1. 快速过滤 - 如果我们每秒处理 10,000 笔交易,每笔交易至少有十几个事件,那么我们只有大约 10 微秒来处理每个事件,否则最终会落后并产生 gap。在这种时间尺度上,即便是接触包含事件负载的内存也是相对昂贵的。事件负载位于不同的缓存行 —— 一个尚未在读取者 CPU 中变热的行 —— 并且必须首先在缓存一致性协议中更改缓存行的所有权(因为它最近由写入者独占,现在必须与读取 CPU 共享,导致跨核总线流量)。对于大多数应用,用户可以在 TXN_HEADER_START 事件时识别他们感兴趣的交易 ID,然后可以忽略任何没有感兴趣 ID 的事件。由于 ID 是稠密的整数集合,可以使用简单的 bool[TXN_COUNT + 1] 类型数组来高效查找与该交易关联的后续事件是否感兴趣(可以通过每笔交易一位而非一个完整 bool 使其更高效)
  2. 压缩 - STORAGE_ACCESS 的账户通过索引引用(该索引指向早前的 ACCOUNT_ACCESS 事件),因为账户地址是 20 字节:太大以至于无法容纳在剩余的两个 content_ext 数组槽位中
压缩技术还用于在 event.content_ext[MONAD_FLOW_BLOCK_SEQNO] 中存储与事件相关的区块。此情况下的 flow 标签是启动关联区块的 BLOCK_START 事件的_序列号_。关于此流标签的几点说明:
  • 有时它为零(无效序列号),这意味着该事件不与任何区块相关联;虽然大多数事件的作用域限定在某个区块内,但共识状态更改事件(BLOCK_QC、BLOCK_FINALIZED 和 BLOCK_VERIFIED)不在区块内发生
  • 请注意,区块流标签_不是_区块编号。这是因为在看到事件时,区块处于 “proposed” 状态,共识算法尚未完成对该区块是否会被纳入规范区块链的投票(这将在下一节中详细讨论)。在区块变为 finalized 之前,唯一明确引用它的方式是通过其唯一 ID,即 32 字节哈希值(可从 BLOCK_START 负载中读取);因此区块流标签也是一种压缩形式
  • 拥有序列号使我们能够将迭代器倒回到区块的开始,如果我们在区块中间开始观察事件序列(例如,如果读取者在执行层守护进程之后启动)。可以在 eventwatch.c 示例程序中的 find_initial_iteration_point 函数中找到此示例(及其详细说明)

Footnotes

  1. 之所以称为 “shifts”,是因为 1UL << x 等于 2^x ↩
  2. 替代恢复方法仍在开发中,将在下一版 SDK 中提供 ↩