Skip to content

Latest commit

 

History

98 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SlickQueue

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

SlickQueue is a header-only C++ library that provides a lock-free, multi-producer multi-consumer (MPMC) queue built on a ring buffer. It is designed for high throughput concurrent messaging and can optionally operate over shared memory for inter-process communication.

Features

  • Lock-free operations for multiple producers and consumers
  • Header-only implementation - just include and go
  • Zero dynamic allocation on the hot path for predictable performance
  • Shared memory support for inter-process communication, backed by slick-shm
  • Cross-platform - supports Windows, Linux, and macOS
  • Modern C++20 implementation

Requirements

  • C++20 compatible compiler
  • CMake 3.10+ (for building tests)
  • slick-shm v0.1.5+ - header-only shared memory library used by slick/queue.hpp

slick/queue.hpp includes <slick/shm/shared_memory.hpp>, so slick-shm must be available on the include path even when the queue is used purely in-process. The vcpkg port and the CMake build both resolve this dependency for you; only a manual copy-the-headers installation requires action.

Installation

Header-Only (manual)

SlickQueue is header-only, but so is its slick-shm dependency - copy both include directories into your project and add both to your include path:

third_party/
    slick-queue/include/slick/queue.hpp
    slick-shm/include/slick/shm/...
g++ -std=c++20 -Ithird_party/slick-queue/include -Ithird_party/slick-shm/include main.cpp

Both libraries install under the same slick/ prefix, so their include directories can also be merged into a single tree (include/slick/queue.hpp alongside include/slick/shm/) and passed as one -I path. Then:

#include "slick/queue.hpp"

On Linux, also link rt, pthread, and atomic (-lrt -lpthread -latomic); macOS needs only -lpthread and Windows needs no extra libraries.

Using vcpkg

SlickQueue is available in the vcpkg package manager:

vcpkg install slick-queue

The port declares slick-shm as a dependency, so it is installed automatically. Then in your CMakeLists.txt:

find_package(slick-queue CONFIG REQUIRED)
target_link_libraries(your_target PRIVATE slick::queue)

Linking slick::queue transitively pulls in slick::shm and the platform libraries it needs.

Using CMake FetchContent

include(FetchContent)

# Disable tests for slick-queue
set(BUILD_SLICK_QUEUE_TESTS OFF CACHE BOOL "" FORCE)
FetchContent_Declare(
    slick-queue
    GIT_REPOSITORY https://github.com/SlickQuant/slick-queue.git
    GIT_TAG v2.1.0  # See https://github.com/SlickQuant/slick-queue/releases for latest version
)
FetchContent_MakeAvailable(slick-queue)

target_link_libraries(your_target PRIVATE slick::queue)

slick-queue first looks for an installed slick-shm with find_package(slick-shm CONFIG); if none is found it fetches slick-shm itself via FetchContent, so no extra declaration is needed. To pin your own version, make slick::shm available (installed or declared) before FetchContent_MakeAvailable(slick-queue).

Usage

Note: slick::queue<T> is the preferred name and is used throughout this documentation. It is an alias template for slick::SlickQueue<T>, which remains available for backward compatibility.

Basic Example

#include "slick/queue.hpp"

// Create a queue with 1024 slots (must be power of 2)
slick::queue<int> queue(1024);

// Producer: reserve a slot, write data, and publish
auto slot = queue.reserve();
*queue[slot] = 42;
queue.publish(slot);

// Consumer: read from queue using a cursor
uint64_t cursor = 0;
auto result = queue.read(cursor);
if (result.first != nullptr) {
    int value = *result.first;  // value == 42
}

Shared Memory Example (IPC)

#include "slick/queue.hpp"

// Process 1 (Server/Writer)
slick::queue<int> server(1024, "my_queue");
auto slot = server.reserve();
*server[slot] = 100;
server.publish(slot);

// Process 2 (Client/Reader)
slick::queue<int> client("my_queue");
uint64_t cursor = 0;
auto result = client.read(cursor);
if (result.first != nullptr) {
    int value = *result.first;  // value == 100
}

Multi-Producer Multi-Consumer

#include "slick/queue.hpp"
#include <thread>

slick::queue<int> queue(1024);

// Multiple producers
auto producer = [&](int id) {
    for (int i = 0; i < 100; ++i) {
        auto slot = queue.reserve();
        *queue[slot] = id * 1000 + i;
        queue.publish(slot);
    }
};

// Multiple consumers (each maintains independent cursor)
// Note: Each consumer will see ALL published items (broadcast pattern)
auto consumer = [&](int id) {
    uint64_t cursor = 0;
    int count = 0;
    while (count < 200) {  // 2 producers × 100 items each
        auto result = queue.read(cursor);
        if (result.first != nullptr) {
            int value = *result.first;
            ++count;
        }
    }
};

std::thread p1(producer, 1);
std::thread p2(producer, 2);
std::thread c1(consumer, 1);
std::thread c2(consumer, 2);

p1.join(); p2.join();
c1.join(); c2.join();

Work-Stealing with Shared Atomic Cursor

