RFC-0196:FIDL 大型邮件

RFC-0196:FIDL 大型消息
状态已接受
区域
  • FIDL
说明

支持通过 FIDL 协议发送大型消息。

问题
Gerrit 更改
作者
审核人
提交日期(年-月-日)2022-06-28
审核日期(年-月-日)2022-10-27

摘要

目前,FIDL 语言将通过基于 Zircon 的传输(例如通道和套接字)传输的消息大小限制为 64 KiB。本文档提出了一种高级设计,用于处理任意大小的消息,即使它们超出了底层传输的最大消息字节数限制也是如此。为此,我们将类似于常用现有模式(使用 fuchsia.mem.Data)的解决方案提升为第一类自动推断的 FIDL 语言支持。

设计初衷

目前,通过线缆发送的所有 FIDL 消息都限制为 ZX_CHANNEL_MAX_MSG_BYTES 大小,目前相当于 64KiB,并且派生自可通过 Zircon 通道发送的消息的大小上限。超出此限制的消息将无法编码。

目前,解决此问题的常见方法是将任意大小的数据作为 blob 通过 zx.handle:VMOfuchsia.mem.Data 发送,其中底层 VMO 本身包含要发送的数据 blob。通常,这些 Blob 包含最终用户希望以 FIDL 表示并编码/解码的结构化数据,但无法实现,因此不得不自行手动进行类型转换。目前,fuchsia.git 中有很多用途都使用了这些封装容器类型。

由于缺少对大型消息的支持,因此会出现一些问题。其中最主要的问题是,由于协议很少需要发送大型消息,但在技术上能够做到,因此会导致大量 bug。例如,非常大的网址或 Wi-Fi 扫描期间生成的非常大的网络列表。除了其他极端情况(例如极少数情况下才会填充所有字段的 table 布局)之外,所有需要采用 :MAX 大小的 vectorstring 的 API 都容易受到此问题的影响。一般来说,任何需要以消息的形式接受用户数据(且无法证明消息小于 64KiB)的操作都可能会受到此失败模式的影响。

通过 VMO 发送未指定类型的数据块不符合人体工学要求,因为它会丢失所有类型信息,而这些信息必须在接收端手动重建。用户不得不自行编码消息,将其打包到另一个 FIDL 消息中,然后在另一端以相反的顺序重复此过程,而不是利用 FIDL 来描述数据的形状并提取编码->发送->解码->调度流水线。例如,ProviderInfo API 具有子类型 InspectConfigInspectSource,目前分别由 fuchsia.mem.Bufferzx.handle:VMO 表示,但表示可由 FIDL 描述和处理的结构化数据。

使用 zx.handle:VMOfuchsia.mem.Data 会导致仅限数据的 FIDL 类型被迫携带 resource 修饰符。这对绑定 API 有下游影响,导致 Rust 等语言中生成的类型无法派生 Clone trait,即使它们应该这样做也是如此。

由于对大型邮件的支持不足而导致的 bug 和人体工学问题普遍存在。在起草本 RFC 期间进行的一项调查显示,过去和现在至少有 30 个用例,如果有更强大的大型消息支持,FIDL 用户将会受益匪浅。

利益相关方

主持人:hjfreyer@google.com

审核者:abarth@google.com、chcl@google.com、cpu@google.com、mseaborn@google.com、nickcano@google.com、surajmalhotra@google.com

咨询者:bprosnitz@google.com、geb@google.com、hjfreyer@google.com、jmatt@google.com、tomberga@google.com、yifeit@google.com

共享:五个团队(组件解析器、DNS 解析器、驱动程序开发、WLAN SME 和 WLAN 政策)已审核使用此设计的原型:

  • 组件解析器:geb@google.com
  • DNS 解析器:dhobsd@google.com
  • 驱动程序开发:dgilhooley@google.com
  • WLAN 政策:nmccracken@google.com
  • WLAN SME:chcl@google.com

此外,我们还对 30 多名现有 fuchsia.mem.Datafuchsia.mem.Buffer 类型的用户进行了访谈,以获取设计反馈和使用情形适宜性信息。

设计

本文档中的关键字“必须”“不得”“必需”“会”“不会”“应”“不应”“建议”“可以”和“可选”应按 IETF RFC 2119 中的说明进行解释。

消息溢出是传输级问题。什么是大型消息,以及如何最好地处理符合该定义的消息,在 zircon 通道、驱动程序框架、overnet 等之间差异很大。这意味着,将特定方法请求或响应称为“大型”并不是一个抽象的声明:对于该方法的所有者协议,必须始终有一个明确定义的“大型”定义。

以下代码段指定了消息如何声明“与承载我的传输工具的预期相比,我体积较大,因此需要特殊处理”。无论是在指定协议的 *.fidl 文件中接口定义时,还是在运行时实际溢出的任何给定消息实例中,此声明都必须清晰可辨。

