RFC-0267:IOBuffer 环形缓冲区管理 | |
---|---|
状态 | 已接受 |
区域 |
|
说明 | IOBuffers 的环形缓冲区纪律。 |
问题 | |
Gerrit 更改 | |
作者 | |
审核人 | |
提交日期(年-月-日) | 2025-01-07 |
审核日期(年-月-日) | 2025-02-20 |
问题陈述
我们希望采用更高效的日志记录机制,以提高日志记录系统的 CPU 使用率,并减轻调度压力。内存用量也是一个问题,但不是本 RFC 的主要重点。
摘要
引入 IO 缓冲区的主要用例之一是支持更高效的日志记录。此 RFC 引入了一种新的环形缓冲区机制,旨在降低日志记录系统的 CPU 使用率和调度压力。
利益相关方
主持人:jamesr@google.com
审核员:adanis@google.com、eieio@google.com、gmtr@google.com、miguelfrde@google.com
咨询对象:内核和诊断团队
社交:
在提交此 RFC 之前,我们已与内核和诊断团队讨论过此提案。
要求
- 归档程序必须知道日志记录组件的身份。
- 必须先支持日志记录,然后才能运行归档程序。
- 此 RFC 不应显著增加总内存用量。
背景
日志记录客户端目前使用 fuchsia.logger.LogSink
:
strict ConnectStructured(resource struct {
socket zx.Handle:SOCKET;
});
日志消息会作为数据报消息发送到套接字。归档程序服务许多套接字。对于每个套接字,它都会读取消息并将其写入环形缓冲区:
- 客户端写入套接字。
- 内核将消息复制到套接字缓冲区。
- 归档管理器唤醒。
- 归档程序会将消息从内核套接字缓冲区复制到环形缓冲区。
设计
实现此 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 算术,这在理论上可以比其他方法获得更高的性能。最后,它为将来添加其他元数据(如果需要)提供了扩展空间。缺点是会浪费一些空间。
系统将通过新的系统调用支持内核中介写入:
// 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
。对于归档程序,这会导致环形缓冲区需要比当前大,因为归档程序必须保持足够的可用空间,以便日志很少被丢弃。
每个日志记录客户端都需要使用新的环形缓冲区机制共享一个区域的自己的 IO 缓冲区。为了支持这一点,我们将引入一个新的 Kernel 对象来表示共享区域:
// 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
信号闪烁。请注意,该信号只能进行脉冲调制,而不能进行有效声明,因为内核无法知道何时取消有效声明该信号。
内存有序写入和写入器同步
以下是原子操作的最低内存有序要求:
初始化
首次初始化环形缓冲区时无需同步。环形缓冲区将从所有标头字段设为零开始。无需支持重新初始化。
写入
并发写入将使用内核中的锁进行同步。
- 使用放宽的语义读取头部,使用获取语义读取尾部。
- 执行验证和空间检查。
- 使用正常写入写入消息。这将作为机会性复制执行。如果发生故障,系统会丢弃锁定,处理故障,然后从第一步重新尝试操作。
- 使用版本语义递增头。
例如,如果行为不端正的客户端将消息的内存源设置为行为不端正的页面器源,则可能会导致所有客户端的写入操作出现延迟。请参阅下文中介绍行为不端的客户端的部分。
读取
- 使用放宽的语义读取尾部,使用获取语义读取头部。
- 对头部和尾部执行检查。在出现错误或没有消息时失败。
- 使用正常读取方式读取消息。
- 使用版本语义递增尾部。
客户端缓冲
目前,某些组件会在归档程序运行之前启动。如果归档程序负责创建 IOBuffer 对,则为了避免死锁,客户端需要缓冲日志,直到收到 IOBuffer。这可能会对在 Archivist 启动之前启动和停止的短时效组件产生影响(因为如果不加注意,日志消息可能会被丢弃)。
或者,需要让组件管理器负责创建 IOBuffer 对象。
若要采用此 RFC,则需要采用这两种方法中的一种,但具体选择哪种方法不在本 RFC 的讨论范围之内。
这假定我们继续使用单个公共环形缓冲区(就像目前的情况一样),但我们可以考虑使用由不同组件创建和使用的多个环形缓冲区(例如,以解决隔离、分配和归因问题)。这超出了本 RFC 的范围,但我们可以说,不应禁止使用此类解决方案。
行为不端的客户端
行为不端的客户端可能会通过写入大量日志而迫使系统丢弃其他日志,或者通过从其拒绝提供后备的页面缓冲区写入内存来干扰其他客户端。
在某种程度上,这些问题已经存在:组件已经可以发送如此多的日志,以致于导致其他日志被丢弃,并且它们可能会占用大量 CPU 和内存,从而降低系统的其余性能。
我们建议继续按照目前的方式处理这些问题:将其视为需要修复的 bug,或用户必须终止的恶意应用(如果产品支持此操作)。
实现
zx_iob_writev
需要稳定下来,然后我们才能使用此 RFC 的任何部分进行日志记录。由于这是客户端需要使用的唯一新 API,因此所有其他建议的更改最初都将是 Zircon 内核的 NEXT vdso 的一部分(可供归档程序使用)。这样,我们就可以根据需要迭代设计。
为了支持此更改,需要更改日志记录协议,但这超出了本 RFC 的范围。
性能
这应该会提高日志记录效率:消息可以直接写入环形缓冲区,而无需唤醒归档程序。系统会监控现有的归档程序和系统基准。
安全注意事项
不适用
隐私注意事项
不适用
测试
我们将添加单元测试。Archivist 提供了适当的集成和端到端测试。
文档
内核系统调用文档将更新。
未来的可能性
我们可能需要考虑支持阻塞写入。我们认为目前不需要这样做,但如果需要,我们可以为
zx_iob_write
提供一个用于进行写入阻塞的选项。日志记录格式目前包含客户端生成的时间戳。我们可以添加选项,以允许内核生成时间戳,这样接收器就可以信任时间戳,但代价是会降低准确性。您或许还可以保证某种排序,但这可能会进一步降低准确性(由于锁争用和需要在写入器中重试)。
为了解决隔离问题,我们可以考虑为不同的子系统支持多个环形缓冲区。
我们日后可能会添加一些选项来支持内核生成的 ID,例如进程 ID、线程 ID 或不会向无特权用户泄露此类详细信息的等效项。
缺点、替代方案和未知情况
在本 RFC 之前,我们讨论了多种替代方案,包括:
我们可以使用一个 IO 缓冲区和一个新的标记写入器内核对象(即多对一配置)。这有一定的优势,但由于会导致跟踪代码方案变得复杂,因此被舍弃了。归档程序需要知道日志记录器何时消失:此 RFC 中的提案依赖于内核现有的对等调度程序模型来实现这一点,而具有标记写入器的单个 IO 缓冲区则需要其他方法。
我们可以从调用线程上的属性派生标记。标记将从进程和作业继承。我们之所以舍弃这种方法,是因为运行程序不一定必须在单独的线程中运行组件。
我们曾考虑过使用流的方法,但由于担心锁争用问题而放弃了该方法,因为流在分页内存上运行,而 IO 缓冲区可以在固定内存上运行。
我们可以将标记与句柄相关联,这意味着我们可以使用单个 IO 缓冲区,然后只需将不同的句柄传递给不同的写入器即可。这需要对 Zircon 内核进行重大重构才能支持,但 API 的质量可能会更差。
我们可以让内核使用 IOBuffer koid 作为标记。这会使 Archivist 需要进行的跟踪变得复杂,因为它需要跟踪每个 IOBuffer 中的消息数,而不是每个组件中的消息数。