RFC-0126:驱动程序运行时

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

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

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

摘要

此 RFC 确立了进程内共置的驱动程序相互通信的设计。驱动程序将通过进程内运行时进行通信,该运行时以 Zircon 内核为模型。运行时将提供类似于 Zircon 渠道和端口的原语,并且将在该运行时之上构建新的 FIDL 传输,从而实现比通过 Zircon 渠道和端口所能实现的更好的性能。这一新的运行时和 FIDL 传输将取代驱动程序目前使用的现有 banjo 运行时。

不在同一进程中共同定位的驱动程序之间的通信将继续使用基于 Zircon 通道的 FIDL。使底层传输对驱动程序透明不在本提案的范围内,将在未来的提案中讨论。

该 RFC 还为线程模型驱动程序建立了一套规则,以便它们能够高效地相互共享线程。将能够在同一线程上处理来自进程内驱动程序和进程外驱动程序的消息。

设计初衷

现有的驱动程序运行时解决了创建时的问题要求。当时还没有 FIDL,驱动程序几乎全部用 C 语言编写,Zircon 内核也尚未得到很好的优化。因此,驱动程序之间用于相互通信的接口最初是一个函数表,其中包含一个关联的类型擦除上下文指针。在 C++ 驱动程序越来越普遍的时期,FIDL 已经创建,但由于绑定实现被认为开销过高,因此不适合用于驱动程序间通信。为了改进 C++ 驱动程序的工程学,当时正在围绕 C 协议定义手动生成封装容器。为了减少维护 C 协议定义和 C++ 封装容器的繁琐工作,我们创建了一种新的 IDL,其语法在很大程度上与当时的 FIDL 相似,可以从共同的可靠来源自动生成 C 和 C++ 代码。后来,这种乐器被称为“班卓琴”。虽然 banjo 最初确实获得了一些投资,但很快就进入了维护模式,与在类似时间段内蓬勃发展的 FIDL 相比,多年来几乎没有改进。Banjo 已不再满足当前的要求,因此有必要重新构想驱动程序运行时。

此设计旨在一次性解决多个不同的问题。

稳定的 ABI

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

线程安全

如今,在编写驱动程序时,如果不需在驱动程序中生成专用线程来处理请求,则很难实现 banjo 协议。这是因为,当驱动程序调用协议中的方法时,没有必须遵循的规则。处理来电时,您需要确保您的逻辑:

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

处理第一个问题的显而易见的方法是获取锁。不过,仅仅获取锁很容易导致死锁,因为驱动程序可能会回调到另一个驱动程序中,并且在同一堆栈帧中,它可能会回调到尝试重新获取同一锁中。目前驱动程序中使用的所有锁实现都不是可重入安全的,并且我们不希望开始使用递归锁,原因在替代方案部分中进行了更深入的探讨。

因此,目前正确处理此问题的唯一方法是将工作推送到队列中,并在稍后处理该队列。由于当前的驱动程序运行时不提供任何机制来安排稍后执行的工作,因此驱动程序必须实例化自己的线程来处理此队列。

这会带来问题,因为这会破坏在同一进程中放置驱动程序所带来的大部分预期性能优势。此外,在驱动程序实现复杂性方面,还有很大的税收。系统上的许多驱动程序不会选择将工作推送到队列中,因此很可能只需一个新客户端,就会以不可避免地导致系统死锁的方式使用。我们驱动程序中潜伏着此类 bug 的历史悠久,但由于重入仅在极少发生的错误条件下发生,因此这些 bug 仅以不稳定的方式显现出来。

样板序列化

促使我们开发新运行时的另一个问题是,与非司机之间的通信缺乏一致性。驱动程序通常会实现一个 FIDL 服务(向非驱动程序组件公开),并通过将请求(通常经过少量转换)转发给另一个驱动程序来处理请求。此过程需要大量样板代码,因为尽管类型(结构、枚举等)甚至协议的定义可能在 Zircon 渠道传输 FIDL 和 Banjo 传输 FIDL 之间共享,但生成的类型完全不一致。因此,驱动程序作者必须实现逻辑,以从一组生成的类型序列化到另一组生成的类型。

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