具体而言:在此设计之前,发件人可以发送超出底层传输的最大消息字节数上限的其他完全有效的消息,从而导致意外且难以调试的 PEER_CLOSED 运行时失败。进行这些更改后,fidlc 编译器将进行静态检查,以确定载荷类型是否可能大于传输的最大消息字节数限制;如果是,则会生成特殊的“溢出”处理代码来对此进行说明。此模式可为大型消息启用辅助运行时消息传送机制,其中使用无边界边信道(对于 Zircon 通道,即 VMO)来存储消息内容。此新消息传送路径完全添加在生成的绑定代码的“核心”中,从而保持 API 和 ABI 兼容性。FIDL 方法实现者现在可以确信,由于达到了任意字节大小限制,因此没有可分配的消息会触发 PEER_CLOSED

线格格式更改

系统会向 FIDL 事务消息标头动态标志部分添加一个新位,称为 byte_overflow 标志。当此标志被翻转时,表示当前持有的消息仅包含消息的控制平面,并且消息的其余部分存储在单独的可能非连续缓冲区中。

此单独缓冲区的位置以及对该缓冲区的访问方式取决于传输方式。如果 byte_overflow 标志处于活动状态,传输中控制平面消息必须包含 16 字节的事务消息标头,后跟一个额外的 16 字节附录消息,用于描述大型消息的大小。这意味着,此消息必须恰好为 32 个字节:默认的 FIDL 消息标头,后跟一个所谓的 message_info 结构体,其中包含三项数据:用于标志的 uint32、用于在排除溢出缓冲区的情况下可能指定附加到消息的句柄数量的 uint32,以及用于指示 VMO 本身中数据大小的 uint64

type MessageInfo = struct {
  // Flags pertaining to large message transport and decoding, to be used for
  // the future evolution and migration of this feature.
  // As of this RFC, this field must be zeroed out.
  flags uint32;
  // A reserved field, to be potentially used for storing the handle count in
  // the future.
  // As of this RFC, this field must be zeroed out.
  reserved uint32;
  // The size of the encoded FIDL message in the VMO.
  // Must be a multiple of FIDL alignment.
  msg_byte_count uint64;
};

由于需要生成一个额外的句柄来指向溢出缓冲区,因此大型 FIDL 消息可能只附加 63 个句柄,而不是通常的 64 个。这种行为对用户来说既不优雅又令人意外,并且只会通过运行时错误进行报告。为了解决这个棘手的问题,我们会致力于开发内核改进,以便在未来解决这个问题。

byte_overflow 标志必须占用动态标志位数组中的第 6 位(即倒数第二个最高有效位)。第 5 位预留用于未来可能的 handle_overflow 位,但此位目前未使用。此位不得用于其他用途。

运行时要求

有许多条件,如果在解码期间违反这些条件,必须导致 FIDL 传输错误,并立即关闭通信通道。如果设置了 byte_overflow 标志,则控制平面消息的大小必须恰好为 32 字节,如上所述,消息正文必须通过其他介质传输。

对于 Zircon 通道传输,字节溢出缓冲区的介质必须是 VMO。这意味着,控制平面消息的随附句柄数量必须至少为 1。该最后一个句柄指向的内核对象必须是 VMO,并且接收器从该 VMO 读取的字节数必须等于 message_info 结构体 msg_byte_count 字段的值。如果消息已知是受限的,则此值必须小于或等于相关载荷的静态推断最大大小。

消息发送方必须通过 zx_vmo_create 系统调用铸造新的 VMO,紧随其后是 zx_vmo_write,用于用消息正文填充 VMO。它们必须确保表示溢出 VMO 的句柄没有正确的 ZX_RIGHT_WRITE

在接收端,消息的收件人必须使用 zx_vmo_read 读出消息中包含的数据。因此,虽然通过 Zircon 通道发送的常规 FIDL 消息只需要两个系统调用(发送方为 zx_channel_write_etc,接收方为 zx_channel_read_etc),但字节溢出消息需要更多系统调用(发送方为 zx_channel_write_etczx_vmo_createzx_vmo_write,接收方为 zx_channel_read_etczx_vmo_readzx_handle_close)。这是一个高昂的代价,不过未来的优化(例如改进 zx_channel_write_etc API)可能会让您收回部分费用。消息接收器不得尝试写入收到的溢出 VMO。

代码生成变更

