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,以指示它正在使用中。
  3. 该进程在完成后会解锁 VMO,表示不再需要它。内核会将所有处于解锁状态的可丢弃 VMO 视为可回收,并在内存压力下随意丢弃它们。
  4. 当进程需要再次访问 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 结构体的输出指针。此结构体旨在让内核传回客户端可能认为有用的信息,并且包含以下内容:

    • 跟踪已锁定的范围的 offsetsize:这些是客户端传入的 sizeoffset 参数。返回这些值只是为了方便,以便客户端无需单独跟踪范围,而是可以直接使用返回的结构体。如果调用成功,它们将始终与传入 zx_vmo_op_range() 调用的 sizeoffset 值相同。
    • 跟踪已舍弃范围的 discarded_offsetdiscarded_size:这是包含已舍弃页面的已锁定范围内的最大范围。此范围内的所有网页可能并非全部被舍弃,它只是此范围内所有已舍弃子范围的并集,并且可能包含尚未舍弃的网页。使用当前 API 时,如果内核已舍弃某个范围,则该范围将跨整个 VMO。如果未舍弃,则 discarded_offsetdiscarded_size 都将设置为零。
  • 锁定本身不会提交 VMO 中的任何页面。它只是将 VMO 的状态标记为“不可丢弃”(kernel)。客户端可以使用适用于常规 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 的限制

  • 可丢弃内存 API 仅适用于 VmObjectPaged 类型,因为 VmObjectPhysical 无法丢弃。

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

  • 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 就对其执行任何进一步操作,都会失败,就像 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 的状态为 discardedVmObjectPaged::GetPageLocked() 将失败并返回 ZX_ERR_NOT_FOUND。在后续的 ZX_VMO_OP_LOCK 操作中,系统会清除 discarded 状态。GetPageLocked() 是所有对 VMO 页面的访问汇总到的函数,包括通过 zx_vmo_read/write 系统调用和通过虚拟机映射进行的页面访问。这样,我们就可以对已废弃的已解锁 VMO 失败调用系统调用,还可以在通过映射访问已废弃的已解锁 VMO 时生成异常。

实现

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

性能

性能影响将因客户端用例而异。客户在使用该 API 时需要注意以下几点。

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

安全注意事项

无。

隐私注意事项

无。

测试

  • 从多个线程调用新 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 相关联,后者将用于锁定和解锁 VMO,而不是 zx_vmo_op_range() 系统调用。metex 可以有三种状态:锁定(由用户空间客户端使用)、可丢弃(可由内核回收)和“需要系统调用”(可能已由内核回收,需要系统调用来检查状态)。通过在已锁定和可丢弃状态之间原子地翻转 metex 的状态,可以在不进入内核的情况下锁定和解锁 VMO。当内核舍弃 VMO 时,它会原子地将其状态切换为“needs syscall”,表示客户端需要与内核同步以检查已舍弃的状态。此提案的更多详细信息超出了本 RFC 的范围,将在单独的 RFC 中提供。

基于分页器的创建 API

任何由分页器支持的 VMO 本质上都是可丢弃的 VMO,因为分页器提供了一种机制来按需重新填充已丢弃的页面。本文档中提出的可丢弃内存类型是匿名可丢弃内存;另一种类型是有文件支持的可丢弃内存,例如由 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 的可能性,并在出现时轻松进行诊断。

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

回收顺序的优先级

为简单起见,内核将按 LRU 顺序回收未锁定的可丢弃 VMO。我们可能会在未来探索让客户端根据需要明确指定回收优先级顺序(每个优先级带中的 VMO 仍可按 LRU 顺序回收)。我们提出的 API 为未来通过 zx_vmo_op_range() 中的 ZX_VMO_OP_UNLOCK 支持此功能留有余地,因为 zx_vmo_op_range() 中目前未使用的 buffer 参数可用于此目的。

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

与其他回收策略的互动

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

  • 用户分页器支持的内存(内存中 blob)的页面驱逐,由内核在 CRITICAL 内存压力级别(即接近 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 / VMAR 语义保持不变。
  • 教会锁定 / 解锁调用的封装容器,以便使用 ASAN_POISON_MEMORY_REGION 接口告知 ASAN,在解锁的 VMO 再次锁定之前,应将其映射视为已被破坏。

在先技术和参考文档