#include "slick/queue.hpp"
#include <thread>
#include <atomic>

slick::queue<int> queue(1024);
std::atomic<uint64_t> shared_cursor{0};

// Multiple producers
auto producer = [&](int id) {
    for (int i = 0; i < 100; ++i) {
        auto slot = queue.reserve();
        *queue[slot] = id * 1000 + i;
        queue.publish(slot);
    }
};

// Multiple consumers sharing atomic cursor (work-stealing/load-balancing)
// Each item is consumed by exactly ONE consumer
auto consumer = [&]() {
    int count = 0;
    for (int i = 0; i < 100; ++i) {
        auto result = queue.read(shared_cursor);
        if (result.first != nullptr) {
            int value = *result.first;
            ++count;
        }
    }
    return count;
};

std::thread p1(producer, 1);
std::thread p2(producer, 2);
std::thread c1(consumer);
std::thread c2(consumer);

p1.join(); p2.join();
c1.join(); c2.join();
// Total items consumed: 200 (each item consumed exactly once)

Byte Buffers and Control-Array Memory

Every reservation is tracked by a 16-byte control slot. By default there is one per item, which is negligible for large T but dominates for a byte buffer: slick::queue<char>(16M) needs 16 MB of data and 256 MB of control slots.

Pass items_per_slot to fix that. items_per_slot is the minimum number of items a single reserve() consumes. Any reserve(n) with n <= items_per_slot takes exactly one unit of items_per_slot items; a larger one takes ceil(n / items_per_slot) units. One control slot covers one unit, so the control array shrinks to size / items_per_slot slots.

// 16M byte buffer, minimum 64 bytes per reservation:
// 16 MB data + 4 MB control, instead of 16 MB + 256 MB
slick::queue<char> buf(16u << 20, 64);

auto i = buf.reserve(3);    // consumes 64 items (the minimum); i is a multiple of 64
std::memcpy(buf[i], "abc", 3);
buf.publish(i, 3);

auto j = buf.reserve(200);  // consumes 256 items (4 units), still one control slot
// ... write 200 bytes at buf[j], then
buf.publish(j, 200);

uint64_t cursor = 0;
auto [data, n] = buf.read(cursor);  // n == 3, cursor == 64

read() returns the size you published, while the cursor advances by the whole units the reservation consumed. The trade-off is that a message smaller than items_per_slot still costs a full unit, so the queue holds at most size / items_per_slot messages - choose it to match your typical message size, not your largest. Both values must be powers of two, with items_per_slot <= size.

As always, publish() should be given the same n as the matching reserve().

API Overview

Constructor

// In-process queue
queue(uint32_t size);
queue(uint32_t size, uint32_t items_per_slot);

// Shared memory queue
queue(uint32_t size, const char* shm_name);                           // Writer/Creator
queue(uint32_t size, uint32_t items_per_slot, const char* shm_name);  // Writer/Creator
queue(const char* shm_name);                                          // Reader/Attacher

items_per_slot defaults to 1. It is recorded in the shared-memory header: the attacher constructor adopts it, just as it adopts size, and a creator constructor that opens an existing segment with a different value throws std::runtime_error. Segments created before this field existed read as items_per_slot = 1.

When items_per_slot is not 1, the creator also sets bit 1 of the layout marker ('SLQ3', or 'SLQ2' without enable_read_last). Versions 2.0.0 and earlier do not know this field and would index the control array one slot per item, but they reject any marker bit they do not recognise - so they fail loudly at attach time with "created with unknown layout features" instead of misreading the segment. A segment with the default items_per_slot = 1 keeps 'SLQ1'/'SLQ0' and remains fully compatible with older peers.

Core Methods

  • uint64_t reserve(uint32_t n = 1) - Reserve n slots for writing (non-blocking; may overwrite old data if consumers lag). n is rounded up to a multiple of items_per_slot()
  • T* operator[](uint64_t slot) - Access reserved slot
  • void publish(uint64_t slot, uint32_t n = 1) - Publish n written items to consumers
  • std::pair<T*, uint32_t> read(uint64_t& cursor) - Read next available item (independent cursor)
  • std::pair<T*, uint32_t> read(std::atomic<uint64_t>& cursor) - Read next available item (shared atomic cursor for work-stealing)
  • std::pair<T*, uint32_t> read_last() - Read the most recently published item without a cursor
  • uint32_t size() - Get queue capacity
  • uint32_t items_per_slot() - Get the minimum number of items a single reserve() consumes
  • uint32_t slot_count() - Get the number of control slots, size() / items_per_slot()
  • uint64_t loss_count() const - Get count of skipped items due to overwrite (0 when the feature is off)
  • void reset() - Reset the queue, invalidating all existing data

Configuring Features (Traits)

Optional features are selected through a traits template parameter, not preprocessor macros:

template<typename T, queue_traits_type Traits = default_queue_traits>
class SlickQueue;                 // slick::queue<T, Traits> is the same template

Derive from slick::queue_traits and override only what you need:

