RFC-0126:驱动程序运行时

RFC-0126:驱动程序运行时
状态已接受
领域
  • 设备
说明

驱动程序的新运行时规范。

Gerrit 更改
  • 564883
作者
审核人
提交日期(年-月-日)2021-08-04
审核日期(年-月-日)2021-09-21

总结

此 RFC 确立了共处于进程内的驱动程序相互通信的设计。驱动程序将通过进程内运行时(以 zircon 内核为模型)进行通信。运行时将提供与 zircon 通道和端口类似的基元,并且将基于此运行时构建新的 FIDL 传输,与通过 zircon 通道和端口实现的性能相比,这种传输方式的效果更好。这种新的运行时和 FIDL 传输将取代当前驱动程序使用的现有班卓琴运行时。

不在同一进程中的驱动程序之间的通信将继续使用基于 zircon 通道的 FIDL。对驱动程序透明底层传输机制被视为超出了本方案的范围,在未来的方案中会进行此讨论。

RFC 还确立了一组线程模型驱动程序应遵循的规则,以便高效地相互共享线程。可以在同一线程上同时传送源自进程中的驱动程序的消息以及进程外的驱动程序。

设计初衷

现有的驱动程序运行时解决了在创建时的问题要求。FIDL 尚不存在,驱动程序在 C 代码中几乎普遍存在,而且 zircon 内核也尚未得到充分优化。因此,用于相互通信的接口驱动程序最初是一个函数表,其中包含关联的类型已擦除上下文指针。当 C++ 驱动程序的编写频率越来越高时,就已经创建了 FIDL,但由于感知到绑定实现的开销较高,因此 FIDL 并不适合执行驱动程序间通信的任务。为了改善 C++ 驱动程序的工效学设计,系统围绕 C 协议定义手动生成了封装容器。为了减少需要维护 C 协议定义和 C++ 封装容器的重复劳动,我们开发了一个新的 IDL,该 IDL 的语法在很大程度上与当时 FIDL 的语法保持一致,它可以通过共同的可信来源自动生成 C 和 C++ 代码。后来又称为班卓琴虽然 Banjo 确实进行了一些初始投资,但很快就进入了维护模式,与 FIDL 相比,FIDL 多年来几乎没有什么进步,而 FIDL 在类似的时间段内也蓬勃发展。Banjo 已不再满足当前的要求,因此必须重新构想驱动程序运行时。

此设计的动机在于许多不同的问题,我们希望一键解决它们。

稳定的 ABI

驱动程序框架处理的最重要的问题是启用稳定的驱动程序 SDK。这是一个平台级目标,将帮助 Fuchsia 实现广泛的硬件支持,并确保我们在不丢失功能的情况下更新操作系统各个部分。Banjo 是当前的驱动程序间通信解决方案,在构建时并未考虑 API 和 ABI 演变。相反,它会进行优化以简化操作和降低开销。因此,目前还没有一种好的机制可以修改 banjo 库,而不会破坏所有依赖于它的客户端和服务器。这将使我们在实现目标的同时改进平台变得非常困难。

线程安全

如今,在编写驱动程序时,实现 banjo 协议非常具有挑战性,而且无需在驱动程序中生成专用线程来处理请求。这是因为在根据协议调用方法时,使用这些协议的驱动程序无需遵循任何规则。处理来电时,您需要确保自己的逻辑:

  • 处理同步问题,因为客户端可能会从多个线程中并行调用。
  • 处理重入。

处理第一个问题的显而易见的方法是获取锁。但是,仅仅获取锁就很容易导致死锁,因为您的驱动程序可能会回调到另一个驱动程序,并且在同一个堆栈帧中,系统可能会将其回调以尝试重新获取同一个锁。目前在驱动程序中使用的所有锁实现都不是可重入安全的,因此,出于替代方案部分深入探讨的原因,我们不想开始使用递归锁。

因此,目前正确处理这种情况的唯一方法是将工作推送到队列,稍后再处理该队列。由于当前驱动程序运行时未提供将工作安排在稍后执行的机制,因此驱动程序必须实例化自己的线程来为此队列提供服务。

这样做会带来问题,因为它破坏了在同一进程中共置驱动程序的大部分预期性能优势。此外,在驱动程序实现的复杂性方面,也要负担高额费用。系统上的许多驱动程序不会选择将工作推送到队列,因此可能还需要一个新客户端才能使用,这样会不可避免地导致系统死锁。有传奇,这类 bug 潜伏在驱动程序中,且由于可重入仅在很少发生的错误条件下发生,才会不稳定地出现。

样板序列化

激励新运行时的另一个问题是与非驱动程序的通信不一致。通常情况下,驱动程序会实现向非驱动程序组件公开的 FIDL 服务,并通过将请求(通常经过有限的翻译)转发到其他驱动程序来处理请求。这个过程中会充斥着样板代码,虽然类型(结构体、枚举等)的定义甚至协议可能会在 zircon 通道传输 FIDL 和 banjo 传输 FIDL 之间共享,但生成的类型完全不一致。因此,驱动程序作者必须实现相应的逻辑,从一组生成的类型进行序列化。

这一特定问题经常被列为当今编写司机时最大的人体工程学痛点之一。这个问题过去更加严重,因为最近才有可能在不同 FIDL 传输之间共享类型,而 banjo 本身最近也成为了 FIDL 传输。以前,类型的定义很容易相互不同步,从而导致出现细微的 bug。如果在定义更改时未正确更新手动序列化逻辑,则仍然可能会出现这些 bug。

利益相关方

教员:abarth@google.com

审核者:abarth@google.com(FEC 成员)、abdulla@google.com (FDF)、yifeit@google.com (FIDL)、eieio@google.com (scheduling/Zircon)

咨询团队:驱动程序团队、网络团队、存储团队、内核团队、FIDL 团队和性能团队的成员。

社交:此 RFC 的草稿已发送给 FEC 讨论邮寄名单,以供发表评论。此设计中发现的早期形式的概念已在驱动程序框架、FIDL 和 Zircon 团队中传播。

设计

更具体地说,该设计将通过一组新的基元以及利用这些基元的新 FIDL 传输来实现。在深入了解此设计的详细信息之前,我们首先探讨此设计用于实现的一些其他要求。

要求

性能

新的驱动程序运行时的一个高级目标就是实现通过利用驱动程序运行时实现的更高性能。各个驱动程序的性能要求差异很大,但大体上讲,我们的目标是针对以下指标进行优化:

  • 总吞吐量高
  • 每秒 I/O 操作次数 (IOPS) 较高
  • 低延迟时间
  • CPU 利用率低

例如,我们希望最大限度地提高 NVMe 固态硬盘的吞吐量,或者确保在输入事件发生和软件收到事件通知之间发生最短延迟时间。虽然在某种程度上,这些任务需要更多的系统协同工作,而不仅仅是驱动程序框架才能满足所需的性能水平,但我们想要确保驱动程序运行时不会成为瓶颈。

其中大部分设计侧重于优化,旨在提升驱动程序间通信的性能,使其超越基于 Zircon 通道的 FIDL 可实现的性能。如果我们在上述所有指标上都无法胜过基于 Zircon 通道的 FIDL,那么它可能就不是当前的班卓运行时的合适替代品。后面的部分将讨论性能优于基于 Zircon 通道的 FIDL 的需求。

驾驶员工效学设计

新驱动程序运行时的另一个主要目标是确保驱动程序易于编写。如果实现最佳性能而不考虑设计决策对驱动程序作者有何影响,将无法产生有用的结果。为作者提供可以在人体工程学设计或零费用开销之间进行选择的选项,这对实现我们的目标至关重要。此外,理想的做法是,能够将编写的驱动程序从简单的实现改进为性能更高的实现,而无需重写,这也是理想之选。

安全性和弹性

位于同一进程中的驱动程序共享安全边界。这是因为,尽管尝试了隔离驱动程序,但我们实际上无法拒绝它们访问属于共享进程中其他驱动程序的内存区域。这是有意为之,因为共享地址空间是主机托管的主要优势之一,我们可以利用该优势来提高性能,超越信道的作用范围。因此,我们需要假设,任何配备合理完善的恶意实体只要获取驱动程序主机中的一个驱动程序的控制权,就能在同一进程中访问所有驱动程序的所有功能。因此,我们不需要以提供额外的安全优势为目的,在驱动程序运行时层中实现安全检查。

但是,这并不意味着我们不能再使用许多通常用于提供安全保障的机制,因为它们可以提高各个驱动程序的弹性。例如,我们仍然可以验证情况是否确实如此,而不是盲目地假设指针指向缓冲区内的数据。这有助于限制 bug 造成的损害,并帮助找到导致问题的根本原因。换句话说,避免错误仍然是不错的选择。设计的其余部分应考虑到这一点。

零复制选项

即使在最佳条件下,基于声道的保真度也会至少生成 2 个副本。一个从用户空间复制到内核(通过 zx_channel_write),另一个从内核复制到用户空间(通过 zx_channel_read)。之所以产生额外的复制,往往是因为客户端中的线性化步骤、选择在服务器中异步处理请求,或将数据移至惯用语言的内置类型,导致总共产生 4 个副本。 由于利用新驱动程序运行时的驱动程序将存在于同一进程中,因而也就存在地址空间,因此可以使最佳情况需要 0 个副本,而符合工效学要求的情况则仅生成 1 个副本。

在驱动程序之间传递的大多数数据(类似于基于通道的 FIDL)应该是控制数据结构,其中数据平面数据存储在 VMO 中。这样,我们就可以减少开销最高的副本。但是,控制数据结构的大小可能会增加,在某些情况下(例如网络数据包批量请求)会达到几千字节。此外,在多个驱动程序堆栈中,数据基本上不会发生变化,并会经过多个层。我们可以忽略所有这些副本。事实上,在当前基于班卓琴的运行时中,我们不遗余力地实现零复制,但是我们实现这种复制的方法在很大程度上存在问题,并且易于出错。这是阻碍我们采用 Rust 驱动程序开发的众多原因之一。

值得注意的是,由于系统调用开销占据主导地位,FIDL 中的副本通常不会被视为性能瓶颈。也就是说,在驱动程序运行时中,我们会避免系统调用,因此副本预计会成为在驱动程序之间发送消息所花费的相当一部分时间。

从性能的角度来看,这些副本目前不会在系统中导致有意义的瓶颈。不过,它们可能会对 CPU 利用率产生可衡量的影响,因此,我们认为提供达到零副本的能力是有用的。

推送与拉取

Zircon 通道基于拉取机制。用户空间会注册某个通道上的信号,以了解何时要读取其数据,并在收到通知后从该通道读取数据到其提供的缓冲区。FIDL 绑定通常会反转这种机制,改为基于推送。回调已注册,回调将在事件准备就绪时触发。

我们可以非常灵活地选择采用完全推送的机制,也可以继续模拟锆石通道机制。对于普通用户而言,推送更符合工效学要求,并且性能可能更高,因为拉取模型需要在收到信号后重新进入运行时。在性能要求更高的其他系统(例如 Windows IOCPLinux io_uring)中,缓冲区通常是预先注册的,通过移除额外的内核条目和副本来实现更高的性能。对于进程内运行时,进入运行时的成本很低,并且实际上没有必要预注册缓冲区来实现零复制,因此,我们的 API 与 zircon 通道的差异不一定是成功的。此外,作为一种语言,Rust 在很大程度上依赖于基于拉取的机制,如果我们在传输级别停止拉取,则可能会与其模型出现阻抗不匹配。

原语

如前所述,新设计将需要新的基元来支持构建新的 FIDL 传输。基元本身很大程度上是在 Zircon 内核提供的基元的基础上建模的。

