cld-toys › Toys › malloc-arena

Commentary: malloc-arena

A 256-byte arena with four 56-byte allocations in it. Free all four, so that every byte in the arena is free, then ask for 96 — and watch it fail. Whether it fails depends on the order you freed in, and on one direction a pointer cannot go. A study guide for arena.py.

malloc-arena/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with arena.py open beside you. arena.py is the toy itself (214 lines, one class); demo.py runs the failing trace and then everything that does and doesn't fix it; test_arena.py pins all 33 claims on this page. Nothing is written to disk, nothing is timed, and there is no randomness anywhere. No dependencies, stdlib only. Every transcript below was captured on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3].
cd malloc-arena
python3 demo.py           # the aha (§6), about a second
python3 test_arena.py     # pins every number this page claims
python3 demo.py --census  # adds §7.4's 986,580-trace sweep, ~75 seconds
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy is an allocator: one fixed bytearray, an 8-byte header in front of every block, splitting on malloc and merging on free. It has policy knobs for the fit rule, for who gets merged and when, for the splitting threshold, and for boundary tags — and the whole page is about which of those knobs is load-bearing.

The shortest version of the result, in five lines of Python:

from arena import Arena
a = Arena(256)                        # 256 bytes, all free
p = [a.malloc(56) for _ in range(4)]  # 4 x (56 + 8) = 256: exactly full
for i in (0, 1, 2, 3):                # give every single byte back
    a.free(p[i])
a.malloc(96)                          # OutOfMemory: no block for 96 bytes

Nothing is allocated. a.stats()["free_bytes"] is 256. The request is 96. It raises.

By the end you should be able to:


2. The problem this mechanism exists to solve

malloc(n) has to hand back n bytes that are contiguous and that nobody else is using. That one word, contiguous, is the whole difficulty. A filesystem can scatter a file across any free blocks it likes and fix it up with an index; an allocator hands out a raw pointer, so the bytes have to be next to each other and stay where they are for as long as the caller holds that pointer.

So the allocator's real job is not bookkeeping, it is keeping large runs of free bytes in existence. Everything in the design is in tension about how:

OSTEP's chapter on free-space management puts the failure this creates in one sentence: "Although all of the memory is free, it is chopped up into pieces, thus appearing as a fragmented memory despite not being one." This toy is 214 lines built to produce exactly that sentence, on purpose, from a free order you would never suspect.


3. Background you need

ConceptWhere it's used hereOne source
Header / block metadatathe 8-byte <II in front of every block; _hdr and _mark are the only things that touch itOSTEP §17.3
Splittingmalloc cuts a remainder off the block it picks, unless the remainder is smaller than a headerOSTEP §17.2
Coalescingfree, lines 153–163 — and the fact that it can only see adjacent blocks is the entire resultDoug Lea, A Memory Allocator
External fragmentationthe failing state in §6.1: free bytes everywhere, no free run big enoughFragmentation (computing)
Boundary tagsboundary_tags=True: a 4-byte footer on free blocks so the previous block can be found in one readLea on boundary tags
PREV_INUSEthe PREV_FREE bit in _mark; without it the footer is unreadable, and §6.6 shows the crashglibc malloc.c
Fit policy_find, and the 986,580-trace census in §7.4Lea, quoting Wilson et al.

The two that carry the result are coalescing and the direction problem behind boundary tags. Everything else on this page is the setting. If you take one idea away, take this one: coalescing is an operation on a moment, not on a set. The set of blocks freed is identical in every run below; only the moments differ.


4. The mental model

A block is a header plus its payload, and the next block starts immediately after it. That is the entire data structure — there is no free list, no tree and no side table:

byte 0 8 64 72 128 +----------+------------------+---------+------------------+---- ... | hdr 8 B | payload 56 B | hdr 8 B | payload 56 B | +----------+------------------+---------+------------------+---- ... ^ ^ ^ block 0 malloc returned 8 block 1 = 0 + total(0) header = <II> = (total size INCLUDING the header, flags) flags bit 0 = FREE, bit 1 = PREV_FREE (only with boundary_tags) Forward is free: next = off + total <- one addition Backward is not: prev = ??? <- nothing points that way So free(b) can merge b with the block AFTER it, and cannot merge it with the block BEFORE it. Watch what that does to four blocks: free 3, 2, 1, 0 (newest first) free 0, 1, 2, 3 (oldest first) --------------------------------- --------------------------------- [U][U][U][F] 3: nothing after it [F][U][U][U] 0: 1 is live [U][U][ F 64+64 ] 2 merges with 3 [F][F][U][U] 1: 2 is live [U][ F 64+128 ] 1 merges [F][F][F][U] 2: 3 is live [ F 256 ] 0 merges [F][F][F][F] 3: nothing after it Both arenas are 100% free. One holds a 248-byte payload; the other holds four 56-byte payloads and cannot hold anything bigger, ever, until something merges them.