struct my_traits : slick::queue_traits {
    static constexpr bool enable_reset_check = true;   // opt in
    static constexpr bool enable_read_last   = false;  // opt out
};

slick::queue<int, my_traits> lean(1024);
slick::queue<int>            standard(1024);  // default traits - both can coexist
Trait Default Effect when enabled
enable_read_last true publish() maintains a last-published index so read_last() works. Costs one CAS per publish and one cacheline per instance.
enable_reset_check false read() loads the producer's reservation counter and rewinds the cursor to 0 if it has run past it, which happens only when reset() rewound the counter. Without this, a reader holding a pre-reset() cursor returns nullptr indefinitely and then skips the start of the new generation.
enable_loss_detection Debug only Per-instance skipped-item counter, reported by loss_count(). Costs one cacheline per instance.
enable_cpu_relax true pause/yield backoff on contended CAS loops. Disabling may cut latency in short bursts but raises CPU use under load.

default_queue_traits is debug_queue_traits (loss detection on) in Debug builds and queue_traits (off) in Release, matching the previous NDEBUG-keyed behaviour. slick::queue<T> and slick::SlickQueue<T> therefore keep working exactly as before.

Notes:

  • read_last() requires enable_read_last. Calling it otherwise is a compile error, not a silent fallback to the old reserved-cursor heuristic, which reported reservations that were never published and truncated sizes above 65,535.
  • enable_read_last must match across a shared-memory segment. It is the one trait that changes the shared header protocol, so the creator records it in the segment's layout marker ('SLQ1' when the last-published index is maintained, 'SLQ0' when it is not) and every attacher checks it. A peer that disagrees is rejected with a std::runtime_error at construction instead of silently corrupting the other side's view - in either direction: an attacher that expects the index would read a counter nobody writes, and one that does not maintain it would freeze read_last() for every peer that does. The other traits are local to each process and can differ freely on one segment.
  • A misspelled override is silent. enable_reset_chek = true in a derived traits struct leaves the inherited member visible and keeps the base value. The queue_traits_type concept catches a wrong type, but cannot catch a typo.

Migrating from SLICK_QUEUE_ENABLE_*: the two macros are gone. Defining one now emits a warning and has no effect - move the setting into a traits struct. Because differently configured queues are different types, mismatched translation units fail to link instead of silently violating the ODR as the macros did.

Important Constraints

Lock-Free Atomics Implementation: slick::queue uses a packed 64-bit atomic internally to guarantee lock-free operations on all platforms. This packs both the write index (48 bits) and the reservation size (16 bits) into a single atomic value.

Lossy Semantics: slick::queue does not apply backpressure. If producers advance by at least the queue size before a consumer reads, older entries will be overwritten and the consumer will skip ahead to the latest value for a slot. Size the queue and read frequency to bound loss.

Reserve Size: read_last() reads the last published index, which is tracked separately from the packed reservation atomic, so the 16-bit reservation size no longer bounds it. Segments created before v1.4.0 carry no layout marker and are rejected at attach time, so that limitation cannot be reached any more.

  • The 48-bit index supports up to 2^48 (281 trillion) iterations, sufficient for any practical application

Performance Characteristics

  • Lock-free: No mutex contention between producers/consumers
  • Wait-free reads: Consumers never block each other
  • Cache-friendly: Ring buffer design with power-of-2 sizing
  • Predictable: No allocations or system calls on hot path (except for initial reserve when full)
  • Tunable hot path: per-instantiation traits remove per-operation atomics from the reader/writer hot path when a feature is not needed, and drop its aligned counter from the object (see Configuring Features (Traits))

Building and Testing

Build Tests

cmake -S . -B build
cmake --build build

The configure step resolves slick-shm with find_package(slick-shm CONFIG) and falls back to downloading it with FetchContent, so the first configure needs network access unless slick-shm is already installed (for example via vcpkg, using -DCMAKE_TOOLCHAIN_FILE=<vcpkg>/scripts/buildsystems/vcpkg.cmake). GoogleTest is fetched the same way for the test target.

Run Tests

# Using CTest
cd build
ctest --output-on-failure

# Or run directly
./build/tests/slick-queue-tests

Build Options

  • BUILD_SLICK_QUEUE_TESTS - Enable/disable test building (default: ON)
  • SLICK_QUEUE_ENABLE_ASAN - Build the tests, and the GoogleTest they link, with AddressSanitizer (default: OFF). Only the test binary is instrumented; slick::queue never carries the flags, so consumers are unaffected. CI runs the suite this way on every platform.
  • CMAKE_BUILD_TYPE - Set to Release or Debug
cmake -S . -B build-asan -DSLICK_QUEUE_ENABLE_ASAN=ON
cmake --build build-asan
cd build-asan && ctest --output-on-failure

With MSVC, the ASan runtime DLL is copied next to the test binary automatically, so no PATH setup is needed.

License

SlickQueue is released under the MIT License.

Made with ⚡ by SlickQuant

About

A robust, ring buffer based, lock-free, C++ MPMC queue with shared memory support.

Topics

Resources

Stars

9 stars

Watchers

3 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages