What is ManagedBuffer, and how does tail allocation put a header and elements in one allocation?

3.10.3 · L5 · Learning

Predict first

What does the buffer report?

import Foundation
struct Header { var count: Int }
final class Storage: ManagedBuffer<Header, Int32> {
    deinit {
        withUnsafeMutablePointers { h, e in _ = e.deinitialize(count: h.pointee.count) }
        print("Storage deinit, freed", header.count, "elements")
    }
}
func demo() {
    var s = Storage.create(minimumCapacity: 10) { _ in Header(count: 0) } as! Storage
    s.withUnsafeMutablePointers { h, e in
        for i in 0..<5 { (e + i).initialize(to: Int32(i * i)) }
        h.pointee.count = 5
    }
    let obj = Unmanaged.passUnretained(s).toOpaque()
    print("one allocation holds object and elements:", malloc_size(obj) >= class_getInstanceSize(Storage.self) + 10 * 4)
    print("instance size:", class_getInstanceSize(Storage.self), " Header:", MemoryLayout<Header>.size)
    print("capacity >= 10:", s.capacity >= 10)
    print("elements:", s.withUnsafeMutablePointerToElements { p in (0..<5).map { p[$0] } })
    print("unique:", isKnownUniquelyReferenced(&s))
    let t = s
    print("unique with a second reference:", isKnownUniquelyReferenced(&s))
    _ = t
}
demo()
  • one allocation true, instance size 24, Header 8, capacity true, elements [0, 1, 4, 9, 16], unique true, then false, then the deinit line freeing 5 elements
  • Two allocations and unique stays true
  • instance size 16

Mental model. A tailored suit with the pocket sewn onto the jacket, instead of a jacket plus a separate bag you have to remember to carry.

ManagedBuffer<Header, Element> is a class whose instances are allocated with extra storage after the object (tail allocation). One malloc holds the 16-byte object header, your Header value and room for the elements, so there is no second pointer chase. The standard library builds Array on the same idea. In the check one block is at least the instance size plus ten 4-byte elements; capacity is at least what you asked for (the allocator may round up); the elements are reached only through unsafe pointers (withUnsafeMutablePointers), which means you must initialise and deinitialise them. That last point is the trap: ManagedBuffer never knows how many elements are live, so a subclass must deinitialise them in its own deinit (the check does and prints freed 5 elements); without it, class-type elements leak.

It is also the right tool for a hand-written copy-on-write container (Q150): hold the buffer in a struct and check isKnownUniquelyReferenced(&buffer) before mutating (true, then false once a second reference exists), copying the elements to a new buffer when not unique. Use it only when a profile shows that the extra allocation of “class plus array” matters; a plain final class with an [Element] is simpler and safe. ManagedBufferPointer and class_getInstanceSize show you the numbers, and malloc_size (Darwin) reports the real block size.

Header and elements are one tail-allocated block; the subclass must deinitialise the elements.

Common traps

  • Forgetting to deinitialise elements in deinit. Memory is freed, but element destructors never run.
  • Reading elements beyond what you initialised. The buffer is raw memory.

They might follow up with

  • Why does Array use something like it? One allocation for header and elements keeps small arrays cheap and cache-friendly.
  • Is there a safe alternative? A class holding an array, at the price of an extra allocation.

Related questions

  • 3.5.1 How do you implement copy-on-write for your own struct?
  • 3.5.2 What exactly does isKnownUniquelyReferenced check, and how do you defeat it by accident?
  • 3.5.3 What is an Array in memory, and what is special about the empty array?

← How big is an any Protocol value, and when does storing one allocate…   Why can a small slice keep a large array (or string) alive, and how… →