RFC-0012:Zircon 可舍弃内存

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

介绍了用户空间应用以向内核表明可回收某些内存缓冲区的机制。

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

总结

该 RFC 描述了一种用户空间应用机制,该机制可向内核表明某些内存缓冲区可以回收。这样一来,当系统可用内存不足时,内核可以自由舍弃这些缓冲区。

设计初衷

在 Zircon 等过度使用系统中,管理可用内存是一个复杂的问题,在该系统中,用户应用可以分配比系统上可能可用的内存更多的内存。这是通过使用物理页面在被提交时由物理页面延迟支持的虚拟内存对象 (VMO) 来实现的。

高估在任何时间点要使用的物理内存量,并根据该估算值停止其他内存分配请求,可能会使表上留有可用内存。这可能会影响性能,因为应用会使用大量此类内存进行缓存。另一方面,低估正在使用的可用内存量可能会导致我们快速耗尽系统上的所有可用内存,从而导致内存不足 (OOM)。此外,“可用”内存本身的定义也很复杂。

Zircon 内核会监控可用物理内存量,并生成不同级别的内存压力信号。这些信号的用途是,使用户空间应用能够根据系统级可用内存水平缩容(或增加)其内存占用量。虽然这有助于防止系统耗尽内存,但这些信号的发起者(内核)与响应程序(用户应用)分离并不理想。响应内存压力的进程没有关于应释放多少内存的足够上下文;内核可以更清楚地了解系统上的全局内存使用情况,而且它还可以考虑其他形式的可回收内存,例如可逐出的用户分页器支持的内存。

此 RFC 提出了一种机制,该机制可让内核在内存紧张时直接回收用户空间内存缓冲区。这种方法具有以下优势:

  • 这样可以更好地控制逐出的内存量;内核可以查看可用内存级别并仅在必要时逐出内存。
  • 内核可以使用 LRU 架构来舍弃内存,这样做可能更适合于内存中的当前工作集。
  • 用户空间有时可能会因内存压力信号而减慢丢弃内存。在某些情况下,系统恢复可能为时已晚。
  • 用户空间客户端为应对内存压力而唤醒有时可能需要更多内存。

设计

概览

可舍弃内存协议的工作原理大致如下所示:

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

请注意,可舍弃内存不能直接替代内存压力信号。对于其他组件级决策(例如选择何时启动内存密集型 activity 或线程),观察内存压力变化仍然很有价值。将来,我们还可以使用这些信号终止组件中的空闲进程。内存压力信号还使组件能够更好地控制要释放的内存和释放时间。

可舍弃的内存 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 的状态标记为“无法舍弃”。客户端可以使用适用于常规 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 的限制

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

  • 此 API 与 VMO 克隆(快照和 COW 克隆)和切片不兼容,因为舍弃克隆层次结构中的 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 没有提交页面一样,并且不存在按需提交页面的机制。例如,zx_vmo_read() 将失败并显示 ZX_ERR_OUT_OF_RANGE。如果 VMO 已在进程的地址空间中映射,则未锁定对映射地址的访问将导致严重的页面故障异常。

内核实现

跟踪元数据

  • 将扩展 VmObjectPaged 中的 options_ 位掩码,以支持 kDiscardable 标记;我们目前仅使用 4 位,共 32 位。
  • 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 的所有页面,并将 VMO 的内部状态设置为 discarded。如果 VMO 的状态为 discarded,则 VmObjectPaged::GetPageLocked() 将失败并显示 ZX_ERR_NOT_FOUND。在后续的 ZX_VMO_OP_LOCK 操作中,discarded 状态会被清除。GetPageLocked() 是一个函数,对 VMO 页面的所有访问均通过 zx_vmo_read/write 系统调用和通过虚拟机映射的页面访问均有漏斗。这使我们能够在已舍弃的已解锁 VMO 上使系统调用失败,并在通过映射访问已舍弃的已解锁 VMO 时生成异常。

实现

这是一个新 API,因此在此阶段没有依赖项。内核端实现可以单独完成。实现该 API 后,用户空间客户端便可开始采用它。

性能

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

  • 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 大小,以及向用户显示的外部大小。虽然使用内部实现定义的大小是一项实用技巧,可能也会对未来的其他用例带来好处,但如果采用两种不同的大小概念,则会导致混乱且容易出错。因此,如果我们没有其他具体用例,让外部大小和内部大小能够明显受益,那么我们选择避免采用这种方法。

使用原子技术更快地锁定 API

此锁定优化提供了一种另一种低延迟选项来锁定和解锁可舍弃的 VMO,旨在供预计会相当频繁地锁定和解锁的客户端使用。这纯粹是一项性能优化,因此我们将来会根据需要增加一项功能。

该 API 使用名为 Metex 的锁定基元(与 Zircon futex 类似),因为它允许通过用户空间原子进行快速锁定,从而节省系统调用的成本。

可舍弃的 VMO 可与 meex(而不是 zx_vmo_op_range() 系统调用)关联,后者将用于锁定和解锁。Metex 可能有三种状态:已锁定(由用户空间客户端使用)、可舍弃(符合内核回收条件)和“需要系统调用”(可能已被内核回收,需要系统调用才能检查状态)。通过以原子方式在已锁定和可舍弃之间转换 MEEX 的状态,可以锁定和解锁 VMO,而无需进入内核。当内核舍弃 VMO 时,会以原子方式将其状态切换为“needs syscall”,表明客户端需要与内核同步以检查舍弃状态。此方案的更多详细信息不在此 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 会为可舍弃的 VMO 可能无意中(或恶意)解锁的 bug 留出余地。我们可能遇到这样的情况:某个进程认为 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_UNLOCKzx_vmo_op_range() 中当前未使用的 buffer 参数在未来支持此功能。

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

与其他回收策略交互

目前,我们可以通过另外两种机制来回收内存:

  • 由内核在关键内存压力级别(接近 OOM)逐出用户分页器支持的内存(内存 blob)。
  • 内存压力信号,其中用户空间组件本身在内存压力级别为“CRITIAL”和“WARNING”时释放内存。

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

锁定由寻呼机支持的 VMO

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

锁定由分页器支持的 VMO 与可舍弃内存 API 完美契合,因为用户分页器支持的 VMO 实际上可以被视为一种可舍弃内存,其中用户分页器提供了一种用于重新填充页面的专用机制。然后,锁定和解锁将应用于这两种类型的可舍弃内存,这两种类型之间的主要区别在于它们的创建和填充方式。

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

客户端可能需要通过某种方式来确定重新填充被舍弃的 VMO 何时可以安全。如果在内存紧张时重新填充 VMO,则提交的额外页面可能会加大系统的内存压力,使其更接近 OOM。此外,一旦 VMO 随后被解锁,如果内存压力持续,则有可能将其舍弃。这可能会导致抖动,即客户端反复重新填充 VMO,最终发现内核很快就将其舍弃。

目前,观察系统内存压力水平的唯一机制是订阅 fuchsia.memorypressure 服务,对于此用例而言,费用可能相当高昂。我们可以考虑扩展此服务,以提供一种执行一次性查询的方法。我们还可以考虑通过 zx_vmo_lock_state 结构体传回一个压力水平指示符,它可以是当前内存压力水平本身,也可以是粗略捕获系统是否面临内存压力的布尔值。

用于跟踪已解锁的 VMO 访问权限的调试辅助功能

在构建标志后面启用额外的检查可能很有用,这些检查使已解锁的可舍弃 VMO 上的系统调用失败。这有助于开发者轻松找到 VMO 访问前面没有锁的 bug,而不必依赖于在内存紧张时 VMO 被舍弃,然后才导致失败。随着我们未来增加范围支持,对 VMO 锁定状态的此类检查费用可能会迅速增加,因此在生产环境中启用此类检查并不可行,但事实证明,它们作为调试工具可能会很有用。

通过映射捕获已解锁的 VMO 访问权限可能更难以实现。为实现这一目标,我们可以采用以下几种方法:

  • 当映射的可舍弃 VMO 处于解锁状态时,取消映射该 VMO。如果使用此方法,我们需要确保现有的 VMO / VMAR 语义保持不变。
  • 告知封装容器锁定 / 解锁调用,告知 ASAN 已解锁的 VMO 的映射应被视为中毒,直到它再次被锁定(使用 ASAN_POISON_MEMORY_REGION 接口)。

早期技术和参考资料