利益相关方

Facilitator: abarth@google.com

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

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

社会化:此 RFC 的草稿已发送到 FEC 讨论邮件列表以征求意见。此设计中包含的概念的早期形式曾在驱动程序框架、FIDL 和 Zircon 团队中流传。

设计

更具体地说,该设计将通过一组新的原语和一个利用这些原语的新 FIDL 传输来实现。在详细介绍设计之前,我们先来了解一下用于得出此设计的一些额外要求。

要求

性能

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

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

例如,我们希望最大限度地提高 NVMe SSD 的吞吐量,或者确保在输入事件发生和软件收到事件通知之间产生最小的延迟。虽然在一定程度上,这些任务需要系统与驱动程序框架协同工作才能达到所需的性能水平,但我们希望确保驱动程序运行时不会成为瓶颈。

大部分设计都侧重于我们可以采用的优化措施,以提高驱动程序间通信的性能,使其超出基于 Zircon 通道的 FIDL 所能实现的性能。如果我们无法在上述所有指标上胜过基于 zircon 通道的 FIDL,那么它可能无法替代当前的 banjo 运行时。有关需要胜过基于 Zircon 通道的 FIDL 的讨论,请参阅后面的部分

驱动程序作者工效学设计

新驱动程序运行时的另一个高级别目标是确保驱动程序易于编写。如果不考虑驱动程序作者会受到设计决策的哪些影响,就无法最大限度地提高性能,也无法获得有用的结果。提供可供作者在人体工程学或零成本开销之间进行选择的选项,对于实现我们的目标至关重要。此外,能够将从简单实现编写的驱动程序发展为性能更高的驱动程序,而无需重写,也是理想的。

安全和弹性

位于同一进程中的驱动程序共享一个安全边界。这是因为,尽管我们尽力隔离驱动程序,但实际上无法阻止它们访问共享进程中属于其他驱动程序的内存区域。这是有意为之,因为共享地址空间是同位放置的主要优势之一,我们可以利用它来提升性能,使其超出渠道可能提供的性能。因此,我们需要假设,任何合理配备的恶意实体一旦获得驱动程序主机中一个驱动程序的控制权,就能够访问同一进程中所有驱动程序的所有功能。因此,我们无需考虑在驱动程序运行时层中实现安全检查,以提供额外的安全优势。

不过,这并不意味着我们不应继续采用通常用于提供安全保证的许多相同机制,因为这些机制可以提高各个驱动程序的恢复能力。例如,我们不会盲目假设指针指向缓冲区内的数据,而是仍然可以验证是否确实如此。这有助于限制 bug 造成的损害,从而帮助找出问题的根本原因。换句话说,防范错误仍然很有意义。其余设计应考虑到这一点。

零复制选项

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

与基于渠道的 FIDL 类似,在驱动程序之间传递的大多数数据都应该是控制数据结构,而数据平面数据则存储在 VMO 中。这样一来,我们就可以减少最昂贵的副本。不过,控制数据结构的大小可能会累积,在某些情况下(例如网络数据包批量请求)达到几千字节。此外,在多个驱动程序堆栈中,数据基本上保持不变,并会经过多个层。我们可以省略所有这些副本。事实上,在当前基于 banjo 的运行时中,我们尽力实现零复制,但实现零复制的方式在很大程度上存在问题,并且容易出现 bug。这是阻止我们采用 Rust 驱动程序开发的众多因素之一。

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

从性能角度来看,这些副本可能不会导致当今系统出现明显的瓶颈。不过,它们可能会对 CPU 利用率产生可衡量的影响,因此,提供实现零复制的能力被认为是有用的。

推送与拉取

Zircon 渠道基于拉取机制运行。用户空间在通道上注册信号,以了解何时有数据要读取,并在收到通知后从通道读取数据到其提供的缓冲区中。FIDL 绑定通常会反转此机制,使其基于推送。注册回调,并在准备好相应内容时触发回调。

我们有很大的灵活性,可以转向完全基于推送的机制,也可以继续模拟 Zircon 通道机制。对于普通用户而言,推送在人体工程学方面要好得多,并且可能更高效,因为拉取模型在收到信号后需要重新进入运行时。在选择更高效的其他系统(例如 Windows IOCPLinux io_uring)中,缓冲区通常会预先注册,以通过移除额外的内核条目以及副本,实现更高的性能。对于进程内运行时,进入运行时的开销很小,实际上无需预注册缓冲区即可实现零复制,因此在我们的 API 中引入与 Zircon 渠道的差异不一定能带来好处。此外,Rust 语言在很大程度上依赖于基于拉取的机制,如果我们选择在传输级别上放弃拉取,则可能会导致其模型出现阻抗不匹配。

基元

如前所述,新设计需要新的原语来构建新的 FIDL 传输。这些原语本身在很大程度上是根据 zircon 内核提供的原语建模的。

基元的 API/ABI 将基于 C,类似于现有的 libdriver API。我们将提供每种语言的封装容器,以便更自然地使用。虽然可以通过 FIDL 定义 API(类似于在 FIDL 中定义 zircon 系统调用接口的方式),但由于整个 API 集应保持相当小的规模,因此可能不值得这样做。

场地

第一个原语是竞技场。为了减少副本数量,我们需要一种方法来确保与请求相关联的数据的生命周期至少与请求的未完成时间一样长。提供给传输的数据必然会由缓冲区提供支持。在基于渠道的 FIDL 绑定中,入站消息数据通常从堆栈开始,如果需要异步回复请求,则可能会按需移入堆分配。FIDL 绑定可以直接读取到堆内存中,但在撰写本文时,这种情况不会发生。如果改为使用运行时提供的 arena 来支持数据,我们可以将请求的生命周期与 arena 相关联。我们还可以针对竞技场启用额外的分配,并保证这些分配的生命周期与请求的生命周期一样长。在处理请求时,通常会将其转发给另一个驱动程序,或向下游驱动程序发出请求;在这些情况下,可以重新利用同一内存分配区,并将其与新请求一起传递。通过这种方案,操作可能可以遍历许多驱动程序,而无需进行全局分配。在块堆栈等驱动程序堆栈中,通常会在同一驱动程序主机中导航 6 个或更多驱动程序,主要是反复将同一请求转发到较低级别的驱动程序,对数据进行极少的修改。

运行时将提供用于创建 arena、递减对 arena 的引用、执行分配以及检查所提供的内存区域是否由 arena 支持的 API。最后一个 API 比较特殊,但对于 FIDL 传输实现中的稳健性而言是必需的。以后可能会添加一个释放单个分配的 API,但初始实现会跳过它。只有在销毁 arena 本身时(即移除对 arena 的所有引用时),才会释放分配。

运行时可以根据需要自由优化竞技场。例如,基本实现可以选择简单地将所有分配请求转发到全局 malloc,并等待 arena 被销毁,然后针对发生的每次分配发出 free。或者,它也可以实现为单个分配 bump 竞技场,其最大大小会根据每个请求所需的最大分配量进行调整。随着时间的推移,我们可能会扩展 arena 接口,以允许客户端提供有关底层 arena 应使用的策略的提示,因为相同的 arena 策略可能并非在所有驱动程序堆栈中都是最佳的。

渠道

第二个基元是渠道基元。它将具有与 zircon 渠道几乎相同的接口。主要区别包括:

  • 写入 API 将接收一个 arena 对象,并递增其引用计数。
  • 写入 API 将要求传递给它的所有缓冲区(数据 + 句柄表)都由 arena 提供支持。相应地,运行时将取得这些缓冲区(以及由 arena 支持的所有其他缓冲区)的所有权。
  • 读取 API 将提供一个竞技场,调用者将拥有该引用的所有权。
  • 读取 API 将提供数据缓冲区和句柄表,而不是期望调用方提供要复制到的缓冲区。

这些差异是必要的,以便基于 FIDL 绑定构建的组件实现零复制。

Zircon 通道之间的相似之处包括:

  • 渠道是成对创建的。
  • 渠道不得重复,因此是双向单生产者单消费者队列。

调度程序

最后一个原语类似于 Zircon 端口对象。它不会提供用于阻塞当前线程的机制,而是提供一种机制,通过回调来指示哪个已注册的通道当前可读或已关闭。还将提供用于注册驱动程序运行时渠道的机制。

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