基元的 API/ABI 将基于 C 语言,类似于现有的 libdriver API。我们将按语言提供每个更符合习惯用法的封装容器。虽然可以通过 FIDL 定义 API,与在 FIDL 中定义 zircon 系统调用接口的方式类似,但可能不值得这样做,因为整体 API 集仍然非常少。

场地

第一个基元是竞技场。为了减少副本数量,我们需要一种方法来确保与请求关联的数据的生命周期至少与请求尚未完成时相同。提供给传输的数据必须由缓冲区提供支持。在基于通道的 FIDL 绑定中,入站消息数据通常在堆栈上开始,如果需要异步响应请求,可以按需进入堆分配中。FIDL 绑定可以直接读入堆内存中,但在写入时,不会发生这种情况。如果我们通过运行时提供的 arena 支持数据,则可将请求的生命周期与 arena 联系。我们还可以针对 arena 进行额外的分配,并保证它们在请求期间持续有效。处理请求时,正常情况下是将其转发给其他驱动程序,或者向下游驱动程序发出请求;在这些情况下,同一个 arena 可被重新调整用途并与新请求一起传递。通过这种方案,让一个操作导航到许多驱动程序,而无需进行全局分配,这是可行的。在块堆栈等驱动程序堆栈中,通常要在同一驱动程序主机中导航 6 个或更多驱动程序,大多时候将同一请求反复转发到较低级别的驱动程序,对数据的修改极少。

运行时将提供用于创建 arena、递减对它们的引用、执行分配以及检查提供的内存区域是否由 arena 支持的 API。这最后一个 API 不常见,但对于 FIDL 传输实现的稳健性而言必不可少。您可以稍后添加释放个别分配的 API,但初始实现会跳过它。只有当对 Area 的所有引用均被移除时,竞技场本身会被销毁时才会发生释放分配。

运行时可以根据需要免费优化 Area。例如,基本实现可以选择直接将所有分配请求转发到全局 malloc,然后等待 arena 销毁,再针对发生的每个分配释放释放。或者,也可以将其实现为单个分配递增场景,其最大大小可根据所需的每个请求分配的最大大小进行调整。随着时间的推移,我们可以扩展 Area 界面,以便客户端提供相关提示,了解底层区域应该采用哪种策略,因为相同的区域策略可能并非在所有驱动程序堆栈中都是最优的。

频道

第二个基元是通道基元。它的接口与一个锆石通道几乎完全相同。最大的区别包括:

  • 写入 API 将获取 arena 对象,并递增其引用计数。
  • 写入 API 将要求传入该 API 的所有缓冲区(数据 + 句柄表)均由 arena 提供支持。由此推知,运行时将拥有这些缓冲区(以及由 arena 支持的所有其他缓冲区)的所有权。
  • 读取 API 将提供一个 arena,调用方获得该引用的所有权。
  • 读取 API 将为数据和句柄表提供缓冲区,而不是要求调用方提供要复制到的缓冲区。

为了让基于其构建的 FIDL 绑定实现零复制,必须存在这些差异。

锆石通道之间的相似之处包括:

  • 渠道是成对创建的。
  • 通道不得重复,因此是双向单提供方单使用方队列。

调度程序

最后一个基元类似于锆石端口对象。它不是提供阻塞当前线程的机制,而是提供一种机制,通过回调指示当前可以读取或关闭的已注册通道。我们还提供用于注册驱动程序运行时通道的机制。

驱动程序将能够创建多个调度程序,与创建多个端口类似。调度程序是实现后续部分中提到的线程模型的主代理。创建调度程序时,您需要指定调度程序应在哪种线程模式下运行。

此外,还有一个 API,用于从驱动程序运行时调度程序对象接收 async_dispatcher_t*,从而实现等待 zircon 对象和发布任务。针对此调度程序注册的回调将遵循运行时通道将受到的相同线程保证。

示例

虽然我们避免为基元指定确切的接口,但在评估设计时,如果通过一个“吸管式”示例了解其可能是什么样子,这样做可能很有用:

https://fuchsia-review.googlesource.com/c/fuchsia/+/549562

线程模型

除了共享相同的地址空间之外,共置驱动程序的第二个主要优势是,可以做出比内核所能作出的更好的调度决策。我们不是为每个驱动程序提供自己的线程,而是希望利用驱动程序共享相同线程的能力。当多个独立的代码段共享同一个线程时,它们必须遵循某种协定以确保正确性。

线程模型设定了规则和预期,驱动程序开发者可以利用这些规则和预期来确保正确性。如果操作正确,线程模型可以大大简化开发体验。如果驱动程序开发者不希望处理并发、同步和重入问题,则应提供相关机制,让他们能够在忽略这些问题的同时编写功能性代码。在另一种极端情况下,线程模型必须提供足够的自由度,使驾驶员达到本文档前面所述的性能目标。

为此,我们提出了一种线程模型,该模型提供以下两种高级模式,供驱动程序在下运行:

  • 并发和同步。
  • 并发和未同步。

可能有必要首先定义所使用的术语:

  • 并发:多个操作可以彼此相对交错。
  • 已同步:在任何特定时刻,驱动程序都不会同时有两个硬件线程。所有调用都按照彼此尊重的方式进行排序。
  • 未同步:在任何给定时刻,驱动程序中都可能会有多个硬件线程。无法保证来电的先后顺序。

已同步并不意味着工作会被固定到单个 zircon 线程。运行时可以随意将驱动程序迁移到它管理的任何线程,前提是要保证同步操作。因此,在同步模式下使用线程本地存储 (TLS) 是不安全的。为了取而代之,驱动程序运行时可能会提供替代 API。将同步模式视为“虚拟”线程或光纤可能会有所帮助。

非同步模式可为驱动程序提供最大的灵活性。但以额外的工作量为代价,驱动程序可以采用更精细的锁定方案,从而实现更高程度的性能。选择启用此模式的驾驶员更有可能导致进程死锁,因此未来我们可能会对选择启用此模式的驾驶员施加限制。