Two sentences to carry away. Merging happens at free time, and only with neighbours that are already free at that instant. And a heap you can only walk forward is a heap you can only merge forward, which turns "free your objects in the order you created them" — the most natural loop in programming — into the one order that merges nothing.


5. Reading the source

214 lines, one class. Read it in this order.

5.1 The header, and why blocks() is the whole navigation system

arena.py · lines 97–104
    def blocks(self):
        """Every block, front to back. `off + total` is the next header --
        the only navigation an 8-byte header can offer."""
        off = 0
        while off < self.size:
            total, flags = self._hdr(off)
            yield off, total, bool(flags & FREE)
            off += total

off += total is the toy's only pointer arithmetic and its only structural claim: the heap is a sequence of blocks with no gaps, so the byte after a block is the next block's header. Everything else — the free list, the fit search, coalescing — is written on top of this one generator.

Notice what the generator cannot do: it cannot start in the middle, and it cannot go backwards. Given an offset you cannot find the previous block without starting again from zero. That asymmetry is not an artifact of Python; it is what an 8-byte header is, and §5.4 is the whole toy paying for it.

5.2 malloc — the split, and the remainder that isn't worth having

arena.py · lines 108–128
    def malloc(self, nbytes):
        need = nbytes + self.overhead
        off = self._find(need)
        if off is None and self.consolidate_on_fail:
            self.consolidate()
            off = self._find(need)
        if off is None:
            raise OutOfMemory("no block for %d bytes" % nbytes)
        total, _ = self._hdr(off)
        rest = total - need
        if rest >= self.overhead + self.split_min:
            # Split. The remainder is written FIRST: marking the allocated
            # block clears the remainder's PREV_FREE, which is only correct
            # if the remainder's header already exists.
            self._mark(off + need, rest, True, prev_free=False)
            self._mark(off, need, False)
        else:
            # Too small to be a block of its own: the request keeps the
            # slack. Internal fragmentation, deliberately.
            self._mark(off, total, False)
        return off + HEADER

Three decisions in twenty lines.

need = nbytes + self.overhead is where the header stops being free. Ask for 96 and you are really asking for 104 contiguous bytes, and §6.1's whole result is the gap between those two numbers and 64.

rest >= self.overhead + self.split_min is the splitting threshold. With split_min = 0 it still refuses to split off a remainder that couldn't hold a header, because such a remainder would be a block with nowhere to write its own size — not a policy, an impossibility. Everything above that floor is policy, and §7.5 measures it: on this toy's trace family, raising the threshold never helps and eventually costs 114,840 traces.

return off + HEADER is the toy's model of a pointer. Real malloc returns an address; this returns an integer offset into self.mem, and free subtracts the same 8 to get back to the header. That subtraction is why a real free(p) needs no search to find the block metadata, and why writing one byte before a returned pointer corrupts the heap rather than a value.

5.3 free — the forward merge, which is the cheap direction

arena.py · lines 147–163
    def free(self, ptr):
        off = ptr - HEADER
        total, _ = self._hdr(off)
        self._mark(off, total, True)
        if self.coalesce == "none":
            return
        nxt = off + total                      # forward: one addition
        if nxt < self.size:
            ntotal, nflags = self._hdr(nxt)
            if nflags & FREE:
                total += ntotal
                self._mark(off, total, True)
        if self.coalesce == "both":
            prev = self._prev(off)             # backward: the hard direction
            if prev is not None:
                ptotal, _ = self._hdr(prev)
                self._mark(prev, ptotal + total, True)

This is the load-bearing function, and the load-bearing part of it is the if nflags & FREE in the forward branch. That condition is a question about right now: is my successor free at this instant? If it is, the two become one block. If it isn't, nothing is recorded — no "merge me later" note, no pending list — and when the successor is freed a moment later it asks the same question about its successor and never looks back at us.

That is why the default coalesce="forward" is not a strawman. It is what you get from the data structure above, and it is enough to keep an arena healthy under the free orders most code produces. It is defeated by exactly one thing: freeing in ascending address order, where the answer to is my successor free? is no, every time, by construction.

