A high-performance, lock-free object pool for C++20 with multi-threading support. Designed for real-time systems, game engines, high-frequency trading, and any application requiring predictable, low-latency object allocation.
- slick-object-pool
- Lock-free multi-producer multi-consumer (MPMC) - Zero mutex overhead, true concurrent access
- Cache-line aligned 64-bit hot counters - Single
fetch_add/CAS fast path, no 128-bit atomics - O(1) allocation/deallocation - Constant-time operations
- Power-of-2 ring buffer - Efficient bitwise indexing, no modulo operations
- Per-thread
object_pool_cache<T>- Amortized shared-atomic traffic for hot producers/consumers - Predictable latency - No garbage collection pauses or lock contention
- Header-only - Single file integration, no build dependencies
- C++20 compliant - Modern C++ with compile-time safety guarantees
- Type-safe - Static assertions ensure compatible types
- Cross-platform - Windows, Linux, macOS, and Unix-like systems
- Real-time systems (robotics, industrial control)
- Game engines (entity management, particle systems)
- High-frequency trading systems
- Network servers (connection pooling, buffer management)
- Any scenario requiring predictable allocation performance
Upgrading from 0.1.x: the header is now
<slick/object_pool.hpp>.<slick/object_pool.h>still compiles - it forwards to the .hpp and emits a deprecation notice - but it will be removed in a future release. DefineSLICK_OBJECT_POOL_NO_DEPRECATION_WARNINGto silence the notice while you migrate.
#include <slick/object_pool.hpp>
struct MyObject {
int id;
double value;
};
int main() {
// Create pool with 1024 objects (must be power of 2)
slick::object_pool<MyObject> pool(1024);
// Allocate object from pool
MyObject* obj = pool.allocate();
obj->id = 42;
obj->value = 3.14;
// Return object to pool
pool.free(obj);
return 0;
}Simply copy include/slick/object_pool.hpp to your project:
# Clone the repository
git clone https://github.com/SlickQuant/slick-object-pool.git
# Copy header to your project
cp slick-object-pool/include/slick/object_pool.hpp your_project/include/slick/Option 1: FetchContent (Recommended)
include(FetchContent)
set(BUILD_SLICK_OBJECTPOOL_TESTS OFF CACHE BOOL "" FORCE)
FetchContent_Declare(
slick-object-pool
GIT_REPOSITORY https://github.com/SlickQuant/slick-object-pool.git
GIT_TAG main # or specific version tag
)
FetchContent_MakeAvailable(slick-object-pool)
target_link_libraries(your_target PRIVATE slick-object-pool)Option 2: Add as Subdirectory
add_subdirectory(external/slick-object-pool)
target_link_libraries(your_target PRIVATE slick-object-pool)Option 3: vcpkg
# Install via vcpkg
vcpkg install slick-object-poolThen in your CMakeLists.txt:
find_package(slick-object-pool CONFIG REQUIRED)
target_link_libraries(your_target PRIVATE slick::object-pool)Option 4: find_package (if manually installed)
find_package(slick-object-pool REQUIRED)
target_link_libraries(your_target PRIVATE slick::object-pool)#include <slick/object_pool.hpp>
#include <iostream>
struct Message {
uint64_t id;
char data[256];
};
int main() {
// Pool size must be power of 2
slick::object_pool<Message> pool(512);
// Allocate from pool
Message* msg = pool.allocate();
msg->id = 1;
std::strcpy(msg->data, "Hello, World!");
// Use the object...
std::cout << "Message: " << msg->data << std::endl;
// Return to pool when done
pool.free(msg);
return 0;
}#include <slick/object_pool.hpp>
#include <thread>
#include <vector>
struct WorkItem {
int task_id;
std::array<double, 64> data;
};
void worker_thread(slick::object_pool<WorkItem>& pool, int thread_id) {
for (int i = 0; i < 10000; ++i) {
// Allocate from pool (lock-free)
WorkItem* item = pool.allocate();
// Do work
item->task_id = thread_id * 10000 + i;
process_work(*item);
// Return to pool (lock-free)
pool.free(item);
}
}
int main() {
// Create pool (must be power of 2)
slick::object_pool<WorkItem> pool(2048);
// Launch multiple producer/consumer threads
std::vector<std::thread> threads;
for (int i = 0; i < 8; ++i) {
threads.emplace_back(worker_thread, std::ref(pool), i);
}
// Wait for completion
for (auto& t : threads) {
t.join();
}
return 0;
}The pool coordinates multiple producers and consumers without locks using two cache-line-aligned 64-bit sequence counters:
- Producers (threads calling
free()) claim a sequence with a singlefetch_add, publish the object pointer, then areleasestore on the slot. - Consumers (threads calling
allocate()/try_allocate())acquire-load the oldest slot, read the payload, then claim it with a singlecompare-and-swap. Reading before the claim is what makes the handoff safe: whileconsumed_still names the slot, no producer can reach the sequence that would reuse it. - Ring buffer wrapping is implicit in the monotonically increasing sequence
numbers (
slot = sequence & (size-1)); no wraparound bookkeeping is needed. - No spinlocks, no mutexes - one shared atomic operation per operation.
The implementation is optimized to prevent false sharing on modern CPUs:
Cache Line 0 (64 bytes) - Producer owned:
└─ reserved_ (atomic counter for producers)
Cache Line 1 (64 bytes) - Consumer owned:
└─ consumed_ (atomic counter for consumers)
Cache Lines 2+ - Cold config and shared data:
├─ size_, mask_ (immutable after construction)
├─ control_ (slot metadata)
├─ buffer_ (actual objects)
└─ free_objects_ (available object pointers)
Key benefits:
- The two hot counters never share a line, so a producer's
fetch_adddoes not invalidate the line a consumer is spinning on (and vice versa) - The consumer fast path touches
consumed_and one slot - it never reads the producer counter at all
What this does not buy you: the control_ and free_objects_ rings pack
eight entries per cache line, so threads working on nearby sequences still
share lines there. A single shared pool does not scale linearly with thread
count - see the benchmarks below, and prefer object_pool_cache<T> for hot
threads.
object_pool instance
├─ Heap: buffer_[size_] (actual objects)
├─ Heap: control_[size_] (slot metadata)
├─ Heap: free_objects_[size_] (free list)
└─ Stack: reserved_, consumed_ (atomics)
Measured on: AMD Ryzen 9 5900HX (8 cores / 16 threads), Windows 11, MSVC 19.44
/O2. Pool size 4096, 2,000,000 allocate() + free() pairs per thread,
median of 5 runs. Reported as ns per allocate+free pair, averaged per
thread - i.e. what one thread waits, not aggregate throughput.
This is a deliberately adversarial loop: threads do nothing between
allocate() and free(), so it measures pure contention on the shared
counters. Real workloads that do work while holding an object contend far
less. Values are system- and toolchain-dependent; measure your own.
| Threads | Direct object_pool |
Via per-thread object_pool_cache<T> |
|---|---|---|
| 1 | ~13-16 ns/op | ~2-4 ns/op |
| 2 | ~125-275 ns/op | ~2-4 ns/op |
| 4 | ~0.9-1.1 µs/op | ~2-4 ns/op |
| 8 | ~2.8-3.1 µs/op | ~3-5 ns/op |
The takeaway is the shape, not the digits: a single shared pool hammered by
many threads is dominated by CAS contention and gets dramatically worse with
thread count. That is what object_pool_cache<T> exists to solve - it keeps
the shared atomics out of the steady state, so most allocations and frees touch
only a thread-local freelist and the cost stays flat as threads are added.
If several hot threads share one pool, give each of them a cache.
| Implementation | Thread Safety | Notes |
|---|---|---|
| slick-object-pool (per-thread cache) | Lock-free | Flat cost as threads scale; no per-op shared atomics in the steady state |
| slick-object-pool (direct, shared) | Lock-free | Fastest single-threaded; degrades sharply under multi-threaded contention |
std::allocator / system malloc |
Thread-safe | General purpose; per-thread arenas, but variable latency and possible syscalls |
boost::pool |
Not thread-safe by default | Requires external synchronization for concurrent use |
| tcmalloc / jemalloc | Thread-safe | Thread-caching general allocators; excellent throughput, no fixed-capacity guarantee |
Note: this table is qualitative. Only the slick-object-pool rows above were measured here; benchmark the alternatives yourself against your own workload rather than trusting any published numbers, including these.
// Create pool in local memory
object_pool(uint32_t size);Parameters:
size: Number of objects in pool (must be power of 2)
// Allocate an object from the pool (falls back to the heap when exhausted)
T* allocate();Returns a pointer to an object from the pool. If the pool is exhausted, allocates a new object from the heap (deleted automatically on free()).
The returned object is not re-initialized. No constructor runs on a pooled object, so it still carries the state its previous user left behind. Reset the fields you rely on. (Heap-fallback objects are value-initialized, so don't depend on the initial state either way.)
// Allocate without heap fallback (returns nullptr when empty)
T* try_allocate() noexcept;Returns a pooled object or nullptr if the pool is empty. Never touches the heap — safe for real-time paths.
// Return an object to the pool (deletes heap-owned objects)
void free(T* obj) noexcept;Returns an object to the pool if it belongs to the pool, otherwise deletes it.
// Return a pooled object without heap cleanup
bool try_free(T* obj) noexcept;Returns obj to the pool and reports whether it was pool-owned. Non-pool objects are not deleted; the caller retains ownership.
// Check whether an object belongs to this pool
bool is_pool_member(const T* obj) const noexcept;// Query and diagnostic methods
uint32_t size() const noexcept; // Pool capacity (fixed)
uint32_t available() const noexcept; // Approximate objects currently in the pool
uint64_t heap_fallback_count() const noexcept; // Times allocate() had to use the heapavailable() reads both counters without synchronization, so under concurrent
traffic it is a recent estimate rather than a snapshot — use try_allocate()
if you need to know whether an object is there.
heap_fallback_count() is the one to watch in production. Every increment
means the pool was empty and allocate() returned a new T() instead. A
steadily rising count means the pool is undersized for its load, or that
per-thread caches are parking more objects than the pool can spare. It is
cumulative for the pool's lifetime (reset() does not clear it), so sample it
and compare deltas. The counter is only written on the fallback path, which is
already paying for an operator new, so it costs the fast path nothing.
For hot, latency-sensitive producers/consumers, wrap the pool with a per-thread cache to amortize shared-atomic costs:
slick::object_pool<MyStruct> pool(1024);
slick::object_pool_cache<MyStruct> cache(pool); // capacity derived from the pool
MyStruct* obj = cache.allocate(); // local freelist, then falls back to pool
cache.free(obj); // kept locally; coldest half returns when full
// cache.flush() returns all cached objects to the pool
// Override when your thread holds more than the default at once:
slick::object_pool_cache<MyStruct> deep(pool, 128);object_pool_cacheis not thread-safe; own exactly one per thread.- On destruction it flushes all cached objects back to the pool. Call
flush()explicitly when a thread goes idle, so its parked objects become available to the rest of the process. - When the local list fills, only the coldest half goes back to the pool.
The most recently freed objects stay local — they are both the hottest in
cache and the ones
allocate()hands out next.
Capacity is bounded from both directions, and the bounds come from opposite concerns:
peak objects held at once ≤ capacity ≤ pool.size() / (2 × caches)
Below the floor, the cache empties on every burst and each allocation pays full shared-pool contention. Measured on an 8-thread loop holding 16 objects at a time:
| capacity | ns/op |
|---|---|
| 8 | ~745 |
| 16 | ~6 |
| 64 | ~6 |
That is a cliff, not a curve — capacity below your burst depth costs ~100×, and capacity above it buys nothing.
Above the ceiling, caches collectively park more objects than the pool can spare and the pool silently degrades into a heap allocator. Producer/consumer handoff, 4 consumer-side caches:
| pool | capacity | max parked | heap fallbacks |
|---|---|---|---|
| 64 | 4 (derived default) | 16 | 0.2% |
| 64 | 64 | 256 | 75.0% |
| 256 | 16 (derived default) | 64 | 0.0% |
| 1024 | 64 (derived default) | 256 | 0.0% |
A thread that allocates and frees one object at a time never accumulates, so capacity barely matters to it. The floor matters for bursty threads; the ceiling matters whenever a thread frees more than it allocates.
If the two bounds cross, the pool is too small — enlarge it rather than
splitting the difference. heap_fallback_count() tells you when you have got
this wrong in production.
The default is derived from the pool, not a fixed number:
pool.size() / 16, clamped to [1, 64]. The divisor is fixed at 16 though, so
the default is guaranteed under the ceiling only up to 8 caches on one pool —
pool.size() / 16 ≤ pool.size() / (2 × caches) holds exactly while caches ≤ 8
(the 4-cache rows above are inside that bound). With more caches than that,
check the ceiling directly: caches × default_capacity(pool) ≤ pool.size() / 2.
An explicit capacity is honoured but clamped to [1, pool.size()] — a cache
bigger than the entire pool can only starve other threads. Note that the clamp
is a sanity bound, not a safe setting; with several caches on one pool you want
considerably less than pool.size() each.
// What a default-constructed cache would use, without constructing one:
size_t cap = slick::object_pool_cache<MyStruct>::default_capacity(pool);Objects stored in the pool must satisfy:
static_assert(std::is_default_constructible_v<T>);Valid types:
- POD types (int, float, etc.)
- std::string, std::vector, and other standard containers
- Structs with default constructors
- Classes with default constructors
Invalid types:
- Types without default constructors
- Types with deleted default constructors
| Platform | Status |
|---|---|
| Windows (MSVC) | ✅ Tested |
| Windows (MinGW) | ✅ Tested |
| Linux | ✅ Tested |
| macOS | ✅ Tested |
| FreeBSD | |
| Unix-like |
- C++ Standard: C++20 or later
- Compiler:
- GCC 10+
- Clang 11+
- MSVC 2019 16.8+
- Dependencies:
- Standard library only
- OS: Windows, Linux, macOS, or POSIX-compliant system
Link with rt and atomic libraries:
target_link_libraries(your_target PRIVATE slick::object-pool rt atomic)Or with command line:
g++ -std=c++20 your_app.cpp -lrt -latomic -o your_appmkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_SLICK_OBJECTPOOL_TESTS=ON
cmake --build .
ctest --output-on-failureAddressSanitizer (Memory errors):
Linux/macOS:
mkdir build-asan && cd build-asan
cmake .. -DENABLE_ASAN=ON -DBUILD_SLICK_OBJECTPOOL_TESTS=ON
cmake --build .
ctest --output-on-failureWindows:
# Build
cmake -B build -DENABLE_ASAN=ON -DBUILD_SLICK_OBJECTPOOL_TESTS=ON
cmake --build build --config DebugThreadSanitizer (Thread safety - Linux/macOS only):
mkdir build-tsan && cd build-tsan
cmake .. -DENABLE_TSAN=ON -DBUILD_SLICK_OBJECTPOOL_TESTS=ON
cmake --build .
ctest --output-on-failureUndefinedBehaviorSanitizer (UB detection - Linux/macOS only):
mkdir build-ubsan && cd build-ubsan
cmake .. -DENABLE_UBSAN=ON -DBUILD_SLICK_OBJECTPOOL_TESTS=ON
cmake --build .
ctest --output-on-failureSee TESTING.md for detailed sanitizer documentation.
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local
cmake --build .
sudo cmake --install .| Option | Default | Description |
|---|---|---|
BUILD_SLICK_OBJECTPOOL_TESTS |
ON | Build unit tests |
CMAKE_BUILD_TYPE |
Debug | Build type (Debug/Release) |
ENABLE_ASAN |
OFF | Enable AddressSanitizer |
ENABLE_TSAN |
OFF | Enable ThreadSanitizer (Linux/macOS) |
ENABLE_UBSAN |
OFF | Enable UndefinedBehaviorSanitizer (Linux/macOS) |
- ✅ Multiple producers can call
allocate()concurrently - ✅ Multiple consumers can call
free()concurrently - ✅ Mixed operations (allocate + free) are safe
- ❌ reset() is NOT thread-safe (use when no other threads are active)
The implementation uses C++20 atomic memory ordering:
- release on the producer side: the payload pointer is written (relaxed),
then the slot's
data_indexis published with areleasestore. - acquire on the consumer side: the slot's
data_indexis read with anacquireload before the payload is read as anacquireload. Together these establish the happens-before edge that makes the freed object's memory safe to reuse. - relaxed for the steady-state hot counters (
reserved_,consumed_) where only a single shared operation (onefetch_addfor producers, oneCASfor consumers) is required for correctness.
// ✅ Good: Power of 2
slick::object_pool<T> pool(1024);
// ❌ Bad: Not power of 2 (throws std::runtime_error)
slick::object_pool<T> pool(1000);
// Rule: size must be 2^N (256, 512, 1024, 2048, etc.)Sizing guidelines:
- Estimate peak concurrent allocations
- Add 20-50% headroom for bursts
- Round up to next power of 2
- Monitor pool exhaustion in production
// When pool is exhausted, allocate() allocates from heap
T* obj = pool.allocate(); // May return heap-allocated object
// free_object() detects and handles both cases
pool.free(obj); // Works for pool or heap objects// ✅ Good: Simple POD struct
struct SimpleType {
int id;
double values[10];
char name[32];
};
// ✅ Good: Types with STL containers
struct ComplexType {
int id;
std::string name; // OK!
std::vector<double> v; // OK!
};
// ❌ Bad: No default constructor
struct BadType {
BadType(int x) : value(x) {} // No default constructor
int value;
};
// ✅ Fix: Add default constructor
struct FixedType {
FixedType() = default; // Default constructor
FixedType(int x) : value(x) {}
int value = 0;
};- Pool size must be power of 2 - Required for efficient bitwise indexing
- Type must be default constructible - Required for pool initialization
- No automatic resize - Pool size is fixed at construction
- No memory reclamation - Objects returned to pool are reused, not freed
- Objects are not re-initialized on reuse - No constructor runs on
allocate(). A recycled object still holds whatever the previous user left in it; reset the fields you depend on yourself. Only heap-fallback objects (allocated when the pool is exhausted) come back freshly value-initialized, so do not rely on the initial state of an allocation either way. - A shared pool does not scale to many hot threads - Use a per-thread
object_pool_cache<T>; see Benchmarks
Q: What happens when the pool is exhausted?
A: allocate() automatically allocates from heap. free() detects and deletes heap-allocated objects.
Q: Can I use std::string or std::vector in pooled objects?
A: Yes - the pool works with any default constructible type. But note that a recycled object is handed back as its previous user left it: no constructor runs on allocate(), so a pooled std::vector member still holds the old elements and capacity. Clear or reassign such members yourself after allocating. (Retained capacity is often exactly what you want - it avoids re-allocating the buffer - just don't mistake it for a fresh object.)
Q: Is the pool real-time safe? A: Operations are lock-free but not wait-free. Allocation may fail and fall back to heap allocation.
Contributions are welcome! Please follow these guidelines:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Follow existing code style (4 spaces, no tabs)
- Add tests for new features
- Update documentation
- Ensure all tests pass
This project is licensed under the MIT License - see the LICENSE file for details.
Copyright (c) 2025 SlickQuant
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
- Design inspired by lock-free queue algorithms
- Cache optimization techniques from LMAX Disruptor
- Part of the SlickQuant performance toolkit
- slick_queue - Lock-free MPMC queue
Note: slick-object-pool is a standalone, zero-dependency library. No external dependencies required!
Made with ⚡ by SlickQuant