对于任何可能超出其协议传输上限的最大字节数的载荷消息,FIDL 绑定实现必须生成溢出处理程序。为此,FIDL 消息可以大致分为以下三类:

  • 有界消息的累计字节数上限始终已知。 此类别包含大多数 FIDL 消息。对于此类消息,绑定生成器必须使用计算的消息最大字节数来确定是否包含在编码时设置 byte_overflow 标志的功能,以及是否在解码时检查该标志。具体而言,如果累计字节数上限大于协议传输限制(对于 zircon 通道,为 64KiB),则必须在生成的代码中包含在编码时设置 byte_overflow 标志和强制性解码时标志检查的功能;否则,则不得包含。
  • 半边界仅在编码时才知道累计字节数上限的消息。此类别包括任何原本应受限但间接包含 flexible uniontable 定义的消息。对于此类消息,绑定生成器必须使用计算的消息最大字节数来确定是否在编码时包含设置 byte_overflow 标志的功能,但在解码时必须始终检查此标志。
  • 无上限消息的累计字节数上限从定义上讲始终不可知。此类别包括所有传递包含递归定义或无界 vector 的消息。对于此类消息,生成的绑定代码必须始终包含在编码时设置 byte_overflow 标志的功能,并且必须始终在解码时检查该标志。
@transport("Channel")
protocol Foo {
  // This request has a well-known maximum size at both encode and decode time
  // that is not larger than 64KiB limit for its containing transport. The
  // generated code MUST NOT have the ability to set the `byte_overflow` on
  // encode, and MUST NOT check it on decode.
  BoundedStandard() -> (struct {
    v vector<string:256>:16; // Max msg size = 16+(256*16) = 4112 bytes
  });
  BoundedStandardWithError() -> (struct {
    v vector<string:256>:16; // Max msg size = 16+16+(256*16) = 4128 bytes
  }) error uint32;

  // This request has a well-known maximum size at both encode and decode time
  // that is greater than the 64KiB limit for its containing transport. The
  // generated code MUST have the ability to set the `byte_overflow` on encode,
  // and MUST check it on decode.
  BoundedLarge() -> (struct {
    v vector<string:256>:256; // Max msg size = 16+(256*256) = 65552 bytes
  });
  BoundedLargeWithError() -> (struct {
    v vector<string:256>:256; // Max msg size = 16+16+(256*256) = 65568 bytes
  }) error uint32;

  // This response's maximum size is only statically knowable at encode time -
  // during decode, it may contain arbitrarily large unknown data. Because it
  // is not larger than 64KiB at encode time, the generated code MUST NOT have
  // the ability to set the `byte_overflow` on encode, but MUST check for it on
  // decode.
  SemiBoundedStandard(struct {}) -> (table {
    v vector<string:256>:16; // Max encode size = 32+(256*16) = 4128 bytes
  });
  SemiBoundedStandardWithError() -> (table {
    v vector<string:256>:16; // Max encode size = 16+32+(256*16) = 4144 bytes
  }) error uint32;

  // This response's maximum size is only statically knowable at encode time -
  // during decode, it may contain arbitrarily large unknown data. Because it
  // is larger than 64KiB at encode time, the generated code MUST have the
  // ability to set the `byte_overflow` on encode, and MUST check for it on
  // decode.
  SemiBoundedLarge(struct {}) -> (table {
    v vector<string:256>:256; // Max encode size = 32+(256*256) = 65568 bytes
  });
  SemiBoundedLargeWithError(struct {}) -> (table {
    v vector<string:256>:256; // Max encode size = 16+32+(256*256) = 65584 bytes
  }) error uint32;

  // This event's maximum size is unbounded. Therefore, the generated code MUST
  // have the ability to set the `byte_overflow` on encode, and MUST check for
  // it on decode.
  -> Unbounded (struct {
    v vector<string:256>;
  });
};

ABI 和 API 兼容性

此设计全面推出后,将完全与 ABI 和 API 兼容。它始终是 ABI 安全的,因为任何将之前有界载荷转换为无界或半有界载荷的更改(例如将 struct 更改为 table 或更改 vector 大小边界)都已是破坏 ABI 合规性的更改。

对于无界或半有界载荷,无论大小如何,系统都会在消息解码期间始终检查 byte_overflow 标志。这意味着,在连接的一端编码的任何消息都可以在另一端解码,即使在演变过程中添加了未知数据,导致消息在解码器的载荷类型视图中意外变大也是如此。

在发布期间的中间阶段,连接的一端可能具有一个知道大型消息的 FIDL 绑定,而另一端则不知道,因此大型消息将无法解码。这与目前的情况类似,此类消息在编码期间会失败,但失败情况现在会稍微远离来源。

由于大多数会发送大型消息的 API 都已实施协议级缓解措施(例如分块),因此中间部署期间解码失败的风险被认为较低。主要风险在于,协议是否会开始通过现有方法发送现在允许的大型消息。此类协议改为引入支持大消息的新方法。

设计原则

此设计遵循了几项关键原则。

用多少,付多少

FIDL 语言的一个主要设计原则是只需为实际用量付费。本文档中介绍的大型消息功能旨在实现这一理想。

即使存在此 RFC,使用边界载荷的方法的性能也不会降低。使用半有界或无界载荷但不会发送大于协议传输字节数限制的消息的方法,只需支付接收端单个位标志检查的费用。只有实际使用大型消息溢出缓冲区的邮件会受到性能影响。

不需要大型消息支持(即可能以 FIDL 表达的大多数方法/协议)的用户无需支付任何费用,无论是在运行时性能开销方面,还是在编写 FIDL API 时产生的脑力开销方面。

不迁移

现在,对于任何可能合理使用大型消息的载荷,都可以在任何位置启用大型消息,而无需迁移现有 FIDL API 或其客户端/服务器实现。之前会导致 PEER_CLOSED 运行时错误的情况现在“可以正常运行”。

量身定制的交通

此设计可灵活满足现有和推测性传输的不同需求。例如,只要 byte_overflow 位被翻转,并且传输层知道如何对包含溢出的数据包进行排序,就可以使用网络上的多数据包消息等惯例。

实现

此功能将通过实验性 fidlc 标志推出。然后,每个绑定后端都将被修改为根据此 RFC 的规定,针对明确指定实验性标志的输入来处理大型消息。一旦该功能被视为稳定,我们就会移除该标志,以便您可以正式使用。

此属性不应需要额外的 fidlc 支持,因为它只会将执行溢出检查所需的信息传递给将学习如何支持大型消息的后端选择。

在此 RFC 之前,绑定会将编码/解码缓冲区普遍放置在堆栈上。今后,建议绑定针对未翻转 byte_overflow 标志的消息继续执行此行为。对于会发生这种情况的消息,绑定改为在堆上分配。

性能

在稍微自定义的场景中,可以使用内核微基准测试来估算所提交付方法的性能影响,方法是将以下两种情况进行求和并进行比较:发送大小为 B 的单个通道消息,与发送 16 字节通道消息和发送大小为 B - 16 的 VMO 的总和。B 的值为:16KiB、32KiB、64KiB、128KiB、256KiB、512KiB、1024KiB、2048KiB 和 4096KiB。

列表 1:显示在将 B 字节的数据作为 16 字节的通道消息和大小为 B - 16 的 VMO(而不是大小为 B 的通道消息)发送时,需要支付的估算1 传送时间性能“税费”的表格。

邮件大小 / 策略 仅限频道 渠道 + VMO VMO 使用税
16KiB 2.5 微秒 5.9 微秒 136%
32KiB 4.5 微秒 7.7 微秒 71%
64KiB 7.9 微秒 13 微秒 65%
128KiB 16.5 微秒 23.3 微秒 41%
256KiB 35.8 微秒 54.4 微秒 52%
512KiB 71.3 微秒 107.4 微秒 51%
1024KiB 157.0μs 223.4 微秒 42%
2048KiB 536.2 微秒 631.8 微秒 18%
4096KiB 1328.2 微秒 1461.8 微秒 10%

列表 2:显示以 16 字节的通道消息和大小为 B-16 的 VMO(而不是大小为 B 的通道消息)传送 B 字节数据时,预计需要支付的传送时间性能“税费”的图表。

仅限线性渠道与渠道 + VMO 比较图表

列表 3:将 B 字节的数据作为 16 字节的通道消息传送与将其作为大小为 B - 16 的 VMO(而不是大小为 B 的通道消息)传送之间的传送时间性能的线性比例比较。

不同载荷大小的 VMO 使用量惩罚图

这些数据得出了一些有趣的观察结果。我们可以看到,数据大小与传送时间之间的关系大致呈线性关系。这两种方法的性能差距显而易见,但有趣的是,随着消息大小的增加,差距似乎会缩小。

综合这些结果,我们可以使用此设计中指定的方法对发送 FIDL 大型消息的预期性能进行建模。与使用相同大小的传统渠道消息(如果允许)相比,我们预计在给定大小下,所谓的“VMO 税”会导致端到端传送时间大约增加 20-60%。有趣的是,随着发送的消息大小的增加,百分比差距会略微缩小,这表明 VMO 税费相对于载荷大小的增长略低于线性。

列表 4:显示本文档中所述设计的模拟交付时间性能的表格。

邮件大小 / 策略 仅限频道 消息 + VMO
16KiB 2.5 微秒 --
32KiB 4.5 微秒 --
64KiB 7.9 微秒 13 微秒
128KiB -- 23.3 微秒
256KiB -- 54.4 微秒
512KiB -- 107.4 微秒
11024KiB -- 223.4 微秒
2048KiB -- 631.8 微秒
4096KiB -- 1461.8 微秒

列表 5:线性比例图,显示本文档中所述设计的模拟提交时间性能。请注意,在从普通消息切换到大型消息时,64 KiB 处存在不连续性。

模拟效果的线性图表

工效学设计

这项变更对人体工学做出了重大改进,因为现在,基本上可以使用第一类 FIDL 概念来描述 zx.handle:VMOfuchsia.mem.Bufferfuchsia.mem.Data 的所有当前用例。下游绑定代码也会受益,因为以前必须通过线缆以无类型方式发送的数据现在可以使用常规的 FIDL 途径进行处理。从本质上讲,为大型消息生成的 FIDL API 现在与非大型消息的对应 API 相同。

向后兼容性

这些更改将完全向后兼容。现有 API 的语义略有变化(从每个消息 64KiB 的边界变为无边界),但由于这项限制放宽了,因此现有 API 不会受到影响。

安全注意事项

这些更改对安全性的影响微乎其微。使用 fuchsia.mem.Data 结构,我们已经可以将模式提升为“第一类”状态,并且没有发现对安全性有任何负面影响。不过,在任何情况下,确保其实现方式都是安全的,这一点仍然很重要。

这种设计还会扩大与 FIDL 协议相关的拒绝服务风险。以前,发送分配内存过多而导致接收器崩溃的 VMO 的攻击向量仅适用于明确发送包含 fuchsia.mem.Data/fuchsia.mem.Buffer/zx.handle:VMO 类型的协议。现在,所有包含至少一种载荷无界或半无界的方法的协议都存在此风险。目前,由于 Zircon 中存在许多拒绝服务攻击途径,因此我们认为这种情况是可以容忍的。我们将在本设计的限制之外寻求更全面的解决方案来解决此问题。

由于未强制要求在接收端检查 ZX_INFO_VMO,因此此设计引入了额外的拒绝服务攻击向量。这样一来,由分页器支持的 VMO 便可通过永远不提供其承诺提供的页面来导致服务器挂起。实际上,意外发生这种情况的风险被判定为较低,因为只有相对较少的程序使用由分页器支持的 VMO 机制。与上述推理类似,在未来的设计中实现更全面的解决方案之前,我们会容忍这种拒绝服务攻击矢量。

隐私注意事项

一项重要的隐私保护注意事项是,消息发件人必须确保为每条基于 VMO 的消息使用新创建的 VMO。它们不得在消息之间重复使用 VMO,否则可能会泄露数据。必须使用绑定才能强制执行这些约束条件。

测试

针对 fidlc 的单元测试和针对下游和绑定输出的金标准的标准 FIDL 测试策略将扩展,以适应大型消息用例。

文档

需要更新 FIDL 线格式规范,以说明本文档中引入的线格式更改。

缺点、替代方案和未知情况

缺点

这种设计有许多缺点。虽然这些问题被判定为次要问题(尤其是与不采取任何行动或实施考虑的替代方案的成本相比),但仍值得指出。

性能悬崖

性能探索中所详述,此 RFC 中所述的策略会导致在 ZX_CHANNEL_MAX_MSG_BYTES 截断点处出现性能“断崖”,即用户开始为发送较大的消息支付“税费”。具体而言,与大小恰好为 64KiB 的消息相比,大小比 64KiB 大 1 字节的消息的接收时间会延长大约 60%(13 微秒,而不是 7.9 微秒)。虽然这种断崖不理想,但相对较小,并且可以通过未来的内核变更来缓解。

拒绝服务攻击

现在,任何至少有一个方法接受无界或半有界载荷的协议都容易受到与内存相关的拒绝服务攻击:恶意攻击者可以发送溢出消息,其中 message_info 结构体的 msg_byte_count 字段中包含一个非常大的值,并且附加了一个类似大小的 VMO。然后,接收器将被迫分配足够的内存来处理此载荷,如果恶意载荷足够大,则不可避免地会崩溃。

正如上文所列的安全注意事项中所述,这是一个非常现实的风险,在未来找到更全面的解决方案之前,此设计会暂时搁置解决此问题。

处理溢出边缘情况

这种设计并不能完全消除由于消息意外变大而导致运行时出现意外 PEER_CLOSED 的可能性:包含超过 64 个句柄的普通消息或包含超过 63 个句柄的大型消息仍会触发错误状态。目前,这被认为是可接受的,因为作者知道在实践中没有使用此类载荷。您可以根据需要处理此边缘情况。在 message_info 结构体中添加 reserved 字段可确保在日后设计句柄溢出支持时具有灵活性。

依赖于上下文的消息属性

byte_overflow 和标志将是第一个对不同传输层有不同含义的标头标志(休眠标志除外,具体取决于我们是否将休眠状态 FIDL 视为“传输层”)。这会引入一些模糊性:仅查看有线编码的 FIDL 事务消息,而不了解发送该消息的传输方式,可能不再完全足以处理该消息。现在需要执行“预处理”步骤,在此过程中,我们会根据标头标志和消息发送传输方式,执行特殊的程序来组装完整的消息内容。例如,在锆石通道传输中溢出的非句柄传送消息现在会在其句柄数组中获取句柄,而溢出的 fdf 消息可能不会。

被拒的替代方案

在设计此 RFC 时,我们考虑了大量替代解决方案。下面列出了最有趣的提案。

提高了 Zircon 消息大小上限

对大型消息而言,最迫切的需求是通过 zx.channel 传输,其消息大小上限目前为 64KiB。一个显而易见的解决方案是直接提高此上限。

这么做会产生几个问题第一个原因是,这种做法只是将问题推迟到将来。由于此类 ABI 破坏型内核限制迁移并非易事,因此情况变得更加复杂,因为需要谨慎管理,以确保在提高限制后编译的二进制文件不会意外发送比在提高限制前编译的二进制文件能处理的数据更多。

许多 FIDL 实现还会根据此限制做出有用的假设。某些绑定(例如 Rust 绑定)会对收到的消息采用“猜测和检查”分配策略。它们会分配一个小缓冲区并尝试 zx_channel_read_etc。如果该系统调用失败并返回 ZX_ERR_BUFFER_TOO_SMALL,则会返回实际消息大小。这样,绑定便可分配大小适当的缓冲区并重试。

其他绑定(例如 C++ 绑定)则不加以注意,始终为传入消息分配 64KiB,以避免可能发生的多次系统调用,但代价是分配更多内存。后一种策略无法扩展到任意大小的消息。

最后,使用 VMO 是一种经过充分测试的解决方案:多年来,它一直是通过 fuchsia.mem.Datafuchsia.mem.Buffer 类型传输大型消息的首选方案。提高内核限制是一个不太成熟的解决方案,可能会带来更多未知问题。

使用 zx_vmar_map 而非 zx_vmo_read

对当前设计进行优化后,FIDL 编码器将使用 zx_vmar_map 直接从 VMO 缓冲区读取数据。这种方法存在两个问题。

主要问题就是此方法不安全,需要修改内核基元才能解决此问题。问题在于,映射内存会导致消息发送方在接收方读取消息内容时修改消息内容,从而造成 TOCTTOU 风险。读取器可以尝试直接从映射的(可能可变的)VMO 读取数据,而无需先进行复制,但即使执行了防御性复制,也很难安全地执行此操作。可以通过通过 zx_vmo_create_child 调用强制执行 VMO 的不变性来缓解这些安全风险,但代价是需要额外的系统调用和最坏情况下的复制开销。

由于内存映射性能方面存在其他问题,以及线 C++ 绑定(例如确定何时可以释放内存)的复杂性,因此此选项不太合适。

分包

这里的想法是,将非常大的消息拆分成许多消息,每个分块不大于 64KiB,然后在另一端将它们组合起来。事务消息标头将包含某种接续标志,用于指明是否预计有“更多数据”要跟随。这样做有内置流控制的优势,并且对于使用标准库中包含流式基本元素的语言的程序员来说,也比较熟悉。

这种方法的缺点是,如果消息不易分块,这种方法显然没有帮助。它也更加复杂:当多个线程发送时,令人眼花缭乱的一系列部分消息块会堵塞传输,需要在另一端重新组装。

最令人担心的是,这种策略会带来服务拒绝风险,这种风险无法仅通过新的内核基元或日后添加的边界协议来解决:恶意或有 bug 的客户端可能会发送非常长的消息数据包序列,但之后却无法发送“关闭”数据包。接收器在等待最后一个数据包时,必须将所有剩余数据包保留在内存中,以便客户端在服务器上“预订”可能无限的永久内存分配。当然,可以通过一些方法来解决此问题,例如超时和政策限制,但这很快就会演变为通过 FIDL 重新实现 TCP。

显式溢出

本文档中提出的设计抽象了从最终用户传送大型消息的详细信息。用户只需定义其载荷,绑定会在后台完成其余工作。

另一种设计可以让用户以声明方式指定具体何时使用 VMO,可以是按载荷级别,也可以是按载荷成员级别。本质上,这只需要修改 FIDL 语言,为 fuchsia.mem.Data 提供更简洁的拼写。

现有设计在提高性能和 API 兼容性的同时,降低了确定性和精细控制,这被认为是一项值得的权衡。

仅允许值类型

此设计的早期版本提议仅为值类型启用 overflowing。原因很简单:现有的所有用例都不是资源类型,而且单个 FIDL 消息不太可能需要一次发送超过 64 个句柄,因此我们判定这项工作优先级较低。

在为 fuchsia.component.resolution 原型化此解决方案的过程中,我们发现了一个问题。某些方法已经在使用表来传输其载荷,因此最好扩展表,以逐步废弃 fuchsia.mem.Data,而不是完全替换方法。具体而言:

