静态分配,固定工作量
Static Allocation, Constant Work

原始链接: https://matklad.github.io/2026/09/02/static-allocation-constant-work.html

本次讨论探讨了内存安全与系统架构的交叉点,重点关注“释放后使用”(use-after-free)漏洞及类型混淆问题。尽管对象池可以在一定程度上降低风险,但并不能完全消除逻辑错误。 为了增强系统安全性,作者提出了两种主要策略: 1. **类型隔离分配**:使用按类型分组的分配器来存储对象,这可以防止物理层面的类型混淆,从而确保即使在内存被误用的情况下,系统依然保持在已定义的状态。 2. **静态分配与固定工作量**:作者建议不在运行时进行动态分配,而是在启动时预分配固定容量的资源。通过将内存视为一个守恒的对象池(包括“预留”或中性状态),开发者可以避免追踪生命周期的复杂性。 这种“固定工作量”方案迫使开发者在设计初期就考虑系统极限,将不可预测的“灰色故障”替换为稳定且确定性的性能。它还通过消除对动态集合的追踪简化了代码,使系统对缓存更友好且易于验证。最终,这些模式将重点从管理内存状态转移到了定义清晰且详尽的状态转换上,从而构建出更健壮、可预测的高性能系统。

这篇 Hacker News 讨论探讨了“TigerStyle”编程模式。该模式强调在程序启动时进行静态内存分配,从而避免动态分配带来的性能开销和风险(如释放后使用错误或不可预见的内存溢出错误)。 支持者认为,这种方法通过确保恒定且可预测的内存使用并提高缓存局部性,增强了高频交易和嵌入式系统的可靠性与性能。开发者不再使用动态结构,而是预先分配“自描述”对象池,并使用状态标记来区分有效数据与“空操作”占位符。 批评者指出,这种设计可能会造成浪费,可能掩盖负载下的性能问题,或引入不必要的分支判断。其他人则指出,虽然这种方法在嵌入式开发中是标准做法,但未必适合更倾向于灵活内存管理的通用消费类软件。一些参与者认为,这反映了重新发掘早期计算中底层优化技术这一更广泛的趋势。最终,大家的共识是,尽管该模式对于关键任务系统是一个强大的工具,但它施加了显著的设计约束,需要基于特定的性能或可靠性需求来合理使用。
相关文章

原文

In reply to this email:

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.”

After your post I’m not sure that’s what it was. A recycling pool looks like a tagged union where the tag is “which generation of object currently lives in this slot,” and nothing in the type system tracks it. Is that a fair reading, or does the pool case stay genuinely easier because generational indices actually solve it and the union case has no equivalent?

Yes, object pools are an interesting case to think about, as they clarify the relation between memory safety and and more general correctness.

First, consider the case where no object pool is used, and we malloc and free order objects. In this case, the logical error of use-after-free turns into physical type confusion, and can easily lead to arbitrary code execution and the like. If you have two objects of different types sharing the same memory location, a user-controlled integer in one object might be a function pointer in the other: an exploitable goto primitive

Now, what happens if we introduce an object pool which stores a list of “dead” objects of type T? Logical use-after-free is still possible, but its physical effect is now different — we still get aliasing of memory, but there’s no type confusion. You can’t necessarily fiddle with an integer and change a function pointer, unless you additionally hit the hard case, where the object in question stores an inline enum. Assuming that doesn’t hapen, you get a perfectly defined, deterministic behavior, even if you are not happy about the result.

This suggests an interesting solution for hardening code, which I’ve learned from Fil. If your allocation function is typed (it takes a T comptime parameter or runtime type witness, rather than a runtime type-erased size and alignment), you can write an allocator that uses type-segregated pools internally. This will be somewhat less memory efficient, as the allocator won’t be able to re-use freed memory of objects of type U for objects of type T, but the memory overhead will probably be small (rare object types do not matter, popular object types will have a lot of intra-type re-use), you might actually gain in memory locality, and solve most of type confusions. Again, inline enums break this, but, curiously, if you always heap allocate enum variants, then this works again. Fil-C can’t use this, because C allocator’s interface is untyped, but someone else could :P

But this is academic. How do we avoid the bugs? Generational indexes are a popular remedy, but I have never used them, so I don’t have any non-common knowledge insights about this pattern. Instead, I will share another pair of tricks from TigerStyle. I have only a vague understanding of what an order matching engine is, but I suspect these tricks might help there

The first one is:

No dynamic memory allocation after initialization

https://www.youtube.com/watch?v=GRJtYwneG2Q&t=1823s

This is the pool idea, taken to its logical conclusion. We specify the maximum number of orders we are willing to work with at startup, and never go beyond that. So, you might start the program as

$ order-engine --orders-max=1_000_000

and then one of the first lines in its main function would be :

const orders: []Order = try gpa.alloc(Order, cli_args.orders_max);

If, at runtime, more than orders_max requests come in, the surplus requests are rejected. Someone might object: “But what if I actually have some spare memory for one more order? Wouldn’t it be a good idea to at least try to handle it?”

My rejoinder would be “Well, what if you don’t?”. Systems operating at capacity without strict limits fail catastrophically. Attempting to allocate just one more Order could cause kernel’s OOM killer to terminate the entire order matching engine, losing the other million orders, or, better yet, to kill the supervisor process so that you can’t even restart.

Static allocation gives you peace of mind. The system might fail to start if you don’t have enough memory, but, if it did start, you can be rest assured that it would handle overload gracefully, continuing to render the service while you are provisioning a beefier machine.

What would you do with the slice of orders? One approach is to @memset(orders, undefined) and hand the slice over to a pool which tracks spare objects with a bit set:

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

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

or with a free list:

const OrderPool = struct {
    orders: []union {
        order: Order,
        next_free: ?u32,
    },
    first_free: ?u32,
};

But there’s an alterative approach. Instead of thinking about a limit on the number of orders, you could instead design the system to always have a fixed amount of orders, by introducing a no-op, neutral order:

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,
    };
};

Your initialization then becomes @memset(orders, .reserved).

One benefit here is cognitive, you no longer think in terms of creating and destroying orders. Instead, the orders merely circulate in the system according to the law of the conservation of the number of orders. It becomes harder to loose track of an order if you must always pay attention not only to where the order goes, but also to where it came from. You explicitly write state transition functions for each pair of states, and that makes it easier to exhaustively enumerate all the cases. And you double check that with asserting, at every point, that the state is what you expect it to be (and then you DST the asserts) .

Another benefit is code simplification and predictability. You no longer need to track a separate collection of “live” orders. Instead, you always iterate the full set, doing no-ops for reserved. This feels wasteful: should we make the code run faster when there are few orders? But consider this: by specifying the limit of orders up-front, you commit to be able to serve that amount. If the maximum amount of orders is active, does the system have acceptable performance? If not, that is a bug! Gray failure (system becoming unusably slow) is another way to break when reaching the limit.

Avoiding indexes improves performance for the maximal load case. This

for (orders) |order| {
    process(order)
}

is much easier for compiler to vectorize, and for CPU Cache to prefetch, than this:

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

Similarly to static allocation, the Constant Work principle gives you peace of mind with respect to performance. P100 latency stays flat regardless of the load. Insufficient performance is discovered when you roll out the system, not during Black Friday on-call.

At TigerBeetle, we apply this pattern in the small. Rather than writing a search loop with an early return:

const item = for (items) |item| {
    if (predicate(item)) break item;
} else null;

we sometimes let the loop to run its full natural course, additionally asserting that theres a unique matching item:

https://github.com/tigerbeetle/tigerbeetle/blob/0.17.9/src/vsr/grid.zig#L715-L725


As usual, this is a trick which is useful to have in your arsenal, but it isn’t a universal solution to all programming’s problems.

联系我们 contact @ memedata.com