此外,还将有一个 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 对象,该对象将与通过渠道发送的消息一起传递。FIDL 绑定应利用此 arena 实现零复制。

用户可以选择不使用竞技场来创建发送给其他驾驶员的结构。这没问题,绑定可以确保将类型复制到由运行时提供的 arena 分配器支持的缓冲区中。不指定 arena 的改进的人体工程学设计必然会导致复制。我们在此处可能选择的唯一设计是让复制操作非常明显,这样就不会因无知而发生复制。

用户可能更倾向于在堆栈上分配所有对象,希望这些对象完全在与调用本身相同的堆栈帧中使用,这在某些情况下可能会实现,具体取决于线程限制。事实上,司机现在就可以使用 Banjo 完成这项任务。虽然我们可以通过以下方式来支持此功能:当我们认为有必要将类型移入堆中时(通过接收器中类似于 ToAsync() 调用的方式),延迟将类型移入由 arena 支持的内存中,但我们仍会避免支持此功能。相反,应使用与上述将消息复制到 arena 支持的缓冲区相同的逻辑。如果出现强有力的使用情形,我们可能会在日后重新考虑这个想法。

消息验证

目前,当驱动程序彼此通信时,我们不会采取任何措施来验证消息是否满足其所用接口指定的所需合约,例如执行枚举验证或 Zircon 句柄验证。在新运行时中,我们可能可以开始执行部分此类验证,但我们需要确定针对每个验证功能执行哪些验证。例如,枚举验证的开销可能很小,不太可能导致任何可衡量的性能损失。另一方面,如果我们需要通过内核进行往返,zircon 通道验证可能会非常耗时,因此我们会考虑如何移除它。此外,我们还会尽量避免剥夺通道的权限,因为这同样需要通过内核进行往返。我们将通过以下流程做出这些决定:对最终决定进行基准比较,以确定我们选择执行哪些验证,这留给实现来完成。

之所以能做出这些选择,是因为同一驱动程序主机中的驱动程序共享相同的安全边界。这些验证步骤仅用于提高我们的恢复能力。

请求转发

驱动程序通常会接收请求、进行一些最小限度的修改,然后将其转发给较低级别的驱动程序。以低成本且符合人体工程学的方式实现这一目标。此外,能够将消息转发到驱动程序拥有通道两端的通道可能是一项有用的活动,因为驱动程序可能需要管理并发并暂存数据。虽然它可能会将该任务推送到队列中,但利用驱动程序运行时通道作为队列可能是一种更简单的方法,只需更少的代码即可实现相同的效果。

传输级取消

当驱动程序必须因新条件或新要求而取消某些未完成的工作时,无论是基于 banjo 的 FIDL 还是基于 zircon 通道的 FIDL,目前都没有统一的方法来实现这一点。在基于渠道的 FIDL 中,许多绑定会让您忽略最终会收到的回复。根据用例,这可能就足够了,因为它可以释放与请求关联的状态,而这可能是唯一的目标。不过,有时需要传播取消信息,以便下游驱动程序了解取消情况。在这种情况下,方法是在协议层构建支持,或者只是关闭通道对的客户端。后一种解决方案仅在需要取消所有未完成的交易且无需与服务器同步时才有效,因为您无法获得确认。

驱动程序运行时通道可以提供与 zircon 通道传输相同的支持级别。在传输层中构建对事务级取消传播的支持是一个诱人的想法,但由于 FIDL 和传输之间的职责划分,实现起来相当具有挑战性。传输不知道交易 ID,因为它们是基于通道原语构建的 FIDL 概念。此外,偏离 Zircon 通道的工作方式可能并不值得,因为这可能会让需要同时处理这两种传输方式的开发者感到困惑。此外,它还会使实现可抽象出多个传输的层变得更加困难。

实现

此设计的实现将主要分三个阶段进行:

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

驱动程序主机运行时 API

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

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

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

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

FIDL 绑定

FIDL 绑定将写入现有 fidlgen_llcpp 代码库中。驱动程序运行时标头的输出将通过标志进行门控。实现策略将遵循与上述类似的策略,重点在于从最少的一组更改开始,以使绑定正常运行,并利用新的驱动程序运行时。一旦该功能正常运行,我们将建立微基准来了解性能。然后,我们可以迭代绑定,进行优化,例如移除我们认为不必要的验证步骤。

线类型定义最终将作为共享标头的一部分添加,但在完成将这些类型从现有 FIDL 标头拆分到专用标头的工作之前,我们将仅重新发出这些类型。这会给尝试同时使用 Zircon 渠道和驱动程序运行时传输的任何驱动程序带来问题,但我们希望届时已完成将有线定义拆分为共享标头的工作。

Migration

尤其是迁移,将面临相当大的挑战。尽管几乎所有驱动程序都位于 fuchsia.git 代码库中,但仍存在此问题。与其尝试一次性将整个世界从 banjo 迁移到新的运行时,不如一次迁移一个驱动程序主机。该策略将包含以下步骤:

  1. 被移植的驱动程序所使用的 Banjo 协议需要进行克隆和修改,以创建面向驱动程序传输的版本。
  2. 正在移植的驱动程序将实现对 banjo 和驱动程序传输 FIDL 服务的支持。
  3. 系统将为驱动程序创建新的组件清单、绑定程序和 build 目标,以表明该驱动程序以新的运行时为目标。
  4. 对于驱动程序所在的每个主板,一旦驱动程序所处的驱动程序宿主中的所有驱动程序都已移植,包含该驱动程序的主板 gni 文件就会更新为使用新版本的驱动程序,而不是以 banjo 为目标的旧版本驱动程序。
  5. 可以删除用于 banjo 的驱动程序 build 变体,以及驱动程序中使用或提供 banjo 协议的任何代码。
  6. 如果没有任何驱动程序再使用 banjo 协议,则可以将其删除。

对于包含在多个驱动程序宿主中的驱动程序(并非每个驱动程序都已移植),可能需要同时在主板中包含这两个版本。绑定规则和组件运行程序字段将确保在相应的驱动程序宿主中加载正确的驱动程序版本。驱动程序还将能够轻松检测到它们是否绑定到公开 banjo 服务或驱动程序传输 FIDL 服务的驱动程序。

许多团队将参与迁移,因为单个团队无法自行迁移所有 300 多个 Fuchsia 驱动程序。您需要一份详细的迁移文档,以便在极少帮助的情况下完成大多数迁移。 此外,在预期团队执行迁移之前,我们需要确保提前充分通知他们,以便他们做好规划。

我们还需要确定有助于整理所移植驱动程序列表的标准。例如,我们可能希望优化产品 build 中的重复驱动程序数量,同时尽可能并行化更多移植的驱动程序。我们必然会创建并积极管理一个流程,以确保我们不会犯下导致回归的错误,并确保迁移及时完成。

评估主机托管

对于执行迁移的团队来说,重新评估其驱动程序是否需要与其他驱动程序一起在进程中运行也很重要。作为另一项制品,驱动程序框架团队将提供一份文档,帮助驱动程序作者进行此评估。团队可能需要进行一些基准比较,以帮助指导此决策流程,并且构建支持来帮助轻松执行此类基准比较也可能很有用。

性能

在真正确定 RFC 中之前指定的许多设计点之前,我们必然需要对其进行基准测试和衡量。虽然我们已经进行了初步基准比较,以帮助确定驱动程序运行时要朝着哪个方向发展,但仍需继续保持严谨,以确保所有概述的优化都有效。

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

此外,我们需要构建更多面向端到端的基准,以确保在更全面的层面上,我们不会在任何正在优化的核心指标上做出妥协:

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

我们可能会利用最重要的应用场景来确定驱动程序,以便更早地将这些驱动程序移植到新运行时,从而开始对结果进行基准比较。 一些驱动程序堆栈示例可能包括:

  • NVMe SSD
  • 以太网 NIC
  • 显示触控输入
  • USB 音频

确保我们获得各种各样的驾驶员使用情形,有助于我们确信自己不会过度优化任何单一使用情形。

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