这些模式将在运行时定义,并与前面所述的驱动程序运行时调度程序对象相关联。由于可以有多个调度程序,因此您也可以在单个驱动程序中混合搭配这些模式来管理并发要求。例如,可以创建多个同步的同步调度程序,以处理无需相互同步的两块硬件。在实践中,驱动程序应该使用单个调度程序,并选择利用特定于语言的构造来管理额外的并发和同步要求。这是因为与驱动程序运行时仅使用多个调度程序相比,特定于语言的构造(例如 C++ 中的 fpromise::promise 或 Rust 中的 std::future)可以做出更好的调度决策,因为可以更精细地跟踪依赖项。用 C 编写的驱动程序缺少任何类型的规范并发管理基元或库,因此是最有可能利用驱动程序运行时基元来管理并发的语言。

对驾驶员间通信的影响

由于上述基元未提供任何可供驱动程序调用其他驱动程序的机制,因此所有调用都必须由运行时进行中介。当写入驱动程序运行时通道时,我们可以改为在同一堆栈帧中选择调用另一个驱动程序,而不是使工作自动排队。运行时可以确定是否调用拥有通道另一端的驱动程序,只需检查另一端的调度程序设置为哪种线程模式即可。如果未同步,它将始终调用同一堆栈帧中的另一个驱动程序。如果调度程序已同步且并发,运行时可以尝试获取调度程序的锁,如果成功,则在同一堆栈帧中调用驱动程序。如果处理失败,它可以将工作加入队列以便稍后处理。

这看起来似乎是一项微不足道的优化,但初步的基准测试表明,避免返回异步循环的需要可以显著改善延迟时间和 CPU 利用率。

重入

在所述的所有模式中,驾驶员都无需处理重入。 运行时会协调驱动程序之间的所有交互,这一事实还让我们能够确定当前调用堆栈中是否已输入我们想要输入的驱动程序。如果它已调用驱动程序,而不是直接调用驱动程序,则运行时会将工作加入队列,并在它返回原始运行时循环后,由另一个 zircon 线程或同一线程进行处理。

色块

在前面讨论的所有模式中,都存在一项隐式要求,即参与共享线程的驱动程序不得阻塞。您可能会出于正当理由想要阻塞线程。为了支持这些用例,创建调度程序时,除了设置调度程序将使用的线程模式之外,驱动程序还可以指定在被调用时是否需要能够阻塞。这将使运行时能够确定其必须创建的最小线程数,以避免发生死锁风险。例如,如果驱动程序创建了 N 个调度程序,所有这些调度程序都可能会阻塞,然后运行时将需要分配至少 N+1 个线程来为各个调度程序提供服务。

最初也不支持在调度程序中指定块的功能,而该块也是未同步的,但根据有效的用例,我们可以重新评估这一决定。上述简单的 N+1 线程策略之所以不起作用,原因在于,可能有未知数量的线程进入该驱动程序,而且这些线程可能会全部阻塞。

工作优先级 / 会话配置文件

有些工作的优先级高于其他工作。虽然能够在工作级别分配优先级和继承优先级,但这并非易事。我们不会尝试在这一领域进行创新,不再局限于 zircon 内核当前所能提供的领域,而是将优先级作为调度程序级别的概念。可以在创建调度程序时指定配置文件。虽然运行时不一定保证所有回调都发生在配置为使用该配置文件运行的 zircon 线程上,但对于所提供的每个 zircon 线程配置文件,它至少会生成一个 zircon 线程。

就运行时如何以最佳方式处理线程配置文件方面,有很大的探索空间;要找到满足用例需求对线程配置文件有用的解决方案,可能需要与 zircon 调度器团队和驱动程序编写者进行一定程度的协作。

FIDL 绑定

以驱动程序运行时传输为目标的绑定的目标不仅仅是完全重新构想 FIDL,将能够做出尽可能少的面向用户的更改,从而实现驱动程序运行时提供的优势。预期情况是,当前以 zircon 通道(编写驱动程序也支持这种通道)的每个语言绑定都会派生出分支,以提供同样以驱动程序运行时传输为目标的变体。在初始实现中,系统将使用 LLCPP 绑定,因为 C++ 是用于编写驱动程序的主要语言,并且在未来很可能会一直是最受欢迎的语言。在对 FIDL 绑定做出具体说明时,本部分的其余内容将采用 LLCPP 绑定。

非协议类型应完整使用,不做任何修改。这一点非常重要,因为能够在多种传输之间共享生成的类型是属性驱动程序作者所期望的重要属性。

为协议生成的类和结构体(也称为消息传递层)一定会重新生成。我们可以对其进行模板处理,以便有条件地利用驱动程序运行时 API,但这需要我们针对每个协议同时生成对信道和驱动程序运行时传输的支持。建议的另一个方法是创建一个极小的类,将底层传输的通道读取和写入 API 抽象化。遗憾的是,这不适用于我们的设计,因为在基于通道的 FIDL 中,该领域是一个比较尴尬的概念,而且这两种传输方式之间读写 API 的缓冲区所有权关系不一致。与维护各种绑定的团队进行一定程度的协作可确定代码重用、差异和绑定的适当级别。

按请求结算的 arena

前面的部分所述,驱动程序运行时将提供一个 Arena 对象,该对象将随通过通道发送的消息一起传递。FIDL 绑定预计会利用此领域来实现零复制。

用户可选择不利用游戏区域创建发送给其他驾驶员的结构。没关系,绑定可确保将类型复制到由运行时提供的 arena 分配器支持的缓冲区。未指定 arena 的改进工效学设计必然会产生副本。在这里,我们唯一可以选择的设计选项是,让副本显得尤为明显,这样副本就不会无知地出现。