// Instead of adding a new method to support large messages, the preferred
// solution is to extend the existing table and keep the current method.
protocol Resolver {
  Resolve(struct {
    component_url string:MAX_COMPONENT_URL_LENGTH;
  }) -> (resource struct {
    component Component;
  }) error ResolverError;
};

type Component = resource table {
  // Existing fields - note the two uses of `fuchsia.mem.Data`.
  1: url string:MAX_COMPONENT_URL_LENGTH;
  2: decl fuchsia.mem.Data;
  3: package Package;
  4: config_values fuchsia.mem.Data;
  5: resolution_context Context;

  // Proposed additions for large message support.
  6: decl_new fuchsia.component.decl.Component;
  7: config_values_new fuchsia.component.config.ValuesData;
};

这些方法会出现一个有趣的问题:虽然载荷较大的情况和载荷携带句柄的情况在实践中是互斥的,但 fidlc 编译器不知道这一点。从其角度来看,这些只是资源类型。虽然可以想象出一些临时解决方案来告知编译器这种特殊情况,但我们认为,在开发出更合适的内核基元之前,只允许对大型消息使用 63 个句柄更为简单。

overflowing 修饰符

此设计的早期迭代允许用户在定义其协议方法的 FIDL 中设置 overflowing 存储分区,如下所示:

// Enumerates buckets for maximum zx.channel message sizes, in bytes.
type msg_size = flexible enum : uint64 {
  @default
  KB64 = 65536;    // 2^16
  KB256 = 262144;  // 2^18
  MB1 = 1048576;   // 2^20
  MB4 = 4194304;   // 2^22
  MB16 = 16777216; // 2^24
};

@transport("Channel")
protocol Foo {
  // Requests up to 1MiB are allowed, responses must still be less than or equal
  // to 64KiB.
  Bar overflowing (BarRequest):zx.msg_size.MB1
      -> (BarResponse) error uint32;
};

最终,我们认为此方法过于复杂和微妙,因为它提供了多个选项,但没有明确的指导来帮助用户选择。最终,大多数用户可能只想回答一个简单的“是/否”问题(“我需要大消息支持吗?”),而不是担心特定限制对性能造成的微小影响。

我们还考虑了另一种替代方案,即使用单个 overflowing 关键字(不使用分桶),以向用户明确说明他们接受的 API 性能可能较差。最终,我们认为性能差距不够大,并且在任何情况下都足够缩小,因此不必在语言本身中显示此类宣传信息。

未来可能开展的工作

虽然这些配套工作并非启用大型消息功能的严格要求,但对于此工作而言,它们是重要的补充和优化。

内核变更

虽然这些内核更改并非实现此功能的关键路径,但无疑有助于减少系统调用抖动并优化性能。实现这些额外优化后,面向用户的大型消息 API 不应发生变化。大多数现有 fuchsia.mem.Data 用户对延迟时间没有特别敏感(否则他们就不会使用 fuchsia.mem.Data!),因此修改内核的主要用途是为在 FIDL 中启用大型消息后出现的紧急用例提升性能。

一流的直播

每当出现大型消息用例时,都会不可避免地有人问:“能否通过在 FIDL 中实现第一类数据流来解决此问题?”为了针对任何具体情况回答这个问题,我们应该考虑两个实用属性,它们可用于对大量数据进行分类:可分块可附加

可分块性是指相关数据能否拆分为有用的子部分,更重要的是,数据的接收器能否仅对一部分数据执行有用的工作。从本质上讲,这两者之间的区别在于,一方面是类型为 T 的数据,另一方面是类型为 vector<T>array<T> 的数据,后者列表的部分视图仍可执行操作。分页列表可分块,而要发送以进行排序的项列表则不可分块。同样,树也无法分块:通常,对树的(任意)部分无法执行太多操作。

可附加性涉及数据在发送后能否修改。可附加 API 的经典示例是 Unix 管道:在从读取端读取数据时,可能会(甚至应该)从写入端添加更多数据。可附加的数据是指在发送后可以添加到的数据。发送时不可变的数据(即使以列表形式)也不受此限制。

列表 6:针对可分块性和可附加性所有可能组合的首选大型数据处理策略的矩阵。

大数据处理策略矩阵

这两种区分很有用,因为将它们组合到一个矩阵中,可以很好地指导您确定哪种大型消息或流更合适。

对于静态 blob(例如数据转储、B 树或大型 JSON 字符串),用户不希望进行流式传输:对他们而言,这是一个消息,而它对于 FIDL 来说太大(或至少可能太大)这一事实通常与他们无关。在这种情况下,他们希望能够告知系统“不惜一切代价将除超大消息以外的所有消息发送到网络上”。数据在设备上某个位置的内存中具体化后,在进程之间分块移动数据没有多大意义。