The counterfactual is in §6.2 and it is unusually clean: replace the whole forward branch with nothing (coalesce="none") and the resulting 256-byte arena is byte-identical. On the trace that fails, the coalescing code runs four times and changes nothing.

5.4 _prev — the direction that costs, in two currencies

arena.py · lines 165–183
    def _prev(self, off):
        """The physically previous block, if it is free. A header-only heap
        cannot answer this in less than a walk from byte 0; a footer answers
        it in one read, but ONLY if PREV_FREE says those 4 bytes are a
        footer and not the tail of somebody's payload.
        """
        if off == 0:
            return None
        if self.boundary_tags:
            if not self._hdr(off)[1] & PREV_FREE:
                return None
            ptotal, = struct.unpack_from("<I", self.mem, off - FOOTER)
            return off - ptotal
        prev = None
        for o, total, free in self.blocks():
            if o == off:
                return prev
            prev = o if free else None
        return None

Two implementations of one question, and they cost in different currencies.

The heap walk (the last five lines) costs time: it re-derives the whole block sequence from byte 0 to find out what is immediately behind off. It costs no memory at all, which is why it is the toy's default answer to "coalesce backwards too".

The footer costs memory: 4 bytes per block, reserved whether or not the block is ever freed, in exchange for one subtraction. §6.6 prices both, and the shapes are exact — n²/2 + 9n/2 − 3 header reads for the walk against 6n − 4 for the footer.

The line to stare at is prev = o if free else None. It is not an optimisation; it is the definition of adjacency. Drop the else None and _prev returns the nearest earlier free block instead of the adjacent one — which sounds almost the same and is memory corruption. §7.1 has the run: a live 56-byte allocation ends up inside a free block, gets handed to a second caller, and both of them write to it.

The if not self._hdr(off)[1] & PREV_FREE: return None guard is the same idea in the footer world, and it is the one glibc spells PREV_INUSE. Its comment in malloc.c is worth having in front of you when you read §6.6: "If prev_inuse is set for any given chunk, then you CANNOT determine the size of the previous chunk, and might even get a memory addressing fault when trying to do so."

5.5 consolidate — the same repair, paid later

arena.py · lines 185–202
    def consolidate(self):
        """Deferred coalescing: one forward sweep merging every run of free
        blocks. Needs no footer, and no free order can defeat it -- but it
        costs a full heap walk, so it is paid on failure, not on every free.
        """
        off = 0
        while off < self.size:
            total, flags = self._hdr(off)
            if flags & FREE:
                nxt = off + total
                while nxt < self.size:
                    ntotal, nflags = self._hdr(nxt)
                    if not nflags & FREE:
                        break
                    total += ntotal
                    nxt += ntotal
                self._mark(off, total, True)
            off += total

This is the third answer, and the interesting one: it only ever walks forward, like free does, but because it walks the whole heap at once it sees runs of free blocks rather than one neighbour. Order cannot defeat it, because by the time it runs there is no order left — just a snapshot.

malloc calls it (line 112) only when a request has already failed, which is the trade: nothing is paid on the fast path, and the arena is allowed to sit in the fragmented state indefinitely as long as nobody asks for anything large. That is precisely the bargain glibc struck with fastbins for two decades, and §8 has what happened to it.


6. The demo, and what it proves

python3 demo.py. Sections A–G below are its real output, in order.

6.1 The headline

arena=256 bytes, header=8, 4 x malloc(56) fills it exactly (4 x 64 = 256). A. Same allocations, same frees, two orders ------------------------------------------- free order [0, 1, 2, 3] free list : [free 64@0] [free 64@64] [free 64@128] [free 64@192] 256 of 256 bytes free (100%) in 4 block(s); largest holds a 56-byte payload malloc(96) -> FAILS: no block for 96 bytes free order [3, 2, 1, 0] free list : [free 256@0] 256 of 256 bytes free (100%) in 1 block(s); largest holds a 248-byte payload malloc(96) -> OK

The arithmetic, so that no number here is unexplained:

The two runs differ in nothing except the order of four free calls. The allocations are the same, the sizes are the same, the set of freed blocks is the same, and the total free bytes are the same.

6.2 The counterfactual: the coalescing code is inert on the failing trace

B. Counterfactual: turn coalescing off entirely ----------------------------------------------- coalesce='none' : [free 64@0] [free 64@64] [free 64@128] [free 64@192] coalesce='forward' : [free 64@0] [free 64@64] [free 64@128] [free 64@192] byte-identical arenas? True