用户可能更喜欢在堆栈上分配其所有对象,并希望这些对象与调用本身完全在同一堆栈帧中使用,根据线程限制,在某些情况下可能会出现这种情况。事实上,驾驶人现在使用班卓琴就可以做到这一点。虽然在我们认为有必要将类型移到堆中时(通过类似于接收器中的 ToAsync() 调用的方式),通过延迟将类型移至由 arena 支持的方式支持这种做法,但我们会避免支持这一点。相反,应使用会导致消息被复制到支持竞技场的缓冲区(如上所述)的相同逻辑。如果出现强大的应用场景,我们以后可能会重新考虑这个想法。

消息验证

目前,当驱动程序相互通信时,我们不会通过执行枚举验证或 zircon 句柄验证等操作,来验证消息是否符合其所用接口指定的必要协定。在新的运行时中,我们或许可以开始执行部分验证,但我们需要确定针对每个验证功能执行哪项验证。例如,枚举验证可能开销很低,并且不太可能导致任何可衡量的性能损失。另一方面,如果我们需要对内核进行往返,则 zircon 通道验证的成本可能会很高,因此我们会考虑如何将其移除。此外,我们选择避免剥离权利渠道,因为这同样需要通过内核进行往返。我们做出这些决定的过程将需要对基准测试最终决定进行哪些验证,这取决于实现。

我们之所以做出这些选择,是因为同一驱动程序主机中的驱动程序具有相同的安全边界。这些验证步骤将仅用于提高我们的弹性。

请求转发

驱动程序是一种常见的操作,用于接收请求,进行一些最小的修改,然后将其转发给较低级别的驱动程序。能够成本低廉且符合人体工程学的方式实现这一目标是一个目标。此外,能够将消息转发到驾驶员同时拥有通道两端的通道可能是一项有用的 activity,因为驱动程序可能需要管理并发并将数据存放在某个位置。虽然它可能会将其推送到队列中,但将驱动程序运行时通道用作队列可能是一种用更少的代码实现同样效果的简单方法。

取消传输级别

当驾驶员由于新条件或要求而必须取消一些未完成的工作时,目前还没有统一的方法来实现这一点,无论是使用班卓琴还是基于 Zircon 通道的 FIDL 都无法做到这一点。在基于通道的 FIDL 中,许多绑定都可以让您忽略最终提供的回复。这可能是足够的,因为它允许取消分配与请求关联的状态,这可能是唯一的目标,具体取决于用例。不过,有时需要传播取消操作,以便下游驱动程序知道取消操作。发生这种情况时,方法是在协议层构建支持,或者直接关闭通道对的客户端端。后一种解决方案仅在需要取消所有未完成事务且不需要与服务器同步的情况下有效,因为您无法获得确认。

驱动程序运行时通道可以提供与 Zircon 通道传输提供的相同级别的支持。在传输层中引入对事务级取消传播的支持是一个很有吸引力的建议,但是由于 FIDL 和传输之间的职责分配颇具挑战性。传输不知道事务 ID,因为它们是基于通道基元构建的 FIDL 概念。此外,偏离 zircon 通道的做法可能也不值得,因为这可能会使需要同时处理这两种传输功能的开发者感到困惑。此外,这还增加了对多种传输进行抽象化处理的层的实现难度。

实现

此设计的实现主要分为三个阶段:

  1. 实现驱动程序主机运行时 API
  2. 实现基于驱动程序主机运行时 API 构建的 FIDL 绑定
  3. 从 banjo 迁移客户端

驱动程序主机运行时 API

运行时 API 将在新的驱动程序主机中实现,用于作为组件运行的驱动程序。我们将通过一个新的组件清单字段(名为 driver_runtime)与 colocate 字段一起指定,使用 API,而不是授权所有驱动程序使用它们。此属性可让驱动程序运行程序知道是否应向驱动程序提供 API。同一 drive_host 中的所有驱动程序的此属性都必须具有相同的值。

除了设计前面描述的基元之外,还需要添加相应支持,以建立一种可在绑定时在驱动程序之间传输新的驱动程序传输通道的机制。此外,还需要实现用于描述绑定规则和节点属性的习惯用法,以便驱动程序能够绑定到实现驱动程序传输 FIDL 服务的设备。

将在隔离的 devmgr 中添加支持,以便我们生成在新的运行程序中运行的驱动程序。此环境将用于实现基元、编写测试和运行性能基准。

运行时 API 的初始实现将侧重于 MVP 来巩固最终 API,以便我们可以开始处理 FIDL 绑定。准备就绪后,处理 FIDL 绑定的工作就可以并行进行,同时可以优化运行时 API 的实现。

FIDL 绑定

FIDL 绑定将在现有 fidlgen_llcpp 代码库内编写。输出驱动程序运行时头文件将受标志的控制。实现策略将遵循与上文类似的策略,侧重于从最少的更改开始,使绑定起作用,利用新的驱动程序运行时。实验正常运行后,我们将建立微基准以了解性能。然后,我们可以对绑定进行迭代,进行优化,例如移除我们认为不必要的验证步骤。

电线类型定义最终将作为共享标头的一部分添加,但在将其从现有 FIDL 标头中拆分为专用标头的工作完成之前,我们只需重新发出这些类型即可。这会导致任何尝试同时使用 zircon 通道和驱动程序运行时传输的驱动程序出现问题,但在此之前,我们希望完成了将导线定义拆分为共享标头的工作。

Migration

迁移尤其具有挑战性。尽管事实上几乎所有驱动程序都位于 fuchsia.git 代码库中。与其尝试将整个世界从 Benjo 迁移到新的运行时,一次迁移一个驱动程序主机会简单得多。该策略将涉及以下步骤:

  1. 需要克隆和修改要移植的驱动程序所使用的 Banjo 协议,才能创建针对驱动程序传输的版本。
  2. 要移植的驱动程序将同时支持 banjo 和驱动程序传输 FIDL 服务。
  3. 系统将为驱动程序创建新的组件清单、绑定程序和构建目标,表明它以新的运行时为目标。
  4. 对于驱动程序所在的每个开发板,在驱动程序主机所在的所有驱动程序均已移植后,包含驱动程序的板级 gni 文件就会更新,以使用新版驱动程序,而不是针对班卓琴的旧版驱动程序。
  5. 用于班卓琴的驱动程序 build 变体可以删除,就像驱动程序中使用或提供班卓琴协议的任何代码一样。
  6. 一旦任何驱动程序都不再使用某个班卓琴协议,它就会被删除。

