Skip to content

Repository files navigation

slick-object-pool

C++20 License Platform Header-only Lock-free CI GitHub release

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.

Table of Contents

Features

🚀 Performance

  • 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

🔧 Architecture

  • 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

⚡ Use Cases

  • 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

Quick Start

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. Define SLICK_OBJECT_POOL_NO_DEPRECATION_WARNING to 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;
}

Installation

Header-Only Integration

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/

CMake Integration

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-pool

Then 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)

Usage Examples

Basic Usage

#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;
}

Multi-Threaded Usage

#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;
}

Architecture

Lock-Free MPMC Design

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 single fetch_add, publish the object pointer, then a release store on the slot.
  • Consumers (threads calling allocate()/try_allocate()) acquire-load the oldest slot, read the payload, then claim it with a single compare-and-swap. Reading before the claim is what makes the handoff safe: while consumed_ 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.

Cache Optimization

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_add does 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.

Memory Layout

object_pool instance
  ├─ Heap: buffer_[size_]       (actual objects)
  ├─ Heap: control_[size_]      (slot metadata)
  ├─ Heap: free_objects_[size_] (free list)
  └─ Stack: reserved_, consumed_ (atomics)

Performance

Benchmarks

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.

Comparison with Alternatives

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.

API Reference

Constructor

// Create pool in local memory
object_pool(uint32_t size);

Parameters:

  • size: Number of objects in pool (must be power of 2)

Methods

// 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 heap

available() 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.

Per-Thread Cache

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_cache is 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.

Choosing a capacity

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);

Type Requirements

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 Support

Platform Status
Windows (MSVC) ✅ Tested
Windows (MinGW) ✅ Tested
Linux ✅ Tested
macOS ✅ Tested
FreeBSD ⚠️ Should work
Unix-like ⚠️ Should work

Requirements

  • 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

Linux/Unix Additional Requirements

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_app

Building

Build Tests

mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_SLICK_OBJECTPOOL_TESTS=ON
cmake --build .
ctest --output-on-failure

Build with Sanitizers

AddressSanitizer (Memory errors):

Linux/macOS:

mkdir build-asan && cd build-asan
cmake .. -DENABLE_ASAN=ON -DBUILD_SLICK_OBJECTPOOL_TESTS=ON
cmake --build .
ctest --output-on-failure

Windows:

# Build
cmake -B build -DENABLE_ASAN=ON -DBUILD_SLICK_OBJECTPOOL_TESTS=ON
cmake --build build --config Debug

ThreadSanitizer (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-failure

UndefinedBehaviorSanitizer (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-failure

See TESTING.md for detailed sanitizer documentation.

Build and Install

mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local
cmake --build .
sudo cmake --install .

CMake Options

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)

Thread Safety

Guarantees

  • 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)

Memory Ordering

The implementation uses C++20 atomic memory ordering:

  • release on the producer side: the payload pointer is written (relaxed), then the slot's data_index is published with a release store.
  • acquire on the consumer side: the slot's data_index is read with an acquire load before the payload is read as an acquire load. 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 (one fetch_add for producers, one CAS for consumers) is required for correctness.

Best Practices

Pool Size Selection

// ✅ 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

Pool Exhaustion Handling

// 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

Type Design

// ✅ 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;
};

Limitations

  1. Pool size must be power of 2 - Required for efficient bitwise indexing
  2. Type must be default constructible - Required for pool initialization
  3. No automatic resize - Pool size is fixed at construction
  4. No memory reclamation - Objects returned to pool are reused, not freed
  5. 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.
  6. A shared pool does not scale to many hot threads - Use a per-thread object_pool_cache<T>; see Benchmarks

FAQ

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.

Contributing

Contributions are welcome! Please follow these guidelines:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Code Style

  • Follow existing code style (4 spaces, no tabs)
  • Add tests for new features
  • Update documentation
  • Ensure all tests pass

License

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.

Acknowledgments

  • Design inspired by lock-free queue algorithms
  • Cache optimization techniques from LMAX Disruptor
  • Part of the SlickQuant performance toolkit

Related Projects

Note: slick-object-pool is a standalone, zero-dependency library. No external dependencies required!


Made with ⚡ by SlickQuant

About

High-performance, lock-free object pool for C++20. Cache-optimized for zero false sharing. Header-only, cross-platform (Windows/POSIX). Perfect for real-time systems, game engines, and high-frequency trading.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages