跳转至

静态分配与恒定工作量:构建高可靠系统的架构模式

文章背景与核心概要

本文探讨了内存安全性、代码正确性与系统设计之间的交叉领域,并以一封关于限价单撮合引擎的邮件讨论为切入点。文章分析了对象回收池如何导致物理类型混淆或逻辑错误,并讨论了类型分配器和隔离池如何缓解这些问题。

TigerStyle 编程规范的启发,本文主张采用两种核心架构模式来构建强大、可预测的系统:静态分配(在初始化后禁止动态分配,以优雅地处理过载情况)和恒定工作量(消灭创建/销毁循环,改用具有中性状态机的固定对象池,从而确保平稳的延迟和最优的硬件利用率)。


对象池的陷阱与内存安全

在回应关于限价单撮合引擎中释放后使用(use-after-free)漏洞的讨论《内存安全最棘手的问题》(Memory Safety’s Hardest Problem)时:

Memory Safety’s Hardest Problem named something I’d hit but couldn’t articulate. Your case is a pointer into one union variant surviving a write of a different variant, so live typed pointers end up reading bytes that belong to something else now.

Last year I wrote a limit-order matching engine and shipped a use-after-free: a cancelled order was released back to the pool while it was still linked into its price level, so the next allocation handed that memory to a new order and the stale link kept resolving. I’d filed it under “I was careless with lifetimes.”

《内存安全最棘手的问题》说出了我曾遇到过却无法表达的痛点。你的情况是指向某个联合体(union)变体的指针在写入另一个变体后依然存活,导致现存的类型指针最终读取了现在属于其他东西的字节。

去年我写了一个限价单撮合引擎,并且发布了一个释放后使用(use-after-free)漏洞:一个已取消的订单在仍挂在其价格档位链表中的时候就被释放回了对象池,因此下一次分配将该内存交给了新订单,而陈旧的链接一直在解析。我当时把它归咎于“我对生命周期的管理不够小心”。

对象池在内存安全和整体正确性之间提供了一个有趣的交集。如果没有对象池,mallocfree 可能会将逻辑上的“释放后使用”漏洞转化为物理上的类型混淆,从而可能导致任意代码执行(例如,被覆盖的整数充当了函数指针)。

当引入类型化对象池时,逻辑上的“释放后使用”仍然可能发生,但物理影响发生了变化:内存别名仍然存在,但类型混淆在很大程度上得以避免(除非涉及内联枚举)。这表明了一种加固策略——借鉴自 Fil-C——即类型化分配函数在内部使用类型隔离池。尽管这种方法在内存效率上略有逊色,但它消除了绝大多数类型混淆,并能改善内存局部性。


静态分配

为了彻底避免生命周期和分配漏洞,我们可以参考 TigerStyle 指南。其核心的第一条规则是:

初始化后禁止动态内存分配

这把对象池的理念推向了其逻辑终点。通过在启动时指定系统可以处理的最大项目数,初始化过程看起来像这样:

$ order-engine --orders-max=1_000_000
const orders: []Order = try gpa.alloc(Order, cli_args.orders_max);

如果请求超出 orders_max,超出的部分将被拒绝,而不是试图进行动态分配。在没有严格限制的情况下满负荷运行的系统往往会发生灾难性故障(例如,触发内核的 OOM 杀手并丢失所有活动工作)。静态分配保证了如果系统成功启动,它将优雅地处理过载,同时可以扩展资源配置。


恒定工作量

与其通过位图(bitsets)或空闲链表来追踪动态对象:

const OrderPool = struct {
    orders: []Order,
    free: DynamicBitSet,

    fn acquire(pool: *OrderPool) ?*Order { ... }
    fn release(pool: *OrderPool, order: *Order) { ... }
};

你可以通过引入一个中性的、无操作(no-op)的变体,将系统设计为始终维持恒定的工作量

const Order = {
    id: u128,
    price: u32,
    count: u32,

    tag: enum { bid, ask, reserved },

    pub const reserved: Order = .{
        .id = 0,
        .price = 0,
        .count = 0,
        .tag = .reserved,
    };
};

初始化只需使用 @memset(orders, .reserved) 即可。

恒定工作量的好处

  1. 认知简单性: 订单根据守恒定律在系统中循环,而不是被创建和销毁。穷尽的状态转换变得更容易推理和断言。
  2. 可预测的性能: 通过遍历整个集合并对保留状态执行空操作,无论当前负载如何,系统都能保持平稳的 P100 延迟。
  3. 硬件友好性: 避免间接寻址(indirection)使编译器能够对循环进行向量化,并允许 CPU 缓存进行高效预取:
// 易于向量化和预取
for (orders) |order| {
    process(order);
}

在最大负载下,这比稀疏索引遍历的表现要好得多:

for (orders_active) |order_index| {
    const order = orders[order_index];
    process(order);
}

在 TigerBeetle 中,这种模式甚至被应用在更小的规模上——例如让循环完整运行而不是使用提前返回(early returns),同时断言唯一性(参见 TigerBeetle Grid 实现)。


和往常一样,这是你架构工具箱中一件有用的工具,但它并不是解决所有编程问题的通用灵丹妙药。