RFC-0196:FIDL 大型邮件

RFC-0196:FIDL 大型邮件
状态已接受
领域
  • FIDL
说明

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

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

总结

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

设计初衷

目前,通过网络发送的所有 FIDL 消息的大小上限为 ZX_CHANNEL_MAX_MSG_BYTES,目前相当于 64KiB,并且源自可通过 zircon 通道发送的消息的大小上限。超出此限制的消息无法编码。

目前,此问题的常见解决方案是通过 zx.handle:VMOfuchsia.mem.Data 将任意大的数据作为 blob 发送,同时底层 VMO 本身包含要发送的数据 blob。通常,这些 blob 包含最终用户希望用其表示并编码/解码为 FIDL 的结构化数据,但无法或被强制自行进行手动类型转换。目前,fuchsia.git 中有许多此类封装容器类型。

存在一些由于缺少大型邮件支持而导致的问题。其中最重要的是,由很少需要发送大型消息但技术上有能力发送大量消息的协议导致大量 bug。示例包括非常大的网址,或 Wi-Fi 扫描期间生成的非常庞大的网络列表。每个需要接受大小为 :MAXvectorstring 的 API 都容易受到此问题的影响,但也存在其他一些极端情况,例如 table 布局,这种布局极少填充所有字段。一般来说,任何需要以并非可证明低于 64KiB 的消息形式的用户数据都可能会受到此失败模式的影响。

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

使用 zx.handle:VMOfuchsia.mem.Data 会产生一种情况,其中仅包含数据的 FIDL 类型必须带有 resource 修饰符。这会对绑定 API 产生下游影响,导致 Rust 等语言中生成的类型在合理的情况下无法派生 Clone 特征。

因大消息支持不足而导致的 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、tombergan@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 等因素而异。这意味着,调用特定方法请求或响应“large”并非抽象语句:该方法必须始终为该方法所属的协议明确定义“large”。

以下代码指定了消息可以如何声明“我的体型相对于我携带的运输工具的预期较大,因此需要特殊处理”。对于实际溢出的任何给定消息实例,此声明必须在接口定义时(在接口定义时(指定协议的 *.fidl 文件中))和在运行时清晰可辨。

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

有线格式更改

FIDL 事务消息标头动态标志部分中添加了一个新的位,该位称为 byte_overflow 标志。此标志在翻转后表示当前保留的消息仅包含消息的控制平面,并且消息的其余部分存储在单独的可能不连续的缓冲区中。

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

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 的句柄没有正确的 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,使用有界限载荷的方法性能不会降低。如果方法使用半有界限或无界限载荷,但不发送大于协议传输的 bye 计数限制的消息,则仅支付接收端的单个位标志检查的成本。只有实际使用大型消息溢出缓冲区的消息才会受其性能影响。

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

不迁移

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

出行方式定制

这种设计可以灵活满足不同传输(包括现有传输和推测传输)的需求。例如,只要 byte_overflow 位被翻转,并且传输知道如何对包含数据包的溢出进行排序,就可以通过网络发送多数据包消息等惯例。

实现

此功能将在实验性 fidlc 标志之后推出。然后,系统会修改每个绑定后端,以处理此 RFC 为明确指定实验性标志的输入指定的大型消息。一旦该功能被视为稳定,我们便会移除该标志,以供一般使用。

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

在此 RFC 之前,绑定通常会将编码/解码缓冲区放到堆栈上。今后,对于没有翻转 byte_overflow 标志的消息,绑定应该继续采用此行为。对于这样做的消息,绑定应该在堆上分配。

性能

可以使用内核 Microbenchmark 在略微自定义的场景中估算所提议的传送方法对性能的影响,对以下两种情况进行求和:发送大小为 B 的单通道消息与发送大小为 B - 16 的 VMO(发送大小为 B - 16 的 VMO,其中 B 的值分别为 B:16KiB18、Ki2B16、32KiB10、BiK2B10

清单 1:表格所示为以 16 字节通道消息和 VMO 大小为 B - 16(而非大小为 B 的通道消息)形式发送 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 微秒 223.4 微秒 42%
2048KiB 536.2 微秒 631.8 微秒 18%
4096KiB 1328.2 微秒 1461.8 微秒 10%

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

线性仅渠道与渠道 + VMO 对比图

清单 3:以 16 字节通道消息的形式传送 B 字节的数据与以 B - 16 大小的 VMO(而非大小为 B 的通道消息)传送 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:线性比例图表,显示本文档中所述的设计的根据模型估算的交付时间表现。请注意,64KiB 从常规消息切换到大型消息时会出现不连续性。

根据模型估算的性能的线性图

工效学设计

这项变更为工效学设计带来了重大改进,因为现在基本上可以使用一流的 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 类型的协议。现在,包含至少一个具有无界限或半有负载的方法的所有协议都会面临这种风险。仅仅因为锆石中存在许多拒绝服务矢量,这目前被认为可以容忍。我们将在此设计之外寻求一种更全面的解决方案来解决此问题。

此设计不强制要求在接收端检查 ZX_INFO_VMO,从而引入了一个额外的拒绝服务攻击向量。这样一来,由分页器支持的 VMO 就不会提供其承诺提供的页面,因此会导致服务器挂起。由于只有相对少数的程序使用由分页器支持的 VMO 机制,因此在实践中意外发生这种情况的风险非常低。与上述推理类似,在能够在未来的设计中实现更全面的解决方案之前,可以容忍这种拒绝服务攻击途径。

隐私注意事项

隐私保护的一项重要考虑因素是,消息发送者必须确保为每条基于 VMO 的消息使用新创建的 VMO。它们不得在消息之间重复使用 VMO,否则有泄露数据的风险。需要绑定来强制执行这些限制。

测试

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

文档

您需要更新 FIDL 传输格式规范,以描述本文档引入的传输格式变更。

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

缺点

这种设计有很多缺点。虽然这些测试被认为微不足道,尤其是相对于什么也不做或实现考虑过的替代方案的成本而言,但仍然值得一提。

性能悬崖

正如性能探索中所详述的,此 RFC 中描述的策略会导致在 ZX_CHANNEL_MAX_MSG_BYTES 临界点出现性能“悬崖”,即用户在发送更大的消息时需要支付“税费”。具体而言,与正好为 64KiB 的消息相比,接收大小超过 64KiB 的消息大约需要多接收 13 μs,而不是 7.9 μs。虽然这种悬崖并不理想,但它相对较小,并且可以通过未来的内核更改加以改善。

拒绝服务攻击

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

正如上面列举的安全注意事项中所讨论的,这是一个非常真实的风险,此设计将忽略这种风险,直到在未来找到更全面的解决方案。

处理溢出极端情况

这种设计并不能完全消除因消息异常大而在运行时中出现意外 PEER_CLOSED 的可能性:具有超过 64 个句柄的常规消息或具有超过 63 个句柄的大型消息仍会触发错误状态。目前,这被认为可以接受,因为作者知道实际上没有使用此类载荷。可以根据需要处理这种极端情况。在 message_info 结构体中添加 reserved 字段可确保在未来的句柄溢出支持设计中具有灵活性。

依赖于上下文的消息属性

byte_overflow 和标志将是第一个标头标志,对于不同的传输意味着不同的含义(可以说,静态标志除外,具体取决于我们是否将静态 FIDL 视为“传输”)。这会带来一些歧义:仅仅查看采用有线编码的 FIDL 事务性消息,而不知道发送了哪条传输,可能不再完全足以处理该消息。现在需要一个“预处理”步骤,根据标头标记和消息发送的传输,我们会执行一个特殊程序来组合完整的消息内容。例如,如果一个非句柄的消息在 zircon 通道传输上溢出,则现在会在其句柄数组中获得一个句柄,而溢出的 fdf 消息则可能不会。

遭拒的替代值

在设计此 RFC 的过程中,我们考虑了很多备选解决方案。下面列出了一些最值得关注的建议。

提高 zircon 消息大小限制

对大型消息最迫切的需求是采用 zx.channel 传输方式,其目前的消息大小上限为 64KiB。一种显而易见的解决方案是提高此限制。

这种情况是不可取的,原因有很多。第一个是它只是忽略了以后的问题。这种会破坏 ABI 的内核限制迁移并非易事,因为这样会破坏 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,然后在另一端组合在一起。事务性消息标头将具有某种延续标志,指示它是否需要“更多数据”。这具有内置流控制的优势,对于在标准库中具有流式基元的语言的编程人员来说也很熟悉。

这种方法的缺点是,在消息不易分块的情况下,这种方法没有明显的帮助。这个过程也要复杂得多:当多个线程正在发送时,令人眼花缭乱的部分消息块数组会堵塞需要在另一端重新组装的传输。

最令人担忧的是,此策略会带来拒绝服务攻击的风险,这种风险日后无法通过新的内核基元或者未来添加有边界协议来修复:恶意或有漏洞的客户端可能会发送非常长的消息数据包,但随后无法发送“关闭”数据包。接收器在等待最后一个数据包时,必须将所有剩余的数据包保存在内存中,以便客户端在服务器上“预订”可能不受限制的永久性内存分配。当然,有多种方法可以解决这个问题,例如超时和政策限制,但这很快就变成了通过 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 因此,在实现这些额外优化时,该 API 不应发生变化。fuchsia.mem.Data 的大多数现有用户对延迟时间都不太敏感(否则他们就不会使用 fuchsia.mem.Data!),因此修改内核的主要用途是提升在 FIDL 中启用大型消息后弹出的新兴用例的性能。

一流的视频流

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

可分块性是指是否可以将数据拆分为有用的子部分,更重要的是,数据的接收器是否只能对一部分数据执行有用的工作。实质上,一方面它是 T 类型的数据与 vector<T>array<T> 类型的数据之间的差异(对列表的局部视图仍然具有可操作性)。分页列表可分块,而送交排序的项列表则不可分块。同样,树也不是可分块的:通常,对树的(任意)部分可以执行的操作也不多。

可附加性涉及数据在发送后是否可以修改。可附加 API 的典型示例是 Unix 管道:当从读取端读取数据时,可能会从写入端添加更多数据(甚至在预期之内)。发送后可能会添加的数据可以附加。在发送时不可变的数据,即使采用列表形式,也不例外。

列表 6:一个矩阵,其中包含所有可能的分块性和可附加性组合的首选大型数据处理策略。

大型数据处理策略矩阵

这两种区别很有用,因为在矩阵中将它们组合起来可以很好地指导哪些大型消息或数据流更合适。

对于静态 blob(例如数据转储、B 树或大型 JSON 字符串),用户不希望进行流式传输:对于用户来说,它是一条消息,而对于 FIDL 来说,该 blob 过大(或至少可能)过大,这通常不会引起用户的意外。在这种情况下,他们希望通过某种方式告知系统“尽一切可能通过网络获取此消息,但最不合理的大小除外”。一旦数据被具体化到设备上某处的内存,在进程之间零散地移动它就没有意义。

对于可分块的动态数据结构(例如网络数据包流),数据流是显而易见的选择(就在名称中!)。用户已经构建了自定义迭代器来处理这种情况,并编写了一些库来处理在发送端设置流并在接收端清楚地公开流的库,因此它似乎非常适合进行一流处理。它也是一种非常自然的模式,具有强大的支持,并且程序员熟悉大多数 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.Bufferzx.handle:VMO 在 fuchsia.git 代码库中广泛使用和支持。这一决策实质上是这种经过充分测试的模式的“一流的”演变。

先前已废弃的 RFC 与这则内容类似,还使用 VMO 作为大型消息的底层传输机制。

附录 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. 您可能很想将 64KiB 消息的发送时间乘以 64,以比较 4MiB 消息的潜在打包解决方案的性能,但这是不正确的。处理器对执行性能比较的机器的影响可确保大小小于 1MiB 的传输作业的执行速度相对快于按顺序传输超过 1MiB 时的性能;从列表 2 所示图表的 1MiB 处的“缺口”可以看出这一点。通过这种基准化分析方法启用的唯一有效比较就是直接在相同大小的消息之间进行比较。