coalesce="none" returns from free before the merge branch ever runs (line 152). coalesce="forward" runs it four times. The two 256-byte bytearrays compare equal — not equivalent, equal, headers and flags and all.

This is what "merging is local" means, made checkable: an allocator with coalescing and an allocator without it are the same allocator under an ascending free order. Whatever fixes this failure, it cannot be a better merge at free time; it has to be a merge that looks somewhere else (§6.5).

6.3 One of 24 — and it is the one your for loop writes

C. All 24 free orders, forward-only coalescing ---------------------------------------------- [free 128@0] [free 128@128] 5 order(s), e.g. [1, 0, 3, 2] [free 64@0] [free 128@64] [free 64@192] 5 order(s), e.g. [0, 2, 1, 3] [free 128@0] [free 64@128] [free 64@192] 3 order(s), e.g. [1, 0, 2, 3] [free 192@0] [free 64@192] 3 order(s), e.g. [2, 1, 0, 3] [free 64@0] [free 192@64] 3 order(s), e.g. [0, 3, 2, 1] [free 64@0] [free 64@64] [free 128@128] 3 order(s), e.g. [0, 1, 3, 2] [free 256@0] 1 order(s), e.g. [3, 2, 1, 0] [free 64@0] [free 64@64] [free 64@128] [free 64@192] 1 order(s), e.g. [0, 1, 2, 3] request orders that fail, of 24 48 0 56 0 88 1 96 1 120 1 121 17 152 17 184 17 200 23 248 23

The rule behind the whole table is one line of arithmetic. A request of n bytes needs n + 8 contiguous bytes, and the only runs available are multiples of 64, so it needs a run of k = ⌈(n + 8) / 64⌉ blocks.

So the failing case at 96 bytes is a knife edge — and the edge is exactly where real code stands. The order (0, 1, 2, 3) is what you get from for p in allocated: free(p), from draining a queue front to back, from tearing down a linked list you built by appending, and from any pool released in creation order. It is not an unlucky permutation; it is the default one.

Widen the arena and the knife edge becomes a plateau — six 32-byte allocations in 240 bytes, all 720 orders:

Same shape, 6 x malloc(32) in a 240-byte arena, all 720 orders: request 56 -> 1 of 720 orders fail (0.1%) request 72 -> 1 of 720 orders fail (0.1%) request 96 -> 349 of 720 orders fail (48.5%) request 120 -> 642 of 720 orders fail (89.2%)

Same arithmetic: blocks are 40 bytes here, so a 96-byte request needs ⌈104/40⌉ = 3 in a row, and 48.5% of all possible free orders cannot provide it.

6.4 The knob everyone argues about does nothing here

D. Does the fit policy rescue the failing arena? ------------------------------------------------ fit=first malloc(96) -> FAILS [free 64@0] [free 64@64] [free 64@128] [free 64@192] fit=best malloc(96) -> FAILS [free 64@0] [free 64@64] [free 64@128] [free 64@192] fit=worst malloc(96) -> FAILS [free 64@0] [free 64@64] [free 64@128] [free 64@192] The knob is not inert -- give it three holes of different sizes and malloc(24), which needs 32 bytes, lands somewhere different each time: holes : [free 64@0] [USED 16@64] [free 32@80] [USED 16@112] [free 128@128] first : [USED 32@0] [free 32@32] [USED 16@64] [free 32@80] [USED 16@112] [free 128@128] best : [free 64@0] [USED 16@64] [USED 32@80] [USED 16@112] [free 128@128] worst : [free 64@0] [USED 16@64] [free 32@80] [USED 16@112] [USED 32@128] [free 96@160]

First fit, best fit and worst fit are three answers to which of the fitting blocks to use. In the failing arena, the set of fitting blocks is empty — _find skips every block on total < need (line 135) before the policy is ever consulted. Three policies, one empty set, one answer.

The second half of the transcript is the control that makes this a finding rather than a tautology: given three holes of 64, 32 and 128 bytes, the same malloc(24) lands in a different one under each policy, splitting the 64-byte hole, exactly filling the 32-byte one, or carving the 128-byte one. The knob works. It is just not connected to this failure. §7.4 measures how much it is connected to any failure.

6.5 What does fix it, and what each fix costs

E. What does rescue it, and what it costs ----------------------------------------- policy 4 frees cost malloc(96) and costs coalesce='forward' 11 reads FAILS 4 reads coalesce='both' (heap walk) 23 reads OK 3 reads coalesce='both' + footers 25 reads OK 4 reads 'none' + consolidate 8 reads OK 12 reads

