RFC-0012:Zircon 可舍弃内存

RFC-0012:Zircon 可舍弃内存
状态已接受
区域
  • 内核
说明

描述了一种机制,使用户空间应用可以向内核表明某些内存缓冲区符合回收条件。

问题
Gerrit 更改
作者
审核人
提交日期(年-月-日)2020-10-27
审核日期(年-月-日)2020-12-02

摘要

此 RFC 描述了一种机制,使用户空间应用可以向内核表明某些内存缓冲区符合回收条件。这样一来,当系统可用内存不足时,内核便可随意舍弃这些缓冲区。

设计初衷

在 Zircon 这样的过度提交系统中,管理空闲内存是一个复杂的问题,因为用户应用可以分配比系统当前可用内存更多的内存。这是通过使用由物理页面延迟支持的虚拟内存对象 (VMO) 来实现的,因为其中的部分已提交。

过高估计任何时间点将要使用的物理内存量,并据此拒绝进一步的内存分配请求,可能会导致内存空闲。这可能会影响性能,因为许多应用会使用大量此类内存进行缓存。另一方面,低估正在使用的可用内存量可能会导致我们快速用尽系统上的所有可用内存,从而导致内存不足 (OOM) 的情况。此外,“可用”内存的定义本身就很复杂。

Zircon 内核会监控可用物理内存量,并生成各种级别的内存压力信号。这些信号的目的是让用户空间应用能够根据系统范围内的可用内存级别来缩减(或增加)其内存占用空间。虽然这有助于防止系统内存不足,但这些信号的启动者(内核)与响应者(用户应用)之间的解耦并不理想。响应内存压力的进程没有足够的上下文来了解它们应该释放多少内存;内核可以更好地了解系统上的全局内存使用情况,还可以考虑其他形式的可回收内存,例如可以逐出用户寻呼器支持的内存。

此 RFC 提出了一种机制,通过该机制,内核将能够在内存压力下直接回收用户空间内存缓冲区。这种方法有以下几个优点:

  • 这样可以更好地控制要逐出多少内存;内核可以查看可用内存级别,并仅逐出所需的内存量。
  • 内核可以使用 LRU 方案来舍弃内存,这可能更适合在内存中容纳当前工作集。
  • 有时,用户空间在响应内存压力信号时会缓慢地释放内存。在某些情况下,系统可能无法恢复。
  • 唤醒以响应内存压力的用户空间客户端有时需要更多内存。

设计

概览

可舍弃内存协议的大致工作方式如下:

  1. 用户空间进程创建 VMO 并将其标记为可丢弃
  2. 在直接 (zx_vmo_read/zx_vmo_write) 或通过地址空间中的映射 (zx_vmar_map) 访问 VMO 之前,进程会锁定 VMO,表明该 VMO 正在使用中。
  3. 该进程完成后会解锁 VMO,表明不再需要它。内核会将所有已解锁的可丢弃 VMO 视为可回收,并可在内存压力下随意丢弃它们。
  4. 当进程需要再次访问 VMO 时,它会尝试锁定该 VMO。现在,可以通过以下两种方式之一成功获取此锁。
    • 锁定可以成功,但 VMO 的页面仍然完好无损,也就是说,内核尚未将其舍弃。
    • 如果内核已舍弃 VMO,则锁定会成功,同时也会向客户端表明其页面已被舍弃,以便客户端重新初始化该 VMO 或采取其他必要措施。
  5. 该进程完成后,将再次解锁 VMO。锁定和解锁可以按这种方式重复任意次数。

请注意,可舍弃内存并非旨在直接取代内存压力信号。对于其他组件级决策(例如选择何时启动内存密集型 activity 或线程),监控内存压力变化仍然很有价值。未来,我们还可以使用这些信号来终止组件内的空闲进程。内存压力信号还可让组件更精确地控制要释放的内存以及释放时间。

Discardable Memory API

我们可以扩展现有的 zx_vmo_create()zx_vmo_op_range() 系统调用来支持此功能。

  • zx_vmo_create() 将扩展为支持新的 options 标志 - ZX_VMO_DISCARDABLE。此标志可与 ZX_VMO_RESIZABLE 结合使用。不过,有关可调整大小的 VMO 的一般建议也适用于可舍弃的 VMO - 在进程之间共享可调整大小的 VMO 可能很危险,应避免这样做。

  • zx_vmo_op_range() 将扩展为支持新操作,以提供锁定和解锁功能 - ZX_VMO_OP_LOCKZX_VMO_OP_TRY_LOCKZX_VMO_OP_UNLOCK

  • 锁定和解锁将应用于整个 VMO,因此 offsetsize 应涵盖整个 VMO 范围。锁定和解锁 VMO 内的较小范围是错误的。虽然当前实现并不严格要求 offsetsize,但确保只有整个 VMO 范围被视为有效,这样一来,未来就可以添加子范围支持,而不会改变客户端的行为。

  • ZX_VMO_OP_TRY_LOCK 操作会尝试锁定 VMO,但可能会失败。 如果内核未舍弃 VMO,则调用会成功;如果内核已舍弃 VMO,则调用会失败并显示 ZX_ERR_NOT_AVAILABLE。如果失败,客户端应使用 ZX_VMO_OP_LOCK 再次尝试,只要传入的实参有效,该方法就一定会成功。ZX_VMO_OP_TRY_LOCK 操作提供了一种轻量级选项,用于尝试锁定 VMO,而无需设置缓冲区实参。客户端也可以选择在无法锁定 VMO 后不采取任何操作。

  • ZX_VMO_OP_LOCK 操作还需要 buffer 实参,即指向 zx_vmo_lock_state 结构的 out 指针。此结构旨在供内核传递客户端可能会觉得有用的信息,包含以下内容:

    • 跟踪锁定范围的 offsetsize:这些是客户端传入的 sizeoffset 实参。返回这些值纯粹是为了方便起见,这样客户端就不需要单独跟踪范围,而是可以直接使用返回的结构。如果调用成功,它们将始终与传递给 zx_vmo_op_range() 调用的 sizeoffset 值相同。
    • discarded_offsetdiscarded_size 跟踪丢弃的范围:这是锁定范围内包含丢弃页面的最大范围。此范围内的所有网页可能并非都已被舍弃,它只是此范围内所有已舍弃子范围的并集,并且可能包含未被舍弃的网页。使用当前 API,如果内核已舍弃某个范围,则舍弃的范围将涵盖整个 VMO。如果未舍弃,discarded_offsetdiscarded_size 都将设置为零。
  • 锁定本身不会提交 VMO 中的任何页面。它只是通过内核将 VMO 的状态标记为“不可丢弃”。客户端可以使用适用于常规 VMO 的任何现有方法(例如 zx_vmo_write()ZX_VMO_OP_COMMIT、映射 VMO 并直接写入映射地址)提交 VMO 中的页面。

// |options| supports a new flag - ZX_VMO_DISCARDABLE.
zx_status_t zx_vmo_create(uint64_t size, uint32_t options, zx_handle_t* out);

// |op| is ZX_VMO_OP_LOCK, ZX_VMO_OP_TRY_LOCK, and ZX_VMO_OP_UNLOCK to
// respectively lock, try lock and unlock a discardable VMO.
// |offset| must be 0 and |size| must the size of the VMO.
//
// ZX_VMO_OP_LOCK requires |buffer| to point to a |zx_vmo_lock_state| struct,
// and |buffer_size| to be the size of the struct.
//
// Returns ZX_ERR_NOT_SUPPORTED if the vmo has not been created with the
// ZX_VMO_DISCARDABLE flag.
zx_status_t zx_vmo_op_range(zx_handle_t handle,
                            uint32_t op,
                            uint64_t offset,
                            uint64_t size,
                            void* buffer,
                            size_t buffer_size);

// |buffer| for ZX_VMO_OP_LOCK is a pointer to struct |zx_vmo_lock_state|.
typedef struct zx_vmo_lock_state {
  // The |offset| that was passed in.
  uint64_t offset;
  // The |size| that was passed in.
  uint64_t size;
  // Start of the discarded range. Will be 0 if undiscarded.
  uint64_t discarded_offset;
  // The size of discarded range. Will be 0 if undiscarded.
  uint64_t discarded_size;
} zx_vmo_lock_state_t;

将扩展 zx::vmo 接口,以支持 op_range()ZX_VMO_OP_LOCKZX_VMO_OP_TRY_LOCKZX_VMO_OP_UNLOCK 操作。Rust、Go 和 Dart 绑定也将更新。

此 API 使客户端能够灵活地在多个进程之间共享可舍弃的 VMO。需要访问 VMO 的每个进程都可以独立执行此操作,并根据需要锁定和解锁 VMO。无需根据对锁定状态的假设在进程之间进行仔细协调。仅当没有人锁定 VMO 时,内核才会认为该 VMO 符合回收条件。

VMO 的限制

  • 可舍弃内存 API 仅支持 VmObjectPaged 类型,因为根据定义,VmObjectPhysical 无法舍弃。

  • 该 API 与 VMO 克隆(包括快照和 COW 克隆)和切片不兼容,因为在克隆层次结构中舍弃 VMO 可能会导致意外行为。zx_vmo_create_child() 系统调用将在可舍弃的 VMO 上失败。

  • ZX_VMO_DISCARDABLE 标志不能用于 zx_pager_create_vmo()options 实参中。造成这种情况的一个主要原因是,分页内存支持的 VMO 可以克隆,而可舍弃的 VMO 则不能。此外,对于由分页内存支持的 VMO,可丢弃性是隐含的,因此不需要额外的标志。

与现有 VMO 操作的互动

现有 VMO 操作的语义将保持不变。例如,zx_vmo_read() 不会在允许操作之前验证可舍弃的 VMO 是否已锁定。客户端有责任确保在访问 VMO 时已锁定该 VMO,以确保内核不会在客户端不知情的情况下将其舍弃。这限制了相应更改的表面积。内核提供的唯一保证是,在 VMO 的页面被锁定时,内核不会舍弃这些页面。

即使 VMO 被舍弃,只要客户端在访问映射之前锁定 VMO,VMO 的任何映射仍将有效。如果 VMO 已被舍弃,客户端无需重新创建映射。

在内核舍弃 VMO 后,如果未先锁定 VMO 就对其执行任何进一步操作,则会失败,就好像 VMO 没有已提交的页面一样,并且不存在按需提交页面的机制。例如,zx_vmo_read() 将因 ZX_ERR_OUT_OF_RANGE 而失败。如果 VMO 已映射到进程的地址空间,则对映射地址的未锁定访问将导致严重的页面错误异常。

内核实现

跟踪元数据

  • VmObjectPaged 中的 options_ 位掩码将扩展为支持 kDiscardable 标志;我们目前仅使用 32 位中的 4 位。
  • 将向 VmObjectPaged 添加新的 lock_count 字段,用于跟踪 VMO 上未完成的锁定操作的数量。
  • 内核将维护一个全局的可回收 VMO 列表,即系统上所有未锁定且可丢弃的 VMO。该列表将更新为以下内容:
    • ZX_VMO_OP_LOCK 会使 VMO 的 lock_count 递增。如果 lock_count 从 0 变为 1,则 VMO 将从全局可回收列表中移除。
    • ZX_VMO_OP_UNLOCK 会递减 VMO 的 lock_count。如果 lock_count 降至 0,VMO 将被添加到全局可回收列表中。

回收逻辑

当可舍弃 VMO 的 lock_count 降至零时,它们会被添加到全局可回收列表中,并在再次锁定后被移除。此标志会维护系统上所有未锁定可舍弃 VMO 的 LRU 顺序。当内存压力过大时,内核可以按顺序从该列表中出列 VMO 并将其舍弃,并在每次舍弃后检查可用内存级别。这只是一个非常简单的示例,展示了实际应用中的回收逻辑。稍后会介绍一些需要考虑的其他事项。

舍弃操作

“舍弃”是在内核端实现的,方法是取消提交 VMO 的所有页面,并将 VMO 的内部状态设置为 discarded。如果 VMO 的状态为 discarded,则 VmObjectPaged::GetPageLocked() 将失败并显示 ZX_ERR_NOT_FOUND。在后续的 ZX_VMO_OP_LOCK 操作中,系统会清除 discarded 状态。GetPageLocked() 是对 VMO 的所有页面访问的漏斗函数,通过 zx_vmo_read/write 系统调用和通过 VM 映射的页面访问都通过此函数。这样一来,我们就可以在舍弃的未锁定 VMO 上使系统调用失败,还可以在通过映射访问舍弃的未锁定 VMO 时生成异常。

实现

这是一个新 API,因此目前没有依赖项。内核侧实现可以单独完成。实现 API 后,用户空间客户端就可以开始采用它了。

性能

性能影响会因客户端用例而异。客户在使用 API 时可以注意以下几点。

  • 在访问可舍弃的 VMO 之前,使用 zx_vmo_op_range() 系统调用来锁定和解锁可舍弃的 VMO 可能会在对性能至关重要的路径上增加明显的延迟时间。因此,应在可以容忍或隐藏延迟时间增加的代码路径上使用系统调用。
  • 由于缓存在内存中保留的时间更长,客户端的性能也可能会得到提升。在内存压力下,客户端必须丢弃的缓冲区现在可以保留更长时间,因为内核只会根据需要丢弃尽可能多的内存。客户端可以通过缓存命中率、需要重新初始化缓冲区的次数等来跟踪此更改。

安全注意事项

无。

隐私注意事项

无。

测试

  • 从多个线程执行新 API 的核心测试 / 单元测试。
  • 用于验证内核端回收行为的单元测试,即只能舍弃未锁定的 VMO。

文档

需要更新 Zircon 系统调用文档,以纳入新的 API。

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

VMO 中的锁定范围

回收粒度选择为整个 VMO,而不是支持 VMO 内范围的更精细的舍弃操作。造成这种情况的原因有以下几种。

  • 重新构建丢弃了一些页面的 VMO 可能很棘手。考虑到通用使用情形(其中 VMO 用于表示匿名内存缓冲区),重新填充已舍弃的页面很可能需要进行零填充,这对于未舍弃的剩余页面来说可能并不总是合理。仅保留 VMO 的部分网页可能也没有价值,也就是说,只有在 VMO 完全填充后才有意义。
  • VMO 粒度可简化 VmObjectPaged 实现,只需最少的跟踪元数据。我们不需要跟踪锁定范围,以便稍后与解锁范围进行匹配。也不涉及复杂的范围合并。
  • 它还使回收逻辑相当轻量级,从而可以一次释放大量内存块。如果支持页面粒度,可能需要维护页面队列,并像我们用于逐出用户分页器支持的页面那样,丢弃过时的可丢弃页面。

所提议的 API 确实为将来指示可回收范围留下了空间,即 zx_vmo_op_range() 中目前未使用的 offsetsize 实参。为锁定 API(页面级锁定)添加范围支持似乎是当前提案的自然扩展。这对于以下客户非常有利:如果使用单独的 VMO 来备份小型可舍弃区域,成本可能会过高。

丢弃的内核实现

当内核回收可丢弃的 VMO 时,它会取消提交其页面,并将其状态跟踪为 discarded。未来对网页的解锁请求将在 discarded 状态下失败;一旦 VMO 再次锁定,discarded 状态就会清除。另一种替代方案是简单地取消提交页面,而不显式跟踪状态。不过,跟踪 discarded 状态可以实现更严格的故障模型。例如,假设一种情况,客户端在其地址空间中映射了一个可舍弃的 VMO,内核在某个时间点舍弃了该 VMO。如果客户端现在尝试通过映射访问 VMO,但未先锁定 VMO,则会发生严重页面错误。而如果内核仅取消提交页面,则后续的解锁访问只会导致系统将零页面静默地交给客户端。这可能不会被检测到,也可能会因出现意外的零页面而导致更细微的错误。

另一种替代方法是将 VMO 内部调整为零。这样一来,我们就可以获得默认情况下所需的故障模型,而无需进行任何显式状态跟踪。不过,这需要跟踪 VMO 的内部实现定义的大小,以及用户看到的外部大小。虽然具有内部实现定义的大小是一个有用的技巧,未来可能也会使其他用例受益,但具有两个单独的大小概念会令人困惑,并且容易出现 bug。因此,在我们找到其他明确受益于同时具有内部大小和外部大小的具体用例之前,我们选择避免采用这种方法。

使用原子操作实现更快的锁定 API

此锁定优化提供了一种替代的低延迟选项,用于锁定和解锁可舍弃的 VMO,旨在供预期会相当频繁地锁定和解锁的客户端使用。这纯粹是一种性能优化,因此如果需要,我们可以在未来添加此功能。

该 API 使用一种名为 Metex 的锁定原语,它类似于 Zircon futex,因为它可以实现通过用户空间原子操作快速锁定,从而节省系统调用的开销。

可舍弃的 VMO 可以与 metex 相关联,该 metex 将用于锁定和解锁 VMO,而不是 zx_vmo_op_range() 系统调用。metex 可以有三种状态:锁定(由用户空间客户端使用)、可丢弃(符合内核回收条件)和“需要系统调用”(可能已被内核回收,需要系统调用来检查状态)。锁定和解锁 VMO 无需进入内核,只需以原子方式在锁定状态和可丢弃状态之间翻转 metex 的状态即可。当内核舍弃 VMO 时,它会以原子方式将其状态翻转为“需要系统调用”,表明客户端需要与内核同步,以检查舍弃状态。此提案的更多详细信息不在本 RFC 的范围内,将在单独的 RFC 中提供。

基于分页器的创建 API

由分页器支持的任何 VMO 本质上都是可丢弃的 VMO,因为分页器提供了一种按需重新填充已丢弃页面的机制。此 RFC 中提议的可舍弃内存类型是匿名可舍弃内存;另一种类型是有文件支持的可舍弃内存,例如由 blobfs 用户分页器填充的 blob 的内存中表示形式。考虑到这一点,我们可以考虑一种替代的创建 API,其中可舍弃的 VMO 与分页器相关联。VMO 创建调用可能如下所示:

zx_pager_create(0, &pager_handle);

zx_pager_create_vmo(pager_handle, 0, pager_port_handle, vmo_key, vmo_size,
                    &vmo_handle);

锁定和解锁功能将按照之前提出的建议,使用 zx_vmo_op_range() 实现。 只有在解锁后,内核才能自由舍弃 VMO 中的页面。

此方法的优点在于,它提供了一个统一的创建 API,适用于所有类型的可舍弃内存,无论它是文件支持的还是匿名的。

不过,在这种情况下,分页器实际上并没有什么特殊用途。由于它处理的是通用匿名内存,因此很可能只会按需提供零页。如果需要以特殊方式填充包含特定内容的网页,那么分页器会更适合。引入额外的间接层(无论是在技术复杂性方面还是在性能开销方面),仅仅是为了按需创建零页面,这似乎是不必要的;内核中已经存在针对常规(非分页器支持)VMO 的此功能。

使用保留器对象进行锁定

此处提出的锁定 API 可能会出现以下 bug:可舍弃的 VMO 可能被意外(或恶意)解锁。我们可能会遇到这样一种情况:一个进程认为 VMO 已锁定,但另一个进程已将其解锁,即第二个进程发出了额外的解锁请求。这会导致第一个进程在访问 VMO 时出错或崩溃,即使它在访问之前正确锁定了 VMO。

我们可以使用保留器对象来实现锁定,而不是使用锁定和解锁操作。保留器对象会在创建时锁定 VMO,并在销毁时解锁 VMO。

zx_vmo_create_retainer(vmo_handle, &retainer_handle);

只要保持器手柄处于打开状态,VMO 就会保持锁定状态。在上述示例中,这两个进程中的每一个都会使用自己的保留器来锁定 VMO,从而避免出现错误的额外解锁。这种锁定模式可降低此类 bug 的可能性,并在发生此类 bug 时轻松诊断。

缺点是,内核需要存储更多元数据来跟踪 VMO 的锁定状态。现在,我们有一个与可舍弃 VMO 关联的保留器对象列表,而不是单个 lock_count 字段。如果我们想消除恶意用户导致内核无限增长的可能性,可能还需要限制此列表的长度。

回收订单的优先级

为简单起见,内核将按 LRU 顺序回收未锁定的可丢弃 VMO。如果需要,我们可以在未来探索让客户端明确指定回收优先级顺序(每个优先级频段中的 VMO 仍可以按 LRU 顺序回收)。通过 ZX_VMO_OP_UNLOCK 中目前未使用的 zx_vmo_op_range() 参数,所提议的 API 为将来支持此功能留下了空间。buffer

不过,我们可能不需要这种级别的控制;全局 LRU 顺序可能就足够了。如果客户端确实想更好地控制何时回收某些缓冲区,则可以改为选择接收内存压力信号,并自行舍弃这些缓冲区。

与其他回收策略的互动

目前,我们还可以通过以下两种机制回收内存:

  • 用户分页器支持的内存(内存中的 blob)的页面逐出,由内核在严重内存压力级别(接近 OOM)执行。
  • 内存压力信号,其中用户空间组件本身会在 CRITICAL 和 WARNING 内存压力级别释放内存。

我们需要弄清楚可舍弃内存在此方案中的位置,确保没有单一的回收策略承担大部分负担。例如,我们可能希望保持文件支持内存与可丢弃内存之间的某种逐出比率。

锁定分页内存支持的 VMO

我们将来可能会将 ZX_VMO_OP_LOCKZX_VMO_OP_UNLOCK 操作扩展到由分页器支持的 VMO。过去,我们一直希望支持锁定用户分页器支持的 VMO,如果出现具体的用例,我们可能会提供此支持。例如,blobfs 可以将它认为重要或不太适合内核 LRU 逐出方案的 blob 的 VMO 锁定在内存中,从而避免重新分页的性能开销。

锁定由分页器支持的 VMO 可以与可丢弃内存 API 很好地结合使用,因为由用户分页器支持的 VMO 本质上可以视为一种可丢弃内存,其中用户分页器提供了一种专门的机制来重新填充页面。锁定和解锁操作随后会应用于这两种可舍弃内存,这两种内存的主要区别在于它们的创建和填充方式。

决定何时重新填充已舍弃的 VMO

客户端可能需要一种方法来确定何时可以安全地重新填充已舍弃的 VMO。如果 VMO 在内存压力下重新填充,则提交的额外页面可能会加剧系统的内存压力,使其更接近 OOM。此外,一旦 VMO 随后被解锁,如果内存压力持续存在,则它可能会被舍弃。这可能会导致抖动,即客户端反复重新填充 VMO,但很快就会看到内核将其舍弃。

目前,观察系统内存压力级别的唯一机制是订阅 fuchsia.memorypressure 服务,但对于此使用情形,这种方式的成本可能相当高。我们可以考虑扩展此服务,以提供执行一次性查询的方式。我们还可以考虑通过 zx_vmo_lock_state 结构体传递回压力等级的指示器 - 无论是当前的内存压力等级本身,还是粗略捕获系统是否处于内存压力下的布尔值。

用于跟踪未锁定 VMO 访问的调试辅助工具

在 build 标志后启用额外检查可能很有用,这些检查会在未锁定的可丢弃 VMO 上使系统调用失败。这样一来,开发者就可以轻松找到 VMO 访问未先获取锁的 bug,而无需依赖于在内存压力下丢弃 VMO,然后导致失败。随着我们将来添加范围支持,对 VMO 的锁定状态进行此类检查很快就会变得非常耗费资源,因此在生产环境中启用此类检查并不可行,但它们可能会作为调试工具发挥作用。

通过映射捕获未锁定的 VMO 访问可能更难实现。我们可以探索以下几种方法来实现此目标:

  • 在解锁可舍弃的已映射 VMO 时,取消映射该 VMO。采用这种方法,我们需要确保现有的 VMO / VMAR 语义保持不变。
  • 教导锁 / 解锁调用周围的封装容器使用 ASAN_POISON_MEMORY_REGION 接口告知 ASAN,在重新锁定之前,应将未锁定的 VMO 的映射视为中毒。

在先技术和参考资料