| RFC-0267:IOBuffer 环形缓冲区规范 | |
|---|---|
| 状态 | 已接受 |
| 区域 |
|
| 说明 | 一种适用于 IOBuffers 的环形缓冲区规范。 |
| 问题 | |
| Gerrit 更改 | |
| 作者 | |
| 审核人 | |
| 提交日期(年-月-日) | 2025-01-07 |
| 审核日期(年-月-日) | 2025-02-20 |
问题陈述
我们希望采用更高效的日志记录机制,以提高日志记录系统的 CPU 使用率并缓解调度压力。内存用量也是一个需要考虑的问题,但不是此 RFC 的主要关注点。
摘要
引入 IO 缓冲区的主要使用场景之一是支持更高效的日志记录。此 RFC 引入了一种新的环形缓冲区规范,旨在通过日志记录系统提高 CPU 使用率并缓解调度压力。
利益相关方
Facilitator: jamesr@google.com
审核者:adanis@google.com、eieio@google.com、gmtr@google.com、miguelfrde@google.com
咨询对象:内核和诊断团队
共同化:
此提案已在提交此 RFC 之前与内核和诊断团队讨论过。
要求
- 归档程序必须知道日志记录组件的身份。
- 在 Archivist 运行之前,必须支持日志记录。
- 此 RFC 不应显著增加总内存用量。
背景
日志记录客户端目前使用 fuchsia.logger.LogSink:
strict ConnectStructured(resource struct {
socket zx.Handle:SOCKET;
});
日志记录消息以数据报消息的形式在套接字上发送。Archivist 服务 许多套接字。对于每个套接字,它会读取消息并将其写入环形缓冲区:
- 客户端写入套接字。
- 内核将消息复制到套接字缓冲区。
- 档案管理员醒来。
- Archivist 将消息从内核套接字缓冲区复制到环形缓冲区。
设计
实现此 RFC 后,写入日志将变为:
- 客户端对 IO 缓冲区执行中介写入。
- 内核将消息复制到 IO 缓冲区区域。
将添加新的环形缓冲区规范:
#define ZX_IOB_DISCIPLINE_TYPE_MEDIATED_WRITER_RING_BUFFER ((zx_iob_discipline_type_t)(2u))
typedef struct zx_iob_discipline_mediated_writer_ring_buffer {
uint64_t tag;
uint64_t reserved[7];
} zx_iob_discipline_mediated_writer_ring_buffer_t;
此规范最初将支持并发内核介导的写入,并具有单个用户空间读取器。将不支持用户空间写入和内核介导的读取。此规范最初只能与共享区域(如下所述)搭配使用。预留字段将来可能会用于配置行为,例如启用或停用用户空间写入。
该规范允许指定内核将写入环形缓冲区的标记,并允许 Archivist 了解客户端的身份(它可以存储从标记到组件身份的映射)。
采用此规范的地区的第一页将包含一个标题,该标题包含以下内容:
uint64_t head;
uint64_t tail;
其余区域(从第二页开始)将用作环形缓冲区,这意味着该区域必须至少为两页,并且环形缓冲区的大小必须是 2 的幂。在页面边界上启动环形缓冲区可让客户端在末尾映射环形缓冲区的开头,从而更轻松地处理环绕问题。它还允许对头部值和尾部值使用模 2 算术,从理论上讲,这比其他方法更高效。最后,它还为将来可能需要的其他元数据提供了增长空间。缺点是会浪费一些空间。
将通过新的系统调用支持内核介导的写入:
// Performs a mediated write to the specified IO Buffer region.
//
// The maximum size of the data to be written is 65,535 bytes.
//
// |options| is reserved for future use and must be zero.
// |region_index| specifies which region to write to. It must use a discipline
// that supports mediated writes.
//
// * Errors
//
// `ZX_ERR_ACCESS_DENIED`: Input handle does not have sufficient rights or |data| is
// not readable.
// `ZX_ERR_BAD_STATE`: The ring buffer is in an invalid state (e.g. tail > head).
// `ZX_ERR_BAD_TYPE`: Input handle is not an IO Buffer.
// `ZX_ERR_INVALID_ARGS`: |options| or |region_index| are invalid, or there is an
// attempt to write more than 65,535 bytes.
// `ZX_ERR_NO_SPACE`: There is no space in the ring buffer.
zx_status_t zx_iob_writev(
zx_handle_t iob_handle, uint64_t options, uint32_t region_index,
const zx_iovec_t* vectors, size_t num_vectors);
此系统调用最初仅适用于新的环形缓冲区规范。对环形缓冲区的所有写入操作都将包含一个 8 字节的标头,该标头包含 IO 缓冲区的标记(8 字节),后跟一个 8 字节的长度,用于指示后续的字节数。所有写入操作都将进行填充以保持 8 字节对齐,但长度不必是 8 字节的倍数。
head 和 tail 指针将通过适当的屏障以原子方式更新。最初,系统将通过使用内核中的锁来支持并发写入,这是可行的,因为系统仅支持中介写入。head 和 tail 指针只会递增;由于它们是 64 位,因此在我们的生命周期内不会发生环绕。环形缓冲区偏移量将使用模运算来确定。
只有内核会递增 head。只有用户空间会递增 tail。
用户空间将负责在环形缓冲区内维护足够的空间。如果空间不足,zx_iob_writev 将失败并返回 ZX_ERR_NO_SPACE。对于 Archivist,这会带来一个后果,即环形缓冲区需要比当前更大,因为 Archivist 必须保持足够的可用空间,以便很少丢弃日志。
每个日志记录客户端都需要自己的 IO 缓冲区,该缓冲区使用新的环形缓冲区规范共享一个区域。为了支持这一点,将引入一个新的内核对象来表示共享区域:
// Creates a shared region that can be used with an IO Buffer.
//
// |size| is the size of the shared region.
// |options| is reserved for future use and must be zero.
//
// * Errors
//
// `ZX_ERR_INVALID_ARGS`: The size is not a multiple of the page size.
zx_status_t zx_iob_create_shared_region(uint64_t options, uint64_t size, zx_handle_t* out);
可以使用此共享区域创建多个 IO 缓冲区。zx_iob_region_t 将延长:
#define ZX_IOB_REGION_TYPE_SHARED ((zx_iob_region_type_t)(1u))
// If the type is ZX_IOB_REGION_TYPE_SHARED, the size comes from the shared region and
// |size| must be zero.
struct zx_iob_region_t {
uint32_t type;
uint32_t access;
uint64_t size;
zx_iob_discipline_t discipline;
union {
zx_iob_region_private_t private_region;
zx_iob_region_shared_t shared_region;
uint8_t max_extension[4 * 8];
};
};
// |options| is reserved for future use and must be zero.
// |shared_region| is a handle (not consumed) referencing a shared region created with
// |zx_iob_create_shared_region|. |padding| must be zeroed.
struct zx_iob_shared_region_t {
uint32_t options;
zx_handle_t shared_region;
uint64_t padding[3];
};
成功写入环形缓冲区后,共享区域的 ZX_IOB_SHARED_REGION_UPDATED 信号将闪烁。这样,Archivist 就可以知道何时有新消息写入共享区域(如果需要,例如有下游日志读取器)。
归档器可以通过监控每个客户端的端点上的信号 ZX_IOB_PEER_CLOSED 来监控日志记录客户端何时消失。
读取阈值
与 ZX_SOCKET_READ_THRESHOLD 类似,将有一个可设置的属性 ZX_IOB_SHARED_REGION_READ_THRESHOLD,该属性会在写入后跨越阈值时导致 ZX_IOB_SHARED_REGION_READ_THRESHOLD 信号闪烁。请注意,该信号只能是频闪信号,不能是断言信号,因为内核无法知道何时取消断言该信号。
内存顺序和写入器同步
以下是原子操作的最低内存顺序要求:
初始化
首次初始化环形缓冲区时,无需进行同步。环形缓冲区将从所有标头字段归零开始。无需支持重新初始化。
写入
并发写入将使用内核中的锁进行同步。
- 以宽松的语义读取头部,以获取语义读取尾部。
- 执行验证和空间检查。
- 使用正常写入操作写入消息。此操作将作为机会性复制执行。如果发生故障,系统会放弃锁定,处理故障,然后从第一步开始重试操作。
- 使用发布语义递增 Head。
如果行为不当的客户端将消息的内存来源设置为行为不当的分页器来源,则可能会导致所有客户端的写入操作停滞。请参阅下文中有关行为不当的客户端的部分。
读取
- 以宽松的语义读取尾部,以获取语义读取头部。
- 对头部和尾部执行检查。如果出现错误或没有消息,则失败。
- 使用正常读取方式读取消息。
- 使用发布语义递增尾部。
客户端缓冲
某些组件目前在 Archivist 运行之前启动。如果 Archivist 负责创建 IOBuffer 对,那么为了避免死锁,客户端需要缓冲日志,直到收到 IOBuffer。这可能会对在 Archivist 启动之前启动和停止的短期组件产生影响(因为如果不小心,日志消息可能会被丢弃)。
或者,需要让组件管理器负责创建 IOBuffer 对象。
若要采用此 RFC,需要采用这两种方法之一,但选择哪种方法不在本 RFC 的讨论范围内。
这假设我们继续使用当前使用的单个通用环形缓冲区,但我们可能会考虑使用由不同组件创建和使用的多个环形缓冲区(例如,为了解决隔离、分配和归因问题)。这超出了本 RFC 的范围,但可以说明的是,不应禁止此类解决方案。
行为不当的客户
行为不端的客户端可能会写入大量日志,导致其他日志被丢弃,从而扰乱其他客户端。例如,它们可能会从拒绝提供后备支持的分页内存中写入数据。
在某种程度上,这些问题已经存在:组件已经可以发送如此多的日志,以至于导致其他日志被丢弃,并且它们可以占用 CPU 和内存,以至于降低了系统其余部分的性能。
我们建议,我们应像现在一样处理这些问题:要么将其视为需要修复的 bug,要么将其视为用户必须终止的糟糕应用(如果产品支持这样做)。
实现
在将此 RFC 的任何部分用于日志记录之前,需要先稳定 zx_iob_writev。由于这是客户端需要使用的唯一新 API,因此所有其他提议的更改最初都将成为 Zircon 内核的 NEXT vdso(可供 Archivist 使用)的一部分。这样,我们就可以根据需要迭代设计。
需要更改日志记录协议才能支持此更改,但这些更改不在本 RFC 的讨论范围内。
性能
这应该可以提高日志记录效率:消息可以直接写入环形缓冲区,而无需唤醒 Archivist。系统会监控现有的 Archivist 和系统基准。
安全注意事项
不适用
隐私注意事项
不适用
测试
系统将添加单元测试。Archivist 具有合适的集成和端到端测试,可供使用。
文档
内核系统调用文档将更新。
未来可能性
我们可能需要考虑支持阻塞写入。我们认为目前不需要这样做,但如果需要,我们可以提供一个选项来将写入设为阻塞。
zx_iob_write日志记录格式目前包含客户端生成的时间戳。我们可以添加一些选项,以允许内核生成时间戳,这样接收方就可以信任时间戳,但会牺牲一些准确性。虽然也可以保证某种顺序,但这可能会进一步降低准确性(由于写入器中的锁争用和需要重试)。
为了解决隔离问题,我们可以考虑为不同的子系统支持多个环形缓冲区。
我们可能会在未来添加支持内核生成的 ID 的选项,例如进程 ID、线程 ID 或不会向非特权用户泄露此类详细信息的等效项。
缺点、替代方案和未知因素
在此 RFC 之前,我们讨论了多种替代方案,包括:
我们可以使用一个具有新的标记写入器内核对象的 IO 缓冲区,即多对一的安排。这种方法有一些优势,但由于会导致跟踪代码的方案过于复杂,因此被舍弃。Archivist 需要知道记录器何时消失:此 RFC 中的提案依赖于内核现有的对等调度程序模型,而带有标记写入器的单个 IO 缓冲区则需要不同的方法。
我们可以从调用线程的属性派生出标记。标记将从进程和作业继承。此方法已被弃用,因为 runner 不一定需要在单独的线程中运行组件。
我们曾考虑过使用流的方法,但由于担心锁定争用问题而放弃了该方法,因为流在分页内存上运行,而 IO 缓冲区可以在固定内存上运行。
我们可以将标记与句柄相关联,这意味着我们可以使用单个 IO 缓冲区,然后只需将不同的句柄传递给不同的写入器。这需要对 Zircon 内核进行重大重组才能支持,但 API 可能会更糟糕。
我们可以让内核使用 IOBuffer koid 作为标记。这会使 Archivist 需要进行的跟踪变得复杂,因为它需要跟踪每个 IOBuffer 中的消息数量,而不是每个组件中的消息数量。