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 协议。这是因为,在对协议调用方法时,使用这些协议的驱动程序没有任何必须遵循的规则。处理来电时,您需要确保逻辑:
- 处理同步问题,因为客户端可能会从多个线程(可能并行)调用。
- 处理重入。
处理第一个问题的明显方法是获取锁。不过,仅仅获取锁定就很容易导致死锁,因为您的驱动程序可能会回调到另一个驱动程序,并且在同一堆栈帧中,它可能会回调以尝试重新获取同一锁定。目前,驱动程序中使用的所有锁实现都不支持重入,而且我们不想开始使用递归锁,原因在替代方案部分中进行了更详细的探讨。
因此,目前正确处理此问题的唯一方法是将工作推送到队列,并稍后处理该队列。由于当前驱动程序运行时未提供用于安排稍后执行的工作的机制,因此驱动程序必须实例化自己的线程才能处理此队列。
这会带来问题,因为这会破坏将驱动程序放置在同一进程中的预期性能优势。此外,在驱动程序实现复杂性方面,代价也很高。系统上的许多驱动程序都不会选择将工作推送到队列,因此,如果有新客户以不可避免地最终导致系统死锁的方式使用这些驱动程序,则可能会出现问题。我们有过这样的历史记录:此类 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(调度/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 类似)应为控制数据结构,数据平面数据存储在 VMOs 中。这样一来,我们就可以减少最昂贵的副本。不过,控制数据结构的大小可能会累加,在某些情况下(例如网络数据包批量请求),可能会达到几千字节。此外,在多个驱动程序堆栈中,数据大多保持不变,并通过多个层传递。我们可以省略所有这些副本。事实上,在当前基于 banjo 的运行时中,我们会想方设法实现零拷贝,但实现方式存在很大问题,并且容易出现 bug。这是阻止我们采用 Rust 驱动程序开发的众多因素之一。
值得注意的是,由于系统调用开销占主导地位,因此 FIDL 中的复制通常不被视为性能瓶颈。尽管如此,在驱动程序运行时,我们会避免使用系统调用,因此在驱动程序之间发送消息所花费的时间中,复制操作预计会占据相当大的一部分。
从性能角度来看,这些副本目前不会在系统中造成明显的瓶颈。不过,它们可能会对 CPU 使用率产生可衡量的影响,因此提供实现零拷贝的能力被认为很有用。
推送与拉取
Zircon 通道采用基于拉取的机制。用户空间会注册在某个通道上接收信号,以便知道何时有数据可读取,并在收到通知后从该通道读取数据到它提供的缓冲区。FIDL 绑定通常会将此机制反转为基于推送。注册回调,并在有内容可供回调时触发回调。
我们可以非常灵活地选择采用完全基于推送的机制,也可以继续模拟 zircon 通道机制。对于普通用户而言,推送更符合人体工学,并且可能具有更高的性能,因为拉取模型需要在收到信号后重新进入运行时。在选择提高性能的其他系统(例如 Windows IOCP 和 Linux io_uring)中,通常会预先注册缓冲区,以通过移除内核中的额外条目以及副本来实现更高的性能。对于进程内运行时,进入运行时非常便宜,实际上无需预注册缓冲区即可实现零拷贝,因此在我们的 API 中引入与 zircon 通道不同的 API 不一定是好事。此外,作为一种语言,Rust 非常依赖于基于拉取的机制,如果我们选择在传输层放弃拉取,则可能会与其模型存在阻抗不匹配的情况。
基元
如前所述,新设计需要新的基元才能构建新的 FIDL 传输层。这些基元本身在很大程度上是模仿 zircon 内核提供的基元。
基元 API/ABI 将基于 C 语言,类似于现有的 libdriver API。我们将提供各个语言的封装容器,以便更符合惯用法。虽然可以通过 FIDL 定义 API(类似于在 FIDL 中定义 zircon 系统调用接口),但由于整个 API 集应保持非常小,因此可能不值得费心。
场地
第一个基元是竞技场。为了减少副本数量,我们需要一种方法来确保与请求关联的数据的生命周期至少与请求的有效期一样长。提供给传输层的数据必定由缓冲区支持。在基于通道的 FIDL 绑定中,传入消息数据通常从堆栈开始,如果需要异步回复请求,则可以按需移至堆分配。FIDL 绑定可以直接读取到堆内存,但在撰写本文时,这种情况并未发生。相反,如果我们通过运行时提供的竞技场来备份数据,则可以将请求的生命周期与竞技场相关联。我们还可以针对竞技场进行额外的分配,并保证这些分配在请求有效期间有效。处理请求时,通常会将其转发到其他驱动程序,或向下游驱动程序发出请求;在这些情况下,可以将同一竞技场重新用于新请求并随之传递。通过这种方案,操作或许可以浏览多个驱动程序,而无需进行全局分配。在驱动程序堆栈(例如分块堆栈)中,通常需要在同一驱动程序主机中浏览 6 个或更多驱动程序,其中大部分是将相同请求反复转发到更低级别的驱动程序,对数据进行最少的修改。
运行时将提供用于创建竞技场、递减对它们的引用、执行分配以及检查所提供的内存区域是否由竞技场提供支持的 API。最后一个 API 比较不寻常,但对于 FIDL 传输实现中的稳健性至关重要。我们可能会稍后添加用于释放单个分配的 API,但初始实现会跳过它。只有在销毁竞技场本身(即移除对竞技场的所有引用)时,才会释放分配。
运行时可以根据需要自由优化竞技场。例如,基础实现可以选择仅将所有分配请求转发给全局 malloc,并等到竞技场被销毁后,再为发生的每个分配发出 free。或者,它也可以实现为单个分配增量竞技场,其大小上限会根据每次请求所需的最大分配量进行调整。随着时间的推移,我们可能会扩展竞技场接口,以允许客户提供有关底层竞技场应采用哪种策略的提示,因为相同的竞技场策略在所有驱动程序堆栈中可能并不理想。
频道
第二种基元是通道基元。它的界面与 Zircon 渠道几乎完全相同。主要区别包括:
- 写入 API 将接受一个竞技场对象,并递增其引用计数。
- 写入 API 将要求传入它的所有缓冲区(数据 + 句柄表)都由竞技场提供支持。因此,运行时将获得这些缓冲区(以及由竞技场支持的所有其他缓冲区)的所有权。
- 读取 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 的缓冲区所有权关系不一致。预计需要与维护各种绑定的团队进行一定程度的协作,以确定适当的代码重用、分歧和绑定级别。
按请求竞技场
如前面部分所述,驱动程序运行时将提供一个竞技场对象,该对象将与通过通道发送的消息一起传递。FIDL 绑定应利用此 arena 实现零复制。
用户可以选择不使用竞技场来创建发送给其他驱动程序的结构。这没问题,绑定可以确保将类型复制到由运行时提供的竞技场分配器支持的缓冲区中。不指定竞技场所带来的人体工学改进必然会导致复制。我们在这里可以选择的唯一设计方案是,让用户非常明显地知道正在发生复制,以免用户因无知而复制。
用户可能更倾向于在堆栈上分配所有对象,希望这些对象完全在调用本身所在的堆栈帧中使用,这在某些情况下可能可行,具体取决于线程限制。事实上,司机目前就是通过 Banjo 来执行此操作。虽然我们可以通过在认为有必要将类型移至堆(通过类似于接收器中的 ToAsync()
调用)时,延迟将类型移至由竞技场提供支持来支持这种情况,但我们会避免支持这种情况。而是应使用导致消息复制到由竞技场支持的缓冲区(如上所述)的相同逻辑。如果有合适的用例出现,我们日后可能会重新考虑该想法。
消息验证
目前,当驱动程序相互通信时,我们不会执行枚举验证或 Zircon 句柄验证等操作来验证消息是否符合其所使用的接口指定的必要协定。在新版运行时中,我们可能会开始执行其中的一些验证,但需要根据每个验证功能确定要执行哪些验证。例如,枚举验证的开销可能很低,并且不太可能导致任何可衡量的性能损失。另一方面,如果我们需要通过内核进行往返,Zircon 通道验证可能会非常耗费资源,因此我们会考虑如何将其移除。此外,我们会尽量避免剥夺频道的权限,因为这需要再次通过内核进行往返。我们将通过以下流程做出这些确定,其中需要对最终决定进行基准测试,以便确定我们选择执行哪些验证。
之所以能够做出这些选择,是因为同一驱动程序主机中的驱动程序共用相同的安全边界。这些验证步骤只会提高我们的弹性。
请求转发
驱动程序接收请求、进行一些最小修改并将其转发到更低级别的驱动程序,这是一种常见操作。我们的目标是能够以经济且符合人体工学的方式实现这一点。此外,能够将消息转发到驱动程序拥有通道两端的通道可能是一项有用的 activity,因为驱动程序可能需要管理并发性并将数据停车在某个位置。虽然它可以将其推送到队列,但利用驱动程序运行时通道作为队列可能是实现相同效果的一种简单方法,并且可以减少代码量。
运输层级取消
当驱动程序因新条件或新要求而必须取消一些未完成的工作时,目前无论是基于 banjo 还是 zircon 通道的 FIDL,都没有统一的方法来实现这一点。在基于通道的 FIDL 中,许多绑定都会让您忽略最终会收到的回复。这可能已经足够了,具体取决于用例,因为它允许取消分配与请求关联的状态,这可能是唯一的目标。不过,有时需要传播取消操作,以便下游驱动程序知道取消操作。在这种情况下,解决方法是在协议层构建支持,或者直接关闭通道对的客户端。后一种解决方案仅适用于需要取消所有待处理交易且无需与服务器同步的情况,因为您无法获得确认。
驱动程序运行时通道可以提供与 zircon 通道传输提供的相同级别的支持。在传输层内构建对事务级取消传播的支持是一个诱人的想法,但由于 FIDL 和传输层之间分工明确,因此实现起来非常具有挑战性。传输层不知道事务 ID,因为它们是基于通道基元构建的 FIDL 概念。此外,也许不值得偏离 Zircon 通道的运作方式,因为这可能会导致需要处理这两种传输方式的开发者感到困惑。这也会使实现抽象多个传输层的可能性变得更加困难。
实现
此设计的实现主要分为三个阶段:
- 实现驱动程序主机运行时 API
- 实现基于驱动程序主机运行时 API 构建的 FIDL 绑定
- 从 banjo 迁移客户
驱动程序主机运行时 API
运行时 API 将在新驱动程序主机中实现,用于作为组件运行的驱动程序。我们不会向所有驱动程序授予使用这些 API 的权限,而是会在 colocate
字段旁边指定一个名为 driver_runtime
的新组件清单字段,以控制使用这些 API 的权限。借助此属性,驱动程序运行程序可以知道是否应向驱动程序提供 API。同一 driver_host 中的所有驱动程序都必须具有此属性的相同值。
除了设计中前面介绍的基元之外,还需要添加支持,以建立一种机制,以便在绑定时在驱动程序之间传输新的驱动程序传输通道。此外,还需要实现一种用于描述绑定规则和节点属性的惯用法,以便驱动程序能够绑定到实现驱动程序传输 FIDL 服务的设备。
我们将在隔离的 devmgr 中添加支持,以便我们能够生成在新的运行程序中运行的驱动程序。此环境将用于实现基元、编写测试以及运行性能基准测试。
运行时 API 的初始实现将侧重于 MVP,以巩固最终 API,以便我们开始着手处理 FIDL 绑定。准备就绪后,您就可以并行处理 FIDL 绑定,同时优化运行时 API 的实现。
FIDL 绑定
FIDL 绑定将写入现有的 fidlgen_llcpp
代码库中。输出驱动程序运行时头文件将受标志控制。实现策略将遵循与上述类似的策略,重点是从一组最少的更改开始,利用新的驱动程序运行时使绑定正常运行。该功能正常运行后,我们将建立微基准测试来了解其性能。然后,我们可以迭代绑定,进行优化,例如移除我们认为不必要的验证步骤。
线类型定义最终将作为共享头文件的一部分添加,但在将这些类型从现有 FIDL 头文件拆分到专用头文件之前,我们只会重新发出这些类型。这会导致尝试同时使用 Zircon 通道和驱动程序运行时传输的所有驱动程序出现问题,但我们希望届时已完成将线定义拆分到共享头文件的工作。
Migration
尤其是迁移将会非常具有挑战性。尽管几乎所有驱动程序都位于 fuchsia.git 代码库中,与尝试一次性将整个世界从 banjo 迁移到新运行时相比,一次迁移一个驱动程序主机要简单得多。该策略将包括以下步骤:
- 需要克隆和修改要移植的驱动程序使用的 Banjo 协议,以创建以驱动程序传输为目标平台的版本。
- 要移植的驱动程序将同时实现对 banjo 和驱动程序传输 FIDL 服务的支持。
- 系统会为驱动程序创建新的组件清单、绑定程序和构建目标,以指明其以新运行时为目标平台。
- 对于驱动程序所在的每个开发板,在其所在驱动程序主机中的所有驱动程序都完成移植后,包含该驱动程序的开发板 gni 文件将更新为使用新版本的驱动程序,而不是以 banjo 为目标平台的旧版本。
- 您可以删除用于 banjo 的驱动程序 build 变体,也可以删除使用或提供 banjo 协议的驱动程序中的任何代码。
- 一旦任何驱动程序都不再使用某个 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 中提出的设计不应改变系统的安全架构。目前位于同一进程内的驱动程序将继续保留在同一进程内,因此安全边界不应发生变化。不过,作为迁移到新驱动程序运行时的预期结果,客户端预计会按接口重新评估,确定其驱动程序是否应与其父级共处于同一进程中。我们将编写文档,为进行此评估的开发者提供指导。
隐私注意事项
此设计预计不会对隐私造成影响。
测试
设计的不同部分将通过不同的机制进行测试。驱动程序运行时将通过 unittest 进行测试,这些测试基于对等 Zircon 基元进行的类似测试。此外,集成测试将使用隔离的 devmgr 和 CFv2 测试框架编写。
驱动程序传输 FIDL 绑定将通过 GIDL 以及集成测试进行测试,以确保正确性。集成测试必须按绑定进行编写,而模仿已针对该绑定的 Zircon 通道传输变体编写的集成测试是最有可能采用的方法。这些集成测试可能还需要使用隔离的 devmgr 和 CFv2 测试框架。
要迁移到新驱动程序运行时,驱动程序可能需要针对每个驱动程序制定测试计划。基于隔离的 devmgr 的测试应支持新传输,而无需任何特殊的努力。对于单元测试,可能需要编写一个测试库来提供驱动程序运行时 API 的实现,而无需在驱动程序主机中运行测试。您可以使用与目前在单元测试中模拟 libdriver API 时所用的方法类似的方法。我们将考虑在测试库和 API 的驱动程序主机实现之间共享代码,以减少功能偏差。
文档
我们需要编写新指南,介绍如何编写使用驱动程序传输 FIDL 的驱动程序,以及如何创建用于提供该传输的驱动程序。它们需要按绑定进行编写,但由于我们最初仅以 llcpp 为目标平台,因此只需编写一组即可。理想情况下,我们可以改编现有的 llcpp 指南,以减少制作指南所需的总体工作量。
驱动程序的参考部分也需要更新,以包含有关运行时 API 的相关信息。您还需要了解线程模型以及如何编写线程安全的代码。
我们需要一份最佳实践文档,帮助驱动程序作者确定是使用驱动程序传输还是 Zircon 通道传输版本的 FIDL。
最后,如迁移部分中所述,您需要提供有关如何从 banjo 迁移到驱动程序传输 FIDL 的文档。
缺点、替代方案和未知情况
线性化
为了实现零拷贝,我们可以避免 FIDL 目前执行的线性化步骤。重要的是,这样做会违反现有的 FIDL 合同在结构布局方面的规定。避免线性化也意味着,我们不会将解码形式转换为编码形式。
FIDL 以这种方式执行操作的原因之一是,它可以让解码变得更加简单。具体而言,确保在一次传输中统计所有字节非常简单,并且边界检查可确保所引用的所有数据都位于消息缓冲区内。鉴于“安全和弹性”部分中所述的安全问题,我们认为不必担心前者。对于后一种情况,我们只需确保所有数据缓冲区都指向由竞技场分配的内存即可。驱动程序运行时将提供一个 API,以便我们执行此检查。
线性化的一个宣传优势是能够对缓冲区进行 memcpy。由于消息已由竞技场提供支持,因此移动消息的所有权就像转移竞技场的所有权一样简单。无法复制消息不一定会带来任何明显的负面影响。
线性化的另一个好处是它简化了解码。事实上,解码消息所需的额外复杂性可能会超过不执行数据复制带来的好处。这需要仔细衡量,以确保跳过线性化是明智之举。
最后,线性化可以通过获得更好的空间局部性来提高缓存性能。如果竞技场实现良好(作为 bump 分配器),空间局部性应该仍然良好,并且通过调用同一堆栈帧中的其他驱动程序而实现的时间局部性改进应该会使此处的任何损失不太可能导致问题。
虽然本 RFC 并未建议跳过线性化,但我们计划在后续工作中实现实现此功能所需的功能集,以便评估是否值得实现此功能。
泄露的句柄
跳过线性化时会遇到的一个问题是,在 FIDL 消息的发送方使用接收方不认识的联合体或表中的字段(可能使用较新版本的 FIDL 库)的情况下,可能会泄露句柄。在 zircon 通道传输和提议的驱动程序运行时传输中,接收器可以放心地忽略新字段,并以其他方式了解消息内容的其余部分。不过,在 Zircon 通道变体中,句柄与数据分开存储,这意味着,如果句柄位于未知字段中,接收器仍会知道句柄的存在,并且可以关闭未使用的句柄。如果我们完全跳过编码步骤,那么无论我们是否了解完整的消息结构,都无法了解所有句柄。
解决此问题的方法是部分编码和解码,而无需线性化。更具体地说,在编码期间,这些句柄会被提取并替换为线性句柄表中的偏移量;在接收时,这些句柄会在解码期间移回对象中。在解码期间,可以跟踪和关闭未使用的表条目,类似于基于 Zircon 通道的方法。这种折衷方法应该能让我们获得跳过线性化步骤带来的大部分性能优势,同时避免潜在的陷阱。
线格格式迁移
另一个问题是,在 FIDL 线格式迁移期间,两个对等驱动程序可能会使用不兼容的布局对象。例如,我们目前正在将 FIDL 封装容器的大小减半。目前,在 IPC 情况下,通过在两种编码的线格格式之间添加转换器来解决此问题。虽然我们可以为非线性化解码格式编写额外的转换器,但这会增加维护负担。
非 LLCPP 绑定
在撰写本文时,只有一个 FIDL 绑定实现(LLCPP)可以利用跳过线性化步骤带来的优化,以及处理可能从非线性化状态进行解码的情况,因为它是唯一能够原生理解线格格式的绑定。对于其他绑定,在接收端,您可以在继续进行正常的绑定专用解码之前,执行额外的线性化缓冲区步骤。此外,在发送端,仍然可以执行线性化,绑定只需注意避免将指针转换为偏移量。
透明传输
本文档中介绍的设计完全由驱动程序作者决定是否针对特定协议使用驱动程序传输版本 FIDL 的 zircon 通道传输。此设计的初始版本选择了将底层传输隐藏起来,以便改进驱动程序的整体人体工学设计、减少需要学习的概念数量,并让平台能够更好地控制驱动程序是否共存。经过一番激烈的争论,我们决定放弃这个想法,因为它会大大增加设计的复杂性,并引入许多需要解决的新问题。例如,需要对消息进行线性化处理将成为一个动态决策,而不是在编译时已知的决策。
如果我们决定继续尝试使底层传输对驱动程序透明,则需要编写后续 RFC。
Operations
如果我们在传输层引入一个概念,以跟踪逻辑操作在堆栈中的各种驱动程序中导航时的情况,我们或许能够提供更好的诊断信息,并做出更好的调度决策。如此设计中所述,与基于 Zircon 通道的 FIDL 类似,事务目前是一种协议层概念。此提案的早期版本考虑过将分配器设为单个所有权,以便我们将其用作跟踪驱动程序之间操作的替代项,但以这种方式使用分配器可能存在缺陷,因此当前设计现在改为提议使用引用计数型分配器设计。我们日后可以考虑在传输中重新考虑一流操作概念,以便进行改进。
单个副本传输
如果我们认为实现零文案并不重要,则可以探索许多其他设计方案。我们可以选择移除竞技场,改为要求预注册完成缓冲区或环,类似于 Windows IOCP、io_uring 或 NVMe 设计。请求完成后,我们会将结果写入预注册缓冲区/环,这可能属于线性化步骤的一部分。这种设计非常适合内置背压。另一种方法是保留竞技场,但始终执行线性化。如果此 RFC 的审核者认为有用,可以详细权衡这些方法的利弊。
传输背压
zircon 通道的一个众所周知的问题是,它们没有任何用于反压的规定。在撰写本文时,存在一个全局通道消息限制,如果达到该限制,则会导致接收端上拥有通道端点的进程被终止。
由于我们正在设计新的通道基元,因此可以选择在此处采取更好的做法。鉴于此提案的范围较大,我们认为不应继续推进。不过,这可能是一个值得探索的领域,因为 FIDL 团队正在积极研究这个问题。在驱动程序运行时中试用新方法可能是一种很好的方式,可以了解所采用的方法是否值得在风险更高的 Zircon 系统调用接口中实现。
双向渠道和事件
有人认为,zircon 通道的双向性质是错误的。我们可以选择不将驱动程序运行时通道设为双向,而是需要两个通道对(4 个端点)才能实现双向通信,类似于内置的 golang 通道。因此,我们不支持通过驱动程序运行时传输进行 fidl 事件。它可能会尽可能更好地匹配功能集,而不是在支持的 FIDL 功能方面与驱动程序运行时传输发生偏移。除了减少用户困惑之外,这还将有助于我们日后更轻松地实现不透明传输(如果我们决定朝着这个方向发展)。
递归锁
除了不支持自动加入队列的工作(这会导致对驱动程序的重新进入访问)之外,我们或许还可以允许驱动程序选择启用此功能。为了正确处理此问题,驱动程序必须执行以下两项操作之一:
- 将工作本身排入队列,并安排稍后处理该队列。
- 使用递归锁。
第一个选项似乎不比 RFC 中介绍的设计更有优势。第二种方法需要使用我们最初选择的锁,以避免在我们的平台中实现支持。原因在于,递归锁会使确保锁的获取顺序正确性变得非常困难,甚至是不可能。如果以多个不同的顺序获取锁,则代码中可能会潜伏着死锁。与其冒着出现此问题的风险,不如确保我们从不重入驱动程序,这样更简单。
Rust 驱动程序支持
我们不打算在此提案中支持 Rust 驱动程序,但预计近期可能会启用该功能。Fuchsia 中的驱动程序作者非常希望使用 Rust 编写驱动程序,而 Banjo 使得驱动程序框架团队很难全面采用 Rust 支持。确保我们能够轻松启用使用 Rust 编写的驱动程序是一项设计考虑,但不一定是促成因素。
预计可以实现本 RFC 中前面所述的驱动程序传输的 rust FIDL 绑定。目前,此类绑定的具体设计和实现尚未确定,未来可能会成为 RFC 的主题。
工作优先级
未来某个时候,我们很想探索如何在工作级别或消息级别(而不是调度程序级别)继承优先级。虽然 Zircon 内核团队有意探索此想法,但驱动程序运行时非常灵活,可以在此进行实验,并在内核提供的功能之外进行创新。在完成运行时的初始实现并进行一些简单的优化后,这可能会成为一个重要的重点领域。
由电源管理的渠道
在其他驱动程序框架中,当较低级别的驱动程序处于暂停状态时,自动停止处理工作是一项有用的操作。本 RFC 中介绍的设计不支持此操作,但为了改善驾驶员作者的工作效率,我们会探索此方向。目前,关于驱动程序框架在电源管理方面应发挥的作用,还有很多悬而未决的问题。
在先技术和参考文档
进程内线程模型的概念和有关如何高效且符合人体工学原理处理并发的想法都不是新概念。此处采用的方法在很大程度上借鉴了以下来源(以及更多来源):