对于包含在多个驱动程序主机中的驱动程序,但并非每个驱动程序都进行了移植,可能需要在开发板中同时包含这两个版本。绑定规则和组件运行程序字段可确保在适当的驱动程序主机中加载正确版本的驱动程序。驱动程序还可以轻松检测自己是否绑定到公开 banjo 服务的驱动程序,或驱动程序传输 FIDL 服务的驱动程序。

许多团队将参与迁移,因为一个团队无法自行迁移全部 300 多个紫红色驱动程序。您需要一份详细的迁移文档,该文档可以在几乎不需要协助的情况下完成大多数迁移。此外,在预期的团队执行迁移之前,我们需要确保提供适当的提前通知,以便他们做好迁移规划。

此外,我们还需要确定相关标准,以便整理要移植的驱动程序列表。例如,我们可能希望针对产品 build 中最少数量的重复驱动程序进行优化,同时并行处理尽可能多的移植驱动程序。因此,我们必须创建并主动管理流程,以确保我们不会出错,进而导致回归,并确保迁移及时完成。

评估共用位置

对于执行迁移的团队来说,重新评估其驱动程序是否需要与其他驱动程序一起工作也很重要。作为另一个工件,驱动程序框架团队将提供一个文档,帮助驱动程序作者进行此评估。团队可能需要进行一些基准化分析来指导这一决策流程,并提供支持让此类基准化分析易于执行,这也可能很有用。

性能

RFC 中前面提到的许多设计点都必须先接受基准化分析和衡量,然后我们才会正式承诺加以承诺。虽然我们已经完成了初步的基准测试,以帮助我们确定我们希望进入的驱动程序运行时的方向,但我们需要继续严格执行,以确保所述的所有优化都是有用的。

我们将建立微基准,以确保在传输层和 FIDL 绑定级别,性能优于 zircon 通道等效项。

此外,我们将需要构建更多面向 e2e 的基准,以确保我们在更全面的层面上不会牺牲任何我们想要优化的核心指标:

  • 总吞吐量高
  • 每秒 I/O 操作次数 (IOPS) 较高
  • 低延迟时间
  • CPU 利用率低

我们可能会利用最重要的用例确定尽早移植到新运行时的驱动程序,以便开始对结果进行基准化分析。一些驱动程序堆栈示例可能包括:

  • NVMe 固态硬盘
  • 以太网 NIC
  • 显示触控输入
  • USB 音频

确保我们获得丰富多样的驱动程序用例,将有助于我们确信没有针对任何单个用例过度优化。

这些基准测试将在驱动程序接口层执行,而不是从涉及所有层的更高级别接口执行,这些接口通常构成相关设备的技术堆栈。这是因为目前很难使用端到端基准来充分了解驱动程序性能,因为就性能而言,驱动程序通常不是瓶颈。我们是解决已知瓶颈的平台,但需要确保驱动程序不会成为新的瓶颈。

初步基准结果

我们进行了广泛的基准测试,以帮助我们确定此设计的方向。我们采用现有的堆栈(块和网络)并测试了以下场景:

  • 在所有班卓琴调用中插入了通道读写调用(不传递任何数据,没有线程跃点)。
  • 推迟了所有班卓琴调用,以便在共享调度程序循环(未插入线程跃点)上作为异步任务运行。
  • 推迟了所有班卓琴调用,以便在每个驱动程序异步调度程序循环上作为异步任务运行。

工作负载会针对这些经过修改的驱动程序堆栈运行,各种队列长度、操作大小、总工作负载大小以及读写操作。

基准测试在使用 x64-reduced-perf-variation board 的 NUC 上运行。可以预测的是,所有基准测试都降低了总吞吐量、尾延迟时间和 CPU 利用率。

  • 当队列长度为 1 和 2 时,这种差异尤为显著。
  • 每个驱动程序线程的总体结果更糟糕。
  • 将通道读取和写入操作插入 banjo 调用对吞吐量没有显著影响,但对尾延迟时间的影响最大。
  • CPU 利用率相对上升了 50%-150%,具体取决于所有试验中的参数。绝对 CPU 利用率一直非常重要 (10%-150%),因此相对增加也会导致绝对 CPU 利用率显著提高。

如需查看完整结果,请点击此处此处

工效学设计

前面“要求”部分所述,人体工学对整体设计非常重要。除了编写紫红色代码所需的概念之外,我们希望避免引入其他概念。驱动程序开发者已经需要了解和了解 FIDL,因为基于通道的 FIDL 是他们与非驱动程序组件交互的方式。因此,如果减少驾驶员间通信的差异与非驾驶组件的互动差异,将有助于减少新概念。Banjo 是一项完全不同的技术,虽然用更复杂的技术替代它,看起来似乎我们在减少人体工程学,但如此接近基于渠道的 FIDL 这一事实有望提高整体工效学设计。我们能够共享类型,这是开发者长期面临的一大痛点,也让我们对这种期望充满信心。

此外,引入线程模型有望大大简化编写正确驱动程序的难度,再次提高整体工效学设计。

人体工学设计可能最让人困扰的地方之一是,它支持 C 驱动程序。由于 fuchsia 平台树中已不再包含任何 C 驱动程序,因此我们无法充分了解此方案对 C 驱动程序的影响。实际上,初始实现甚至不支持 C 驱动程序,因为 FIDL 目前缺少适当的 C 绑定,即使对于 zircon 通道传输也是如此。此设计使用的许多设计选择都基于主要用于 C 的系统,因此,只要编写的 C FIDL 绑定符合工效学设计要求,C 驱动程序就不会受到不利影响。我们很长时间没有在这方面获得足够的反馈,这表明存在风险。

