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 |