对于可分块且动态的数据结构(例如网络数据包流),流是显而易见的选择(这从名字中就可以看出来!)。用户已经创建了自定义迭代器来处理这种情况,编写了用于在发送端设置数据流并在接收端干净地公开数据流的库,因此它似乎是首选处理方式的理想人选。这也是一个非常自然的模式,在 FIDL 绑定到其中的大多数语言(C++、Dart、Rust)中都具有强大的支持和程序员熟悉度。

如果消息可分块但大部分是静态的,例如列出连接到设备的外围设备的快照,该怎么办?将这些信息分块并以流的形式公开非常容易,但这是否有益并不明显:在某些情况下,API 会公开此类信息,但作者将分页视为为了取悦 FIDL 而添加的权宜之计,而不是一项核心功能。在这种情况下,流式传输还是大型消息是更好的选择,似乎取决于具体情境。

总而言之,大型消息只是通过网络获取大量数据的众多可能方法中的一种工具。FIDL 未来很可能会有出色的流式传输实现,这种实现将是对大型消息提供的功能的补充,而不是替代。

受限协议和灵活的信封大小限制

这种设计存在非常现实的拒绝服务风险,某些协议(尤其是在许多原本独立的客户端之间共享的协议)可能希望避免这种风险。为此,可以考虑向协议添加 bounded 修饰符,在编译时强制执行其所有方法仅使用边界类型:

// Please note that this syntax is very speculative!
bounded protocol SafeFromMemoryDoS {
  // The payload is bounded, so this method compiles.
  MySafeMethod(resource struct {
    a bool;
    b uint64;
    c array<float32, 4>;
    d string:32;
    e vector<zx.handle, 64>;
  });
};

这种设计的一个后果是,FIDL 协议作者面临着“分叉”选择:添加 bounded 可使协议免受因消息无限大而可能导致的拒绝服务攻击,但会阻止该方法的载荷传递使用 tableflexible union 类型。这是一个不幸的权衡,因为可扩展性和 ABI 兼容性是 FIDL 语言的核心目标。通过强制用户使用 ABI 稳定类型,我们会大大限制他们日后改进载荷的能力。

一种可能的折衷方案是为 flexible 封装容器布局引入显式大小限制。这提供了 ABI 兼容性,因为灵活的定义会随时间而变化,但仍会对类型的最大大小强制执行 ABI 破坏性限制:

// Please note that this syntax is very speculative!
@available(added=1)
type SizeLimitedTable = resource table {
  1: orig vector<zx.handle>:100;
  // Version 2 still compiles, as it contains <=4096 bytes AND <=1024 handles.
  @available(added=2)
  2: still_ok string:3000;
  // Version 3 fails to compile, as its maximum size is greater than 4096 bytes.
  @available(added=2)
  3: causes_compile_error string:1000;
}:<4096, 1024>; // Table MUST contain <=4096 bytes AND <=1024 handles.

这种大小限制提供了一种“软”灵活性:载荷仍可随时间推移而发生变化,但在首次定义载荷时,对其增长范围施加了硬限制(即 ABI 破坏限制)。

在先技术和参考文档

此提案与 fuchsia.mem.Data 类似,在 fuchsia.mem.Data 之前,fuchsia.mem.Bufferzx.handle:VMO 在 fuchsia.git 代码库中得到了广泛的使用和支持。这项决策本质上是对此经过充分测试的模式的“第一类”演变。

之前已废弃的 RFC 中描述了与此 RFC 略有相似的内容,也使用了 VMOs 作为大型消息的底层传输机制。

附录 A:fuchsia.git 中的 FIDL 载荷的边界性

下表显示了截至 2022 年 8 月初,fuchsia.git 代码库中溢出(大于 64KiB)载荷和标准载荷之间的边界分布。这些数据是通过构建 fuchsia.git 的“全部”build 收集的。然后,通过一系列 jq 查询分析生成的 JSON IR。

列表 7:显示 fuchsia.git 代码库中每种载荷边界性和大小的测量频率的表格。

边界性 / 消息类型 标准 溢出 总计
有界限 3851 (76%) 45 (%) 3896 (77%)
半受限 530 (10%) 70 (1%) 600 (11%)
无界限 0 (0%) 602 (12%) 602 (12%)
总计 4381 (86%) 717 (14%) 5098(100%)

  1. 我们可能会很想通过将 64 KiB 消息的传送时间乘以 64 来比较可能的分片解决方案(例如 4 MiB 消息)的性能,但这并不正确。在进行性能比较的机器上,处理器缓存的影响可确保小于等于 1 MiB 的数据传输速度相对较快,而如果按顺序传输大于 1 MiB 的数据,速度会相对较慢;这在 Listing 2 中所示图表中 1 MiB 处的“缺口”中有所体现。此基准测试方法支持的唯一有效比较是直接比较大小相同的消息。