The cost unit is header reads — every _hdr call, counted by the toy itself (self.reads, line 75). It is deterministic, which wall-clock time would not be, and it is the operation a real allocator pays for in cache misses.

Three fixes, three shapes:

Nothing here is free, and none of the three dominates. That is the design space, in four rows.

6.6 Boundary tags: the price, the payoff, and the crash

F. Boundary tags: the cost, the price, and the trap --------------------------------------------------- Backward merge over n blocks, freed newest-first (every free merges): n heap walk footer + PREV_FREE 4 23 20 8 65 44 16 197 92 32 653 188 64 2333 380

Both columns have exact closed forms, checked against all five rows in test_arena.py: the heap walk costs n²/2 + 9n/2 − 3 header reads and the footer costs 6n − 4. At n = 4 the footer wins by 13%; at n = 64 it wins by 6.1×, and the gap grows without limit. This is the entire argument for boundary tags, and it is why Knuth's trick survived into every serious allocator: free must not be O(heap).

The price is paid in bytes, on the toy's own trace:

The price, on this toy's own trace (4 x malloc(56) in 256 bytes): boundary_tags=False overhead= 8 bytes/block -> 4 of 4 allocations served boundary_tags=True overhead=12 bytes/block -> 3 of 4 allocations served

4 × (56 + 12) = 272 > 256. The fourth allocation has 256 − 3 × 68 = 52 bytes left, which holds a payload of 52 − 12 = 40, and 56 > 40. The fix that guarantees the arena can always be defragmented is the fix that costs you an allocation.

And then the trap. Write the footer as the obvious thing — the 4 bytes below a header are the previous block's size, so read them — and the demo's NaiveFooter subclass shows what happens on the very first free:

And the trap -- the same trace, with the PREV_FREE check removed: struct.error: pack_into requires a buffer of at least 544 bytes for packing 4 bytes at offset 540 (actual buffer size is 512) the block below is USED, so those 4 bytes are payload: 0. prev = off - 0 = off, so the block merges with ITSELF: 32 -> 64, and a footer for a 64-byte block at offset 480 lands at 540.

Read the failure carefully, because it is the reason a bit exists in every production allocator. Sixteen 32-byte blocks fill a 512-byte arena; the last one starts at offset 480. Freeing it newest-first means the block below it is still allocated, so the 4 bytes at 476 are not a footer at all — they are the tail of somebody's payload, here all zeros. ptotal = 0, so prev = 480 − 0 = 480, so the block's predecessor is itself. It merges with itself into a 64-byte block whose footer would live at 480 + 64 − 4 = 540, in a 512-byte arena, and Python refuses.

A footer is only meaningful if the block below is free, and nothing about the footer itself can tell you whether it is. That is exactly what glibc's PREV_INUSE bit is for, and its comment reads like a description of this crash: "If that bit is clear, then the word before the current chunk size contains the previous chunk size, and can be used to find the front of the previous chunk... If prev_inuse is set for any given chunk, then you CANNOT determine the size of the previous chunk, and might even get a memory addressing fault when trying to do so."

In C there is no struct.error. The bad merge succeeds silently, the allocator hands out a block that overlaps live data, and the program crashes somewhere else entirely, later. This toy's arena is a bytearray with bounds checking, which is the only reason the bug announces itself.

6.7 The boundary condition — where the effect vanishes

G. Where it vanishes: one size, no mixing ----------------------------------------- ascending frees, forward-only: [free 64@0] [free 64@64] [free 64@128] [free 64@192] 4 of 4 further malloc(56) served from that same arena.

The very same fragmented arena that cannot serve one 96-byte request serves four 56-byte requests without a complaint. Nothing was merged; nothing needed to be.

That is the boundary, and it is sharp: if every allocation is the same size, coalescing is unnecessary and free order is irrelevant, because any free block satisfies any request. A reader can place their own system by asking four questions:

  1. Are the sizes uniform? A slab allocator, an object pool, a fixed-size page allocator, a Vec<T> arena — none of them can produce this failure, which is why they exist. This is the single biggest reason the bug is rare in practice even though it is trivial to construct.
  2. Does anything ever free in bulk? A region/arena allocator that frees everything at once by resetting one pointer has no free order at all. Same immunity, different route.
  3. Can the allocator move things? A compacting garbage collector fixes fragmentation by relocating live objects — the one option a C allocator cannot take, because it handed out raw pointers and cannot find them again.
  4. Is the heap allowed to grow? This arena is fixed. A real malloc under pressure calls sbrk or mmap for more, which hides fragmentation as address-space growth rather than as a failed request — and the process RSS keeps rising while free() is called correctly. Fragmentation on a growable heap does not look like OutOfMemory; it looks like a leak.

Where the boundary is not. Freeing "enough" memory is not a defence: §7.3 shows the same failure with a live block still in the arena, and the 100% figure is not a threshold — it is a rhetorical extreme. The failure begins long before the arena is fully free.


7. Design decisions and roads not taken

7.1 Adjacent, not nearest — the one-word change that corrupts the heap

_prev ends with prev = o if free else None, which resets the candidate every time a used block goes by. Drop the reset — keep the last free block seen, adjacent or not — and the code still runs, still merges, and still looks right. Here it is against the demo's own trace:

== CF1: merging the nearest EARLIER free block instead of the ADJACENT one after free(0) : [free 64@0] [USED 64@64] [USED 64@128] [USED 64@192] after free(2) : [free 128@0] [free 64@128] [USED 64@192] block 1 was LIVE and is now inside a free block of 128 bytes. malloc(120) handed back offset 8; block 1's payload starts at 72. block 1 now reads b'XXXXX' -- two live allocations on the same bytes. shipped _prev, same trace: [free 64@0] [USED 64@64] [free 64@128] [USED 64@192]

Block 1 was never freed. After free(2) it is inside a 128-byte free block, because block 0 was "the previous free block" and the merge added block 2's size to it. The next malloc(120) is handed offset 8, writes 120 bytes, and walks straight through block 1's payload at offset 72 — two live pointers to the same bytes, with no error anywhere.

This is the strongest argument in the toy for why coalescing is written the awkward way it is. The merge is not "join the free blocks"; it is "join the blocks that are physically touching", and physical adjacency is the only thing that makes the arithmetic in blocks() stay true.

7.2 A heap walk instead of a free list

Every textbook allocator threads a linked list through the free blocks: the payload of a free block is unused, so the first two words can hold next and prev pointers for nothing. This toy doesn't, and _find walks all blocks including allocated ones.

It loses real performance — a free list visits only free blocks, and glibc's 128 bins mean it usually visits about one. It was rejected anyway, because a free list introduces a second structure that must agree with the headers, and the whole point of this toy is what the headers alone can and cannot express. Introducing a list would also quietly answer the toy's central question: a doubly-linked, address-ordered free list can be walked backwards, and the reader would learn nothing about why PREV_INUSE exists. OSTEP makes the same observation from the other side: "by keeping the list ordered by the address of the free space, coalescing becomes easier, and fragmentation tends to be reduced."

7.3 Why the headline uses a 100%-free arena, when it doesn't have to

100% free is theatre, and the honest version is milder: the failure needs fragmentation, not emptiness. Freeing only three of the four blocks fails identically:

== CF3: freeing FEWER blocks does not help free 0,1,3 (block 2 still live): [free 64@0] [free 64@64] [USED 64@128] [free 64@192] 192 bytes free, malloc(96) -> FAILS

The extreme was chosen because it kills the reflex the failure normally triggers. "Out of memory with 192 of 256 bytes free" invites you should free more or you need a bigger arena, and both are wrong. At 256 of 256, there is nothing left to free and no amount of extra arena would have helped — which is the only version of the demo that forces the reader to look at the free list instead of the free total.

7.4 Fit policy: the argument is real, the effect is small, the direction is settled

The census (python3 demo.py --census, about 75 seconds) plays 986,580 distinct traces — four allocations drawn from nine sizes, two of them freed, then two more requests — under each policy, with backward coalescing on so that the fit policy is the only variable:

H. Fit policy over 986,580 traces (this takes about a minute) ------------------------------------------------------------- 986580 traces of 4 allocations, 2 frees, 2 more requests fit=first served every request in 499025 traces (50.58%); sole loser in 0 fit=best served every request in 521527 traces (52.86%); sole loser in 0 fit=worst served every request in 470446 traces (47.68%); sole loser in 28579

Best fit wins, first fit is 2.28 points behind it, worst fit is 5.18 points behind best — and worst fit is the only policy that is ever the sole loser (28,579 traces where the other two both cope). The ordering matches the literature Doug Lea cites: "best-fit schemes (of various kinds and approximations) tend to produce the least fragmentation on real loads compared to other general approaches such as first-fit."

Two things worth taking from it. First, the effect is real but small — a couple of points — which is why production allocators spend their complexity budget on size-class bins rather than on fit refinement. Second, and this is the point of putting the census next to §6.4: the policy only ever changes which request fails, never whether the arena can be repaired. Not one of the 986,580 traces is rescued from a fragmented arena by a fit policy, because fit policies do not create contiguity. Only merging does.

7.5 The splitting threshold, which is a knob that only loses here

Splitting threshold, same traces, fit=first: split_min= 0 -> 499025 traces fully served split_min= 8 -> 499025 traces fully served split_min=16 -> 499025 traces fully served split_min=32 -> 492918 traces fully served split_min=64 -> 384185 traces fully served

The idea behind a splitting threshold is that a tiny remainder is a liability: it is too small to satisfy anything, and it still costs a header. Refusing to create it — handing the slack to the requester instead — trades external fragmentation for internal.

On this trace family it never pays: 0, 8 and 16 are indistinguishable, 32 costs 6,107 traces and 64 costs 114,840. The reason is that the arena is small and the sizes are large relative to it, so the "wasted" remainder is usually big enough to serve the next request outright. Real allocators still enforce a floor (glibc's MINSIZE), but for the reason in §5.2 rather than this one: a block below the floor has nowhere to keep its own metadata. The knob is kept in the toy because a knob that measurably does nothing is worth knowing about — it is one of the two knobs a reader would have guessed was the fix.

7.6 No size classes, no bins, no mmap

Real allocators put free blocks in bins by size so _find is a lookup rather than a scan, and hand large requests directly to mmap so they never touch the heap at all. Both were left out for the same reason: they are about making allocation fast, and this toy is about whether allocation is possible. Bins would make _find O(1)-ish without changing a single byte of §6.1's failing arena — the same four 64-byte blocks would simply be found faster.


8. What's simplified vs. the real thing

SimplificationWhat a real allocator does
One thread, no locks free here is a read-modify-write over shared bytes with no synchronisation at all. glibc gives each thread its own arena (that is where this toy's name comes from) plus a per-thread cache, so the common path takes no lock. Note what that does to this toy's failure: per-thread arenas multiply free bytes without making any of them contiguous — §9 Q4.
A linear scan instead of bins glibc keeps 128 bins, approximately log-spaced, and maintains "the invariant that no consolidated chunk physically borders another one" — the property this toy's consolidate restores by hand.
Coalescing on every free Current glibc defers it, exactly like §5.5. __libc_free puts a small chunk straight into the thread cache with tcache_put and returns, never reaching the code that merges neighbours; consolidation happens when the cache is flushed or a request cannot be met. For about twenty years the same job was done by fastbins — and they are gone from glibc master: there is not one occurrence of the string fastbin left in malloc/malloc.c, M_MXFAST is listed as deprecated, and do_set_mxfast is now a function whose entire body is return 1;. The mechanism outlived its implementation.
A fixed arena that cannot grow malloc(3): "Normally, malloc() allocates memory from the heap, and adjusts the size of the heap as required, using sbrk(2). When allocating blocks of memory larger than MMAP_THRESHOLD bytes, the glibc malloc() implementation allocates the memory as a private anonymous mapping using mmap(2)." A growable heap turns this toy's OutOfMemory into unbounded RSS growth — the same bug wearing a leak's clothing (§6.7).
No alignment Blocks here start wherever the arithmetic lands. Real allocators return memory aligned to max_align_t (16 bytes on x86-64), which rounds every request up and creates internal fragmentation the toy never has. It also means real chunk sizes are always multiples of 16, which is why the low bits of the size field are free to hold PREV_INUSE at all.
No realloc, no calloc, no mmap chunks realloc is the interesting omission: it can sometimes grow in place by absorbing the next block if it is free — which is the forward merge of §5.3 used for a completely different purpose, and one more reason forward is the direction worth having cheaply.
No safety checks glibc validates on the path this toy's _prev takes: "corrupted size vs. prev_size while consolidating" is the error you get when a chunk's footer disagrees with the header it should mirror. §6.6's crash is what that check is guarding against; heap exploitation is largely the art of getting past it.
Bytes are inert The payloads here are never read or written by the toy, so a use-after-free is invisible. In C the recycled block is where the next object lives, and §7.1's overlapping-allocation bug is a security vulnerability rather than a printout.

9. Check yourself

Answer before expanding. Each answer is derivable from the source, and each was verified by running it and pinned in test_arena.py.

Question 1

In the failing arena — 256 bytes, all free, four 64-byte blocks — what is the largest request that does succeed, and why is it not 64?

Answer

56.

== Q3: largest request the failing arena CAN serve largest n with malloc(n) OK: 56 (= 64 - 8)

malloc(n) looks for a block of n + self.overhead bytes (line 109), and the overhead is 8. A 64-byte block can therefore hold a 56-byte payload and not one byte more, because the header lives inside the block it describes. This is also why the demo's request is 96 rather than 64: 64 would have been ambiguous — a reader could think it was a rounding problem rather than a contiguity problem.

Question 2

You are looking at the failing arena in a debugger. Without freeing anything (there is nothing left to free) and without changing any policy, what single call makes the next malloc(96) succeed?

Answer

a.consolidate().

== Q2: the failing arena, rescued after the fact before consolidate(): [free 64@0] [free 64@64] [free 64@128] [free 64@192] after consolidate(): [free 256@0] malloc(96) -> OK

The information needed to merge those four blocks was never lost — it is sitting in the headers, and always was. What was lost was the moment free had to act on it. Coalescing is not a fact about the arena's contents; it is a fact about when you looked. This is the whole justification for deferred coalescing: the repair is available at any later time, for the price of one heap walk.

Question 3

The demo frees all four blocks. Suppose your program is more careful and only frees three of them, keeping block 2 alive. Does malloc(96) succeed?

Answer

No — and the "100% free" headline is theatre, as §7.3 admits:

== CF3: freeing FEWER blocks does not help free 0,1,3 (block 2 still live): [free 64@0] [free 64@64] [USED 64@128] [free 64@192] 192 bytes free, malloc(96) -> FAILS

Blocks 0 and 1 are freed in ascending order and do not merge; block 3 has no successor to merge with. Three free blocks of 64, largest payload 56, same failure. Fragmentation does not need an empty arena; it only needs a free order that never lets two neighbours be free at the same instant.

Question 4

You run this allocator per-thread — three threads, one 256-byte arena each, all in the failing state — as glibc does with its arenas. A request for 96 bytes arrives. What are your odds?

Answer

Zero, on 768 free bytes.

== Q1: three 256-byte arenas, one per thread, all in the failing state total free bytes across arenas: 768 arenas that can serve malloc(96): 0 of 3

Sharding multiplies the number of free bytes a process has and does nothing whatsoever for contiguity, because contiguity is a property inside one arena. This is the general shape of the answer for any "just add more memory" response to fragmentation, and it is why an allocator's health is measured by its largest free block rather than by its free total. It also explains a monitoring failure mode: a dashboard showing 75% heap free is compatible with an allocator that can no longer serve a single medium-sized request.

Question 5

Turn on boundary_tags=True to get order-independent coalescing. The same trace now serves only 3 of its 4 allocations (§6.6). How big would the fourth allocation have to be to fit?

Answer

40 bytes or less.

== Q4: with boundary tags the 4th allocation needs to be how small? after 3 x malloc(56): [USED 68@0] [USED 68@68] [USED 68@136] [free 52@204] largest 4th allocation that still fits: 40 bytes (52 payload + 12)

Each block now costs 56 + 8 + 4 = 68 bytes, so three of them consume 204 and leave 52. A 52-byte block holds 52 − 12 = 40 bytes of payload. The reader who predicted 44 forgot that the footer is reserved in every block, allocated or free — the toy cannot know at allocation time whether a block will later need somewhere to write its size. glibc dodges exactly this by storing the footer in the next chunk's prev_size field, which the next chunk is only using as payload when it is in use — the trick that makes boundary tags cost zero bytes in practice, and the reason PREV_INUSE has to exist to say which of the two meanings those 4 bytes currently have.

Question 6

The demo's failing order is (0, 1, 2, 3). If you cannot change the allocator, what is the smallest change to the program that fixes it, and what does that tell you about which programs are at risk?

Answer

Free in the reverse order — for p in reversed(allocated): free(p) — which is the (3, 2, 1, 0) run in §6.1 and produces one 256-byte block.

Every free then finds its successor already free, so every free merges. The general rule: with forward-only coalescing, LIFO deallocation always coalesces perfectly and FIFO deallocation never does. Programs that free in stack order (scope exit, recursive teardown, RAII) are immune by construction. Programs that free in creation order (queues, worker pools, "iterate the list and delete each node") hit the worst case every time — and one of them is by far the more natural thing to write.

That is also the honest limit of this result: it is a statement about a header-only allocator with forward-only merging. Any allocator that can merge backwards — which is all of the real ones, via boundary tags — has no preferred free order at all, at the price §6.6 measures.


10. Further reading

Every link below was fetched and confirmed live when this was written (2026-07-31).