初步基准测试结果

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

  • 将插入的渠道写入和读取调用添加到所有 banjo 调用中(不传递任何数据,没有线程跳跃)。
  • 将所有 banjo 调用延迟为在共享调度程序循环上作为异步任务运行(未插入线程跳跃)。
  • 将所有 banjo 调用延迟到每个驱动程序的异步调度器循环中作为异步任务运行。

针对这些修改后的驱动程序堆栈运行的工作负载具有不同的队列长度、操作大小、总工作负载大小以及读写比。

基准测试是在使用 x64-reduced-perf-variation 板的 NUC 上运行的。可以预见的是,所有基准测试都降低了总体吞吐量,增加了尾部延迟时间,并提高了 CPU 利用率。

  • 当队列长度为 1 和 2 时,差异尤为显著。
  • 每个驱动程序线程的总体效果较差。
  • 将渠道读取和写入插入到 banjo 调用中对吞吐量没有显著影响,但对尾部延迟的影响最大。
  • 在所有试用实验中,CPU 利用率相对增加了 50%-150%(具体取决于参数)。CPU 利用率绝对值始终不小(10%-150%),因此相对增加也会导致 CPU 利用率绝对值显著增加。

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

工效学设计

正如“要求”下的前一部分中所述,人体工程学对整体设计非常重要。我们希望避免引入编写 Fuchsia 代码所需的概念之外的其他概念。驱动程序作者已经需要了解并理解 FIDL,因为基于渠道的 FIDL 是他们与非驱动程序组件交互的方式。因此,减少驱动程序间通信与非驱动程序组件交互的差异,将有助于减少新概念。Banjo 是一种完全不同的技术,虽然用总体上更复杂的技术来替换它似乎会降低人体工程学,但它与基于渠道的 FIDL 非常接近,因此有望提高总体人体工程学。我们将能够共享类型,这长期以来一直是开发者的痛点,这也增强了我们对这一预期的信心。

此外,引入线程模型有望大大简化编写正确驱动程序的难度,从而再次提高总体人体工程学水平。

人体工程学方面最令人头疼的问题之一是 C 类驾照的支持。由于 Fuchsia 平台在树中不再有任何 C 驱动程序,因此我们无法很好地了解此提案对 C 驱动程序的影响。事实上,初始实现甚至不支持 C 驱动程序,因为 FIDL 目前缺少适当的 C 绑定,即使是针对 Zircon 渠道传输也是如此。此设计所采用的许多设计选择都基于主要用于 C 的系统,因此很有可能,只要编写的 C FIDL 绑定使用起来符合人体工程学,C 驱动程序就不会受到影响。我们暂时无法获得这方面的充分反馈,这确实意味着存在风险。

在迁移过程中,我们可能会进行用户研究,以了解预期结果是否反映在现实中。

向后兼容性

我们会在通过 Fuchsia SDK 导出任何 banjo 接口之前实现这些更改。因此,我们没有施加任何限制来确保向后兼容性。不过,由于我们需要执行分段迁移,因此可能需要确保一定程度的向后兼容性,即在短时间内,同一驱动程序中同时支持 banjo 和驱动程序传输 FIDL。有关详情,请参阅迁移部分

安全注意事项

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

隐私注意事项

此设计预计不会对隐私造成影响。

测试

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

驱动程序传输 FIDL 绑定将通过 GIDL 以及集成测试进行测试,以确保正确性。集成测试必然需要按绑定来编写,并且最有可能采用的方式是模仿已针对相应绑定的 zircon 通道传输变体编写的集成测试。这些集成测试可能还需要利用隔离的 devmgr 和 CFv2 测试框架。

迁移到新驱动程序运行时环境的驱动程序可能需要根据每个驱动程序制定测试计划。基于 devmgr 的隔离测试应支持新传输,而无需任何特殊努力。对于单元测试,可能需要编写一个测试库来提供驱动程序运行时 API 的实现,而无需在驱动程序主机中运行测试。一种类似的方法可用于在单元测试中模拟 libdriver API。将考虑在测试库和 API 的驱动程序宿主实现之间共享代码,以减少功能方面的偏差。

文档

我们需要编写新的指南来介绍如何编写使用驱动程序传输 FIDL 的驱动程序,以及如何创建提供该 FIDL 的驱动程序。它们需要按绑定进行编写,但由于我们最初仅面向 llcpp,因此只需编写一组。理想情况下,我们可以调整现有的 llcpp 指南,以减少制作指南所需的总体工作量。

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

我们需要一份最佳实践文档,帮助驱动程序作者确定是使用驱动程序传输还是 Zircon 通道传输版本的 FIDL。

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

缺点、替代方案和未知因素

线性化

为了实现零复制,我们可以避免 FIDL 目前执行的线性化步骤。从结构布局的角度来看,这样做会严重违反现有的 FIDL 合约。通过避免线性化,这也意味着我们不会执行从解码形式到编码形式的转换。

FIDL 采用这种方式的原因之一是,它能让解码变得更加简单。特别是,确保所有字节都得到处理,以及确保所引用的所有数据都位于消息缓冲区内的边界检查,都可以在一次传递中轻松完成。由于“安全性和恢复能力”部分中讨论的安全问题,我们认为不必担心前者。对于后一种担忧,我们只需确保所有数据缓冲区都指向由 Arena 分配的内存。驱动程序运行时将提供一个 API,使我们能够执行此检查。

线性化的一个优点是能够对缓冲区进行 memcpy 操作。由于消息已由 arena 提供支持,因此转移消息的所有权与转移 arena 的所有权一样简单。无法复制消息不一定是不利因素,也不会带来明显的负面影响。

线性化的另一个好处是简化了解码。实际上,解码消息所需的额外复杂性可能会超过不复制数据带来的优势。需要仔细测量,以确保跳过线性化能带来明显优势。

最后,线性化可以通过提高空间局部性来提升缓存性能。如果 arena 实现得很好(作为 bump 分配器),空间局部性应该仍然很好,并且在同一堆栈帧内调用其他驱动程序带来的时间局部性改进应该不太可能导致任何损失。

虽然此 RFC 并未建议跳过线性化,但我们计划在后续工作中实现所需的功能集,以便评估是否值得实现此功能。

泄露的句柄

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

一种解决方案是部分编码和解码,而不进行线性化。更具体地说,在编码期间,句柄将被提取出来并替换为线性句柄表中的偏移量;在接收时,句柄将在解码期间移回对象中。在解码期间,可以跟踪并关闭未使用的表条目,类似于基于 Zircon 通道的方法。这种折中方法应该可以让我们获得跳过线性化步骤的大部分性能优势,同时避免潜在的陷阱。

线框格式迁移

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

非 LLCPP 绑定

在撰写本文时,只有单个 FIDL 绑定实现 LLCPP 可以利用跳过线性化步骤带来的优化,并处理可能从非线性化状态解码的情况,因为它是唯一能够原生理解线格式的绑定。对于其他绑定,在接收端,可以在继续进行其他正常的特定于绑定的解码之前,执行额外的缓冲线性化步骤。此外,在发送端,仍可执行线性化,绑定只需注意避免将指针转换为偏移量。

透明传输

本文档中介绍的设计将是否利用 FIDL 驱动程序传输版本的 zircon 通道传输来处理特定协议的选择权完全交给驱动程序作者。此设计的初始版本选择向驱动程序隐藏底层传输,旨在提高整体驱动程序人体工程学,减少需要学习的概念数量,并让平台更好地控制驱动程序是否位于同一位置。经过多次讨论,我们决定放弃这个想法,因为它会大大复杂化设计,并引入许多需要解决的新问题。例如,线性化消息的需求将成为动态决策,而不是在编译时已知的决策。

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

Operations

如果我们引入一个传输层概念来跟踪逻辑操作在堆栈中通过各种驱动程序时的状态,我们或许能够提供更好的诊断,并做出更好的调度决策。如本设计中所述,与基于 Zircon 通道的 FIDL 类似,事务目前是协议层概念。此提案的早期版本曾考虑使分配器具有单一所有权,以便我们将其用作跟踪驱动程序之间操作的替代方案,但以这种方式使用分配器可能存在缺陷,因此当前设计现在建议使用引用计数分配器设计。重新审视传输中的第一类操作概念是我们未来可以考虑的改进。

Single Copy Transport

如果我们认为实现零复制并不重要,那么可以探索许多替代设计方案。我们可以选择移除 arena,而是要求预注册完成缓冲区或环,类似于 Windows IOCPio_uring 甚至 NVMe 设计。当请求完成时,我们会将结果写入预注册缓冲区/环,这很可能是在线性化步骤中完成的。这种设计非常适合内置反压。另一种方法是保留 arena,但始终执行线性化。如果此 RFC 的审核者认为有必要,可以进一步阐述这些方法的优缺点。

传输反压力

Zircon 通道的一个众所周知的问题是,它们没有任何反压规定。在撰写本文时,存在全局渠道消息限制,如果达到此限制,则拥有接收端渠道的进程会被终止。

由于我们正在设计新的渠道基元,因此可以选择在此处做一些改进。鉴于此提案的范围很大,我们认为应该搁置此提案。不过,这可能是一个值得探索的领域,因为 FIDL 团队正在积极调查此问题。在驱动程序运行时中试用新方法可能是一种很好的方式,可以了解所采用的方法是否也值得在 zircon 系统调用接口中实现,因为在 zircon 系统调用接口中,风险会更高。

双向渠道和事件

有人建议,Zircon 渠道的双向特性是一个错误。我们可能会选择不使驱动程序运行时通道成为双向通道,而是需要两个通道对(4 个端点)才能实现双向通信,类似于 GoLang 通道内置功能。因此,我们不支持通过驱动程序运行时传输的 FIDL 事件。驱动程序运行时传输支持的 FIDL 功能不会出现漂移,而是尽可能更好地匹配功能集。除了减少用户困惑之外,如果我们决定朝着这个方向发展,将来还可以更轻松地实现不透明传输。

递归锁

除了不支持自动将导致驱动程序重入访问的工作排入队列之外,我们还可以允许驱动程序选择加入此功能。为了正确处理这种情况,驱动程序必须执行以下两项操作之一:

  1. 自行将工作排入队列,并安排稍后处理该队列。
  2. 使用递归锁。

与 RFC 中提出的设计相比,第一个选项似乎没有任何优势。 第二种方案需要使用我们最初选择避免在平台中实现支持的锁定。这是因为递归锁很难(即使能够)确保获取锁的顺序的正确性。如果以多种不同的顺序获取锁,代码中就会潜伏着潜在的死锁。与其冒此风险,不如简单地确保我们永远不会重入驱动程序。

Rust 驱动程序支持

我们不打算在此提案中支持 Rust 驱动程序,但预计会在不久的将来启用它。Fuchsia 中的驱动程序作者非常希望使用 Rust 编写驱动程序,但 Banjo 使驱动程序框架团队很难完全采用 Rust 支持。确保我们可以轻松启用用 Rust 编写的驱动程序是一项设计考虑因素,但未必是促使我们这样做的因素。

预计可以实现之前在此 RFC 中描述的驱动程序传输的 Rust FIDL 绑定。此类绑定的具体设计和实现目前尚未确定,可能会成为未来 RFC 的主题。

工作优先级

在未来的某个时间点,我们很乐意探讨如何在工作或消息级别(而非调度器级别)继承优先级。虽然 Zircon 内核团队有探索此想法的初步计划,但驱动程序运行时在这一方面具有很大的灵活性,可以进行实验并实现超出内核提供的创新。在完成运行时的初始实现并进行一些唾手可得的优化后,这可能会成为一个重要的关注领域。

电源管理频道

在其他驱动程序框架中,当较低级别的驱动程序处于暂停状态时,自动停止服务工作是一项有用的操作。此 RFC 中提出的设计并未提供任何支持此功能的措施,但为了提高驱动程序作者的人体工程学体验,这是一个值得探索的领域。目前,在驱动程序框架应在多大程度上参与电源管理方面,存在许多未解决的问题。

在先技术和参考资料

进程内线程处理模型和如何高效且符合人体工程学地处理并发的想法都不是新概念。此处采用的方法在很大程度上借鉴了以下来源(以及更多来源):