The problem this solves
A long-running C service that allocates per request will eventually fragment its heap and contend on its allocator. Both show up as an unstable tail: most requests are fine, and the slow ones are slow for reasons that do not appear in a profile taken at a calm moment.
Arenas change the shape of the problem. Instead of freeing each object where it happens to die, you declare a boundary (a request, a frame, a batch) and reclaim everything inside it at once.
Step 1: set up an arena environment
#include <ttak/mem/arena_helper.h>
ttak_arena_env_config_t cfg;
ttak_arena_env_config_init(&cfg); /* defaults first */
ttak_arena_env_t env;
if (!ttak_arena_env_init(&env, &cfg)) {
/* allocation failed; nothing to destroy */
return -1;
}The environment owns the backing memory and outlives every generation taken from it. Initialise the config struct through its _init function rather than zeroing it yourself, so that future fields get their intended defaults.
Step 2: take a generation per unit of work
ttak_arena_generation_t gen;
ttak_arena_generation_begin(&env, &gen, epoch_id);
void *header = ttak_arena_generation_claim(&env, &gen, 512);
void *body = ttak_arena_generation_claim(&env, &gen, 8192);
/* ... handle the request; nothing here calls free() ... */
ttak_arena_generation_retire(&env, &gen);claim() is a bump allocation and returns NULL when the generation cannot satisfy it, so check it like any allocator. retire() reclaims everything at once. Every pointer taken from the generation is dead the instant it returns That is the contract, and it is also the whole benefit.
Step 3: know how much room is left
size_t left = ttak_arena_generation_remaining(&gen);
if (left < needed) {
/* retire early, or size the generation up at begin() */
}If you want a generation reused rather than returned, ttak_arena_generation_reset() rewinds it without giving the memory back to the environment, which is useful for a loop that processes many small items.
Step 4: rotate under steady load
ttak_arena_env_rotate() advances the environment so that retired generations become reusable. Call it at a natural quiet point (the end of a batch, a tick boundary) rather than on every request, since the point is to move cleanup to a moment you chose.
Step 5: the cross-thread case
Arenas answer the lifetime question inside one unit of work. When a structure is read by threads that are still inside it while another thread retires it, that is epoch reclamation's job instead:
#include <ttak/mem/epoch.h>A reader enters an epoch, works, and leaves. A writer retires the old structure, and reclamation happens once no reader can still be holding it. There is no global pause and no stop-the-world phase; the cost is that reclamation is deferred rather than immediate.
Step 6: hand ownership over explicitly
When memory has to outlive the generation that produced it, libttak's detachable ownership makes that a deliberate call rather than a comment. Detaching is visible at the call site, which means a reviewer can answer "who frees this?" by reading the code in front of them.
Rules of thumb
- One generation per unit of work, retired on every path including the error path.
- Never let an arena pointer escape the generation without detaching it.
- Size the generation for the common case and handle the overflow explicitly; a failed claim is not an exceptional event.
- Rotate at a boundary you chose, not on a timer you did not.
Summary
Generational arenas turn per-object cleanup into a scheduled event, epoch reclamation covers the readers-while-retiring case, and detachable ownership keeps handover explicit. Together they are why libttak describes itself as deterministic rather than merely fast.