SlickQueue is a header-only C++ library that provides a lock-free, multi-producer multi-consumer (MPMC) queue built on a ring buffer, plus a lossless single-producer single-consumer (SPSC) queue. Both are designed for high throughput concurrent messaging and can optionally operate over shared memory for inter-process communication.
- Lock-free operations for multiple producers and consumers
- Two queue types:
slick::queuenever blocks the producer but may overwrite unread items.slick::spsc_queuenever overwrites and tells the producer when the ring is full instead. See below - Header-only implementation - just include and go
- No allocation on the hot path: a local queue allocates its buffers once in the constructor, and a shm queue maps its segment
- Shared memory support for inter-process communication, backed by slick-shm
- Cross-platform - supports Windows, Linux, and macOS
- Modern C++20 implementation
- 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 and slick/spsc_queue.hpp include <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.
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.cppBoth 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.
SlickQueue is available in the vcpkg package manager:
vcpkg install slick-queueThe 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.
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.4.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).
Note:
slick::queue<T>is the preferred name and is used throughout this documentation. It is an alias template forslick::SlickQueue<T>, which remains available for backward compatibility.
#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
}#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
}Segment lifetime: a queue never removes its shared-memory segment on destruction, not even
the queue that created it. On POSIX the name stays until it is unlinked, so if a departing process
unlinked it while a peer was still attached, that peer would keep reading the old mapping while
a restarted or late process created a new segment under the same name. Instead, the process that
coordinates the queue's lifetime calls remove_shm() (or
slick::shm::shared_memory::remove(name)) once no peer will attach again. A process that
restarts in the meantime reattaches to the existing segment and keeps its contents. On Windows the
segment is freed automatically when its last handle closes.
// Coordinator, after every worker has shut down
server.remove_shm();#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();#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)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 == 64read() 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().
slick::spsc_queue<T> in slick/spsc_queue.hpp is for exactly one producer and one consumer.
Unlike slick::queue it is lossless: it never overwrites an unread item, and try_reserve()
tells the producer when the ring is full. It has no per-item control slot and no CAS or fetch_add.
#include "slick/spsc_queue.hpp"
slick::spsc_queue<int> q(1024); // or q(1024, "my_shm") to create, q("my_shm") to attach
// Producer
auto i = q.try_reserve(); // invalid_index when full; reserve() spins instead
if (i != q.invalid_index) {
*q[i] = 42;
q.publish(i);
}
// Consumer
if (int* v = q.read()) { /* next item, valid until the next read()/read_last()/release() */ }
if (int* v = q.read_last()) { /* newest item; every older unread one is dropped */ }reserve(n)reservesnseparate items. Write itemkthroughq[i + k], because a batch may wrap around the end of the ring. Publish in reservation order. Publishing a later batch first also exposes the earlier one, and publishing the earlier one afterwards is a no-op.- A pointer from
read()orread_last()stays valid until the consumer's nextread(),read_last()orrelease(). That call hands the slot back to the producer. The held item counts against thesize()capacity, so withsize() == 1the producer waits for that call after every item. read_last()loads the producer's tail and moves the read cursor there. Its cost doesn't grow with the backlog, and the producer does no extra work for it. It returnsnullptrif nothing was published since the last read.read_last(dropped)also setsdroppedto the number of unread items it skipped (0 onnullptr).- In shared memory both cursors live in the segment. A consumer that reattaches resumes where the previous one stopped, and gets the item that one was holding again. Call
release()before detaching to avoid that. - The second template parameter takes a
slick::spsc_queue_traits. Its one knob isenable_cpu_relax(defaulttrue), which pauses between attempts whilereserve()waits for room.
Shared-memory layout (128-byte line spacing on every platform):
| Offset | Field |
|---|---|
| 8, 12 | size, sizeof(T) (uint32) |
| 24 | marker 'SLS0' (uint32), same offset as slick::queue's 'SLQx', so each type rejects the other's segment at attach time |
| 48 | init state (uint32), same protocol as slick::queue |
| 128 | tail (uint64), written by the producer |
| 256 | head (uint64), written by the consumer |
| 384 | T[size], so alignof(T) must be at most 128 (checked at compile time) |
// 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/Attacheritems_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.
uint64_t reserve(uint32_t n = 1)- Reservenslots for writing (non-blocking; may overwrite old data if consumers lag).nis rounded up to a multiple ofitems_per_slot()T* operator[](uint64_t slot)- Access reserved slotvoid publish(uint64_t slot, uint32_t n = 1)- Publishnwritten items to consumersstd::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 cursoruint32_t size()- Get queue capacityuint32_t items_per_slot()- Get the minimum number of items a singlereserve()consumesuint32_t slot_count()- Get the number of control slots,size() / items_per_slot()bool use_shm()- Whether the queue lives in shared memoryconst char* shm_name()- Get the shared-memory segment name (empty string for a local-memory queue)bool remove_shm()- Unlink the shared-memory segment's name so the next queue created with it starts fresh; open mappings stay valid. The destructor never does this (see Segment lifetime). Returnsfalsefor a local-memory queueuint64_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
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 templateDerive 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()requiresenable_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_lastmust 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 astd::runtime_errorat 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 freezeread_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 = truein a derived traits struct leaves the inherited member visible and keeps the base value. Thequeue_traits_typeconcept 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.
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
- 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))
cmake -S . -B build
cmake --build buildThe 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.
# Using CTest
cd build
ctest --output-on-failure
# Or run directly
./build/tests/slick-queue-testsBUILD_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::queuenever carries the flags, so consumers are unaffected. CI runs the suite this way on every platform.CMAKE_BUILD_TYPE- Set toReleaseorDebug
cmake -S . -B build-asan -DSLICK_QUEUE_ENABLE_ASAN=ON
cmake --build build-asan
cd build-asan && ctest --output-on-failureWith MSVC, the ASan runtime DLL is copied next to the test binary automatically, so no PATH
setup is needed.
SlickQueue is released under the MIT License.
Made with ⚡ by SlickQuant