在迁移过程中,我们进行用户研究以了解预期结果是否已在现实中反映出来,这对您来说可能很有用。

向后兼容性

我们会先实现这些更改,然后再通过 Fuchsia SDK 导出任何班卓琴接口。因此,为了确保向后兼容性,我们不会施加任何限制。不过,由于我们需要逐段执行迁移,因此可能需要确保一定程度的向后兼容性,以让同一驱动程序同时支持 banjo 和驱动程序传输 FIDL。迁移部分对此进行了更详细的说明。

安全注意事项

此 RFC 中提出的设计不应改变系统的安全架构。同一进程中当前存在的驱动程序将继续这样做,因此安全边界不应发生变化。但是,作为迁移到新驱动程序运行时的预期结果,客户端将根据接口重新评估,其驱动程序是否与其父级位于同一进程中是否有意义。我们将编写相关文档,为进行此类评估的开发者提供指导。

隐私注意事项

此设计在隐私保护方面预计不会受到任何影响。

测试

设计的不同部分将通过不同的机制进行测试。驱动程序运行时将通过单元测试(大致基于等效 zircon 基元的类似测试)进行测试。此外,系统将利用隔离的 devmgr 和 CFv2 测试框架编写集成测试。

系统将通过 GIDL 以及确保正确性的集成测试来测试驱动程序传输 FIDL 绑定。集成测试必须基于每个绑定编写,并且模拟该绑定的 zircon 通道传输变体已有的集成测试将是最有可能采用的路径。这些集成测试可能还需要利用隔离的 devmgr 和 CFv2 测试框架。

要迁移到新的驱动程序运行时的驱动程序可能需要针对每个驱动程序设计测试计划。基于 devmgr 的隔离测试应该支持新的传输,而无需任何特殊操作。对于单元测试,可能需要编写测试库来提供驱动程序运行时 API 的实现,而无需在驱动程序主机中运行测试。可以使用与当前在单元测试中模拟 libdriver API 的方法类似的方法。系统将考虑在测试库与 API 的驱动程序主机实现之间共享代码,以减少功能偏移。

文档

我们将需要推出新的指南来介绍如何编写使用驱动程序传输 FIDL 的驱动程序,以及如何创建使用 FIDL 的驱动程序。需要针对每个绑定编写它们,但由于我们最初仅以 llcpp 为目标,因此只需要编写一个集。理想情况下,我们可以调整现有的 llcpp 指南,以减少制作这些指南所需的总工作量。

驱动程序的参考部分也需要更新,以包含有关运行时 API 的相关信息。此外,您还需要了解有关线程模型的信息以及如何编写线程安全代码。

有必要编写一份最佳实践文档,帮助驱动程序编写人员确定是使用 FIDL 的驱动程序传输版本还是 zircon 通道传输版本。

最后,如“迁移”部分所述,您需要提供有关如何执行从 banjo 到驱动程序传输 FIDL 的迁移的文档。

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

线性化

为了实现零复制,我们可以避免 FIDL 目前执行的线性化步骤。严重的是,这样做会违反结构布局方面的现有 FIDL 协定。通过避免线性化,这也意味着我们不会将解码形式转换为编码形式。

FIDL 这样做的原因之一就是它允许解码更简单。特别是,确保将所有字节都包括在内非常简单,只需进行一次传递即可,并执行边界检查以确保引用的所有数据都位于消息缓冲区内。由于“安全和弹性”部分讨论的安全问题,因此我们认为没有必要担心之前的顾虑。对于后者,我们只需确保所有数据缓冲区都指向 arena 分配的内存。驱动程序运行时将提供一个可用于执行此检查的 API。

线性化的广为人知的好处之一是能够对周围的缓冲区进行 memcpy 处理。由于消息已由 arena 提供支持,因此转移消息的所有权就像转移 arena 的所有权一样简单。无法复制消息不一定是坏事,会导致任何明显的后果。

线性化的另一个好处是可以简化解码。实际上,如果不执行数据复制,解码消息所需的额外复杂性会胜过胜利。需要对此进行仔细衡量,以确保跳过线性化处理是显而易见的胜利。

最后,线性化可以通过获得更好的空间局部性来提高缓存性能。如果 Area 得到了良好实现(作为 bump 分配器),则空间局部性仍然应该良好,并且通过调用同一堆栈帧内的其他驱动程序进行时间局部改进应该在此方面造成任何损失,而这不太可能导致问题。

虽然此 RFC 并未建议跳过线性化作为记录计划,但计划在后续工作中实现这样做所需的特征集,以便我们可以评估它是否值得实现。

漏光手柄

跳过线性化操作会遇到一个问题:当 FIDL 消息的发送方使用接收器未知的联合或表中的字段(可能使用了较新版本的 FIDL 库)时,我们可能会泄露句柄。在 zircon 通道传输以及所提议的驱动程序运行时传输中,接收器可以安全地忽略新字段,并以其他方式理解消息内容的其余部分。不过,在 zircon 通道变体中,句柄与数据是分开存储的,这意味着如果句柄位于未知字段中,接收器会始终知道句柄的存在,并可以关闭未使用的句柄。如果我们完全跳过编码步骤,无论了解完整的消息结构,我们都将无法了解所有句柄。

一种解决方案是对部分进行编码和解码,而不是进行线性化。更具体地说,在编码期间,手柄会被剥离并以偏移量替换为线性手柄表,而在接收时,手柄会在解码期间移回对象中。与基于 zircon 通道的方法类似,系统可以跟踪未使用的表条目,并在解码期间将其关闭。这种半途方法应该可以充分利用跳过线性化步骤的大部分性能优势,同时避免潜在的陷阱。

有线格式迁移

另一个问题是,在 FIDL 传输格式迁移期间,两个对等驱动程序可能正在使用不兼容布局的对象。例如,我们目前将 FIDL 信封的大小减半。目前,通过在 IPC 之间添加一个转换器,可以在两种编码有线格式之间进行转换,从而解决了这个问题。虽然我们可能会为非线性化解码格式编写额外的转换器,但会导致额外的维护负担。

非 LLCPP 绑定

在撰写本文时,只有一个 FIDL 绑定实现 (LLPP) 才能利用通过跳过线性化步骤提供的优化,并处理可能从非线性化状态进行解码,因为它是唯一能够原生理解传输格式的绑定。对于其他绑定,在接收端,可以先执行额外的步骤来对缓冲区进行线性化,然后再继续执行正常的绑定专用解码。此外,在发送端,您仍然可以执行线性化,绑定只需要小心,避免将指针转换为偏移。

透明传输

本文档中介绍的设计让驱动程序开发者能够自行决定是否针对特定协议使用驱动程序传输版本的 FIDL 的 zircon 通道传输。这种设计的初始版本选择向驾驶员隐藏底层传输,以改进整体驾驶员工效学,减少需要学习的概念数量,并使平台能够更好地控制驾驶员是否位于同一位置。经过多次争论后,他们决定放弃这一想法,因为它极大地使设计复杂化,并引入了许多需要解决的新问题。例如,对消息进行线性化的需求将成为动态决策,而不是编译时已知的决策。

如果我们决定继续尝试使底层传输对驱动程序透明,则需要编写跟进 RFC。

Operations

如果我们在传输层引入一个概念来跟踪逻辑操作在堆栈中的各个驱动程序之间导航,我们或许能够提供更好的诊断,并且或许能够做出更好的调度决策。如本设计所述,与基于 Zircon 通道的 FIDL 类似,交易目前是协议层概念。此方案的早期版本考虑过使分配器具有单一所有权,使我们能够用它们作为跟踪驱动程序之间的操作的替代,但使用分配器可能存在缺陷,而当前设计现在提议采用重新计数的分配器设计。重新审视传输中的一流运算概念是一项我们可以在未来考虑的重大举措。

单个复制传输

如果我们确定实现零复制并不重要,则可以通过许多选项来探索替代设计。我们可以选择移除该区域,而是需要与 Windows IOCPio_uring 甚至 NVMe 设计类似的预注册完成缓冲区或环。当请求完成后,我们会将结果写入预注册缓冲区/环中,这可能是线性化步骤的一部分。这种设计很适合内置背压。另一个选择是保留 Area,但始终执行线性化。如果此 RFC 的审核人员认为有用,可以进一步权衡这些方法的优缺点。

传输背压

锆石通道的一个众所周知问题是它们没有任何背压配置。在写入数据时,存在一个全局通道消息限制,如果达到该限制,会导致在接收端拥有该通道端的进程被终止。

由于我们要设计一个新的频道基元,因此在这里我们可以做出更好的选择。鉴于此方案的覆盖范围很广,我们认为我们应该忽略它。不过,这可能是一个值得探索的领域,因为 FIDL 团队正在积极研究此问题。在驱动程序运行时中试用新方法可能是一种好方法,有助于了解所采用的方法是否也值得在 zircon 系统调用接口中实现,该接口的风险更高。

双向渠道和事件

有人认为锆石通道的双向性是错误的。我们或许可以选择不将驱动程序运行时通道设为双向,而是需要两个通道对(4 端)才能进行双向通信,类似于内置的 golang 通道。因此,我们将不支持通过驱动程序运行时传输来支持 fidl 事件。不是在驱动程序运行时传输支持的受支持 FIDL 功能方面发生偏移,而是有可能尽可能更好地匹配功能集。除了减少用户混淆之外,将来如果我们决定朝着相应方向前进,这还可以让实现不透明传输变得更轻松。

递归锁定

如果不支持自动排队工作,会导致重新进入驱动程序,而在这种情况下,我们或许可以仅在驾驶员选择启用后允许此类工作。为了正确处理此问题,驱动程序必须执行以下两项操作之一:

  1. 将工作加入队列,并安排稍后接受服务的队列。
  2. 使用递归锁定。

与 RFC 中呈现的设计相比,第一个选项似乎没有什么优势。第二种方案需要使用我们最初选择的锁,以避免在我们的平台中实现对它的支持。原因在于,递归锁定使它颇具挑战性,即使无法确保获取锁的顺序的正确性也很难。如果以多个不同的顺序获取锁,则代码中潜藏着潜在的死锁。为了确保我们绝不会反复进入驾驶员,让问题迎刃而解会更为简单。

Rust 驱动程序支持

我们不打算在此提案中支持 Rust 驱动程序,但预计在不久的将来可能会启用它。紫红的驱动程序作者非常希望用 Rust 编写自己的驱动程序,而 banjo 使驱动程序框架团队很难完全支持 Rust 支持。确保我们能够轻松支持以 Rust 编写的驱动程序是一项设计考虑因素,但不一定是驱动因素。

应该可以实现此 RFC 前面所述的驱动程序传输的 Rust FIDL 绑定。此类绑定的具体设计和实现目前尚未公开,并且可能会成为未来 RFC 的主题。

工作优先级

在未来的某个时间点,您可以探索如何在工作或消息级别(而不是调度程序级别)继承优先级。虽然 zircon 内核团队中有一些微不足道的计划可以探索这一理念,但驱动程序运行时可以非常灵活地在此处进行实验,并在内核之外进行更多创新。完成运行时的初始实现并完成容易实现的优化后,这可能是一个重要的关注领域。

电源管理频道

在其他驱动程序框架中,当较低级别的驱动程序处于挂起状态时,自动停止服务工作是一项有用的操作。此 RFC 中介绍的设计并没有任何功能来实现这一点,但为了改进驱动程序作者的人体工学设计,这是一个探索领域。关于驱动程序框架在电源管理方面的参与程度,目前有许多待解决的问题。

早期技术和参考资料

进程中线程模型的概念都不是新概念,也不是关于如何高效且符合人体工程学处理并发的构想。此处采用的方法从以下来源(以及更多来源)汲取了很多灵感: