When you need an object pool

The heap is general-purpose but problematic in real-time systems:

  • Non-deterministic allocation time
  • Fragmentation over long runtimes
  • Failure mode is a runtime crash, not a compile-time error

A pool allocates all memory up front in a fixed-size array. Each slot holds one object. Allocation is a pointer pop; deallocation is a pointer push. Both are O(1) and deterministic.

Use a pool when:

  • Objects have the same type (or same max size)
  • Lifetime is arbitrary (unlike arena allocators)
  • Allocation rate is bounded and known at compile time

Implementation

 1// object_pool.h
 2#pragma once
 3#include <array>
 4#include <cassert>
 5#include <cstddef>
 6#include <memory>
 7#include <new>
 8
 9template <typename T, size_t Capacity>
10class ObjectPool {
11public:
12    ObjectPool() noexcept {
13        // Build the intrusive free list through the storage
14        for (size_t i = 0; i < Capacity - 1; ++i)
15            slots_[i].next = &slots_[i + 1];
16        slots_[Capacity - 1].next = nullptr;
17        head_ = &slots_[0];
18    }
19
20    // Allocate raw storage — caller must placement-new the object
21    T* allocate() noexcept {
22        if (!head_) return nullptr;
23        Slot* s = head_;
24        head_   = s->next;
25        --free_;
26        return reinterpret_cast<T*>(s->storage);
27    }
28
29    // Deallocate — caller must have already destroyed the object
30    void deallocate(T* ptr) noexcept {
31        Slot* s = reinterpret_cast<Slot*>(ptr);
32        s->next = head_;
33        head_   = s;
34        ++free_;
35    }
36
37    size_t capacity() const noexcept { return Capacity; }
38    size_t available() const noexcept { return free_; }
39    bool   full()  const noexcept { return free_ == 0; }
40    bool   empty() const noexcept { return free_ == Capacity; }
41
42private:
43    union Slot {
44        alignas(T) char storage[sizeof(T)];
45        Slot* next;
46    };
47
48    std::array<Slot, Capacity> slots_;
49    Slot*  head_ = nullptr;
50    size_t free_ = Capacity;
51};

Usage — manual acquire/release

 1static ObjectPool<SensorSample, 32> pool;
 2
 3// Allocate + construct
 4SensorSample* s = pool.allocate();
 5if (s) {
 6    new (s) SensorSample{HAL_GetTick(), readVoltage()};
 7    processQueue.push(s);
 8}
 9
10// After processing — destroy + deallocate
11SensorSample* s = processQueue.pop();
12if (s) {
13    s->~SensorSample();       // explicit destructor
14    pool.deallocate(s);
15}

Usage — with unique_ptr (RAII)

unique_ptr with a custom deleter automates the destroy + deallocate step:

 1template <typename T, size_t Cap>
 2class ObjectPool {
 3public:
 4    // ...
 5
 6    struct Deleter {
 7        ObjectPool* pool;
 8        void operator()(T* ptr) const noexcept {
 9            ptr->~T();
10            pool->deallocate(ptr);
11        }
12    };
13
14    using UniquePtr = std::unique_ptr<T, Deleter>;
15
16    template <typename... Args>
17    UniquePtr make(Args&&... args) {
18        T* ptr = allocate();
19        if (!ptr) return {nullptr, {this}};
20        new (ptr) T(std::forward<Args>(args)...);
21        return {ptr, {this}};
22    }
23};

Usage:

 1static ObjectPool<Message, 16> msgPool;
 2
 3// Allocate, construct, return RAII handle
 4auto msg = msgPool.make(0x01, payload, len);
 5if (!msg) {
 6    // pool exhausted
 7    return;
 8}
 9
10sendQueue.push(std::move(msg));  // moves ownership into queue
11
12// When msg goes out of scope — destructor called, slot returned to pool
13// No manual cleanup needed

ISR-safe pool

On a single-core Cortex-M, the pool is ISR-safe if both sides run on the same core and allocation/deallocation are not interleaved (SPSC pattern: ISR allocates, task deallocates, or vice versa).

For preemptive RTOS or multi-core, wrap the free-list operations in a critical section:

 1T* allocate() noexcept {
 2    taskENTER_CRITICAL();         // FreeRTOS critical section (or __disable_irq)
 3    Slot* s = head_;
 4    if (s) { head_ = s->next; --free_; }
 5    taskEXIT_CRITICAL();
 6    return s ? reinterpret_cast<T*>(s->storage) : nullptr;
 7}
 8
 9void deallocate(T* ptr) noexcept {
10    Slot* s = reinterpret_cast<Slot*>(ptr);
11    taskENTER_CRITICAL();
12    s->next = head_;
13    head_   = s;
14    ++free_;
15    taskEXIT_CRITICAL();
16}

On desktop, use a std::mutex or a lock-free free list (single-writer atomic compare-exchange on the head pointer).


Memory layout

The Slot union is the key: it reuses the same memory as either the stored object or a free-list pointer. No separate bookkeeping array.

slots_[0]: [ T or next* ]  ← 32 bytes (sizeof(T) or sizeof(ptr))
slots_[1]: [ T or next* ]
...
slots_[N-1]: [ T or next* ]

Total memory: N * max(sizeof(T), sizeof(void*)) — exactly what you’d use for a raw array, with zero additional overhead per slot.


Compile-time capacity check

1template <typename T, size_t Cap>
2class ObjectPool {
3    static_assert(Cap > 0, "Pool capacity must be positive");
4    static_assert(sizeof(T) >= sizeof(void*),
5                  "T must be at least pointer-sized for free list");
6};

If you need a pool for very small types (smaller than a pointer), pad the slot:

1union Slot {
2    alignas(alignof(T)) char storage[std::max(sizeof(T), sizeof(Slot*))];
3    Slot* next;
4};

Complete drop-in header

 1// object_pool.h — single header, no dependencies
 2#pragma once
 3#include <array>
 4#include <cstddef>
 5#include <memory>
 6#include <new>
 7#include <type_traits>
 8
 9template <typename T, size_t Cap>
10class ObjectPool {
11    static_assert(Cap > 0);
12    union Slot {
13        alignas(T) char buf[sizeof(T) < sizeof(Slot*) ? sizeof(Slot*) : sizeof(T)];
14        Slot* next;
15    };
16
17    std::array<Slot, Cap> pool_;
18    Slot*  head_  = nullptr;
19    size_t avail_ = Cap;
20
21    void buildFreeList() {
22        for (size_t i = 0; i < Cap - 1; ++i) pool_[i].next = &pool_[i + 1];
23        pool_[Cap - 1].next = nullptr;
24        head_ = &pool_[0];
25    }
26
27public:
28    ObjectPool() noexcept { buildFreeList(); }
29
30    struct Deleter {
31        ObjectPool* p;
32        void operator()(T* ptr) const noexcept {
33            ptr->~T();
34            p->deallocate(ptr);
35        }
36    };
37    using Ptr = std::unique_ptr<T, Deleter>;
38
39    template <typename... Args>
40    Ptr make(Args&&... args) noexcept(std::is_nothrow_constructible_v<T, Args...>) {
41        T* raw = allocate();
42        if (!raw) return {nullptr, {this}};
43        return {new (raw) T(std::forward<Args>(args)...), {this}};
44    }
45
46    T* allocate() noexcept {
47        if (!head_) return nullptr;
48        Slot* s = head_; head_ = s->next; --avail_;
49        return reinterpret_cast<T*>(s->buf);
50    }
51
52    void deallocate(T* ptr) noexcept {
53        Slot* s = reinterpret_cast<Slot*>(ptr);
54        s->next = head_; head_ = s; ++avail_;
55    }
56
57    size_t available() const noexcept { return avail_; }
58    size_t capacity()  const noexcept { return Cap; }
59};

Quick reference

Property Value
Allocation O(1) — pop from free list
Deallocation O(1) — push to free list
Fragmentation None — fixed slots
Overhead per slot 0 (free list uses slot memory)
Thread safety Needs critical section for multi-producer
Object lifetime Arbitrary (unlike arena)
Capacity Fixed at compile time