I've been working on a lockless multi-producer, multi-consumer queue in an effort to learn as much as I can about concurrency, without the use of mutual exclusion. The queue uses a bounded ring buffer to store/load the data from. Two cursors are used two track the next available index on the buffer, one for producers and one for consumers. The cursors are a custom sequential container type, which uses memory barriers to order access to some data. Inspiration is taken from the LMAX Disruptor here. Once a producer or consumer has successfully incremented the cursor, it can then safely do its work on the "claimed" index, knowing any following producer or consumer will be accessing a higher index. The queue size is specified by the user, but ceiled up to the nearest power of two, so a bitwise-and can be used in place of modulo to automatically wrap the index back to zero.. The queue works as it is right now, I've got two different version of the enqueu/dequeue functions, with one using a CAS loop to increment the cursor, and the other using a fetch_add.
The main issue I'm having is that the fetch_add version of enqueue is about 10x faster than the CAS version, which I don't really understand. Then second, I'm getting quite a bit of false sharing reported in Intel VTune Profiler, even though I've padded everything.
Anyway, here's the code:
The Enqueue/Dequeue & EnqueueCAS/DequeueCAS functions are inside the TMPMCQueue class at the bottom.
// SPDX-License-Identifier: GPL-2.0-or-later
/** Lockless Multi-Producer Multi-Consumer Queue Type.
* Author: Primrose Taylor
*/
#ifndef MPMCQUEUE_H
#define MPMCQUEUE_H
#include "stdio.h"
#include "stdlib.h"
#include <atomic>
#define PLATFORM_CACHE_LINE_SIZE 64
/**
* A container which can ensure that access to it's data will be sequentially consistent across all accessing threads,
* but allows for getting the data via a custom memory order.
*/
template <typename T>
class TSequentialContainer
{
public:
TSequentialContainer()
{
static_assert(
std::is_copy_constructible_v<T> ||
std::is_copy_assignable_v<T> ||
std::is_move_assignable_v<T> ||
std::is_move_constructible_v<T>,
"Can't use non-copyable, non-assignable, non-movable, or non-constructible type!"
);
}
explicit TSequentialContainer(const T& InitialValue)
{
TSequentialContainer();
Data.store(InitialValue, std::memory_order_seq_cst);
}
/**
* Get the data, using an acquire fence to ensure that any prior write is visible to this load.
*/
T Get() const
{
const T OutCopy = Data.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);
return OutCopy;
}
/**
* Load the data with relaxed semantics. NOTE: NOT THREAD SAFE!
*/
T GetRelaxed() const
{
return Data.load(std::memory_order_relaxed);
}
T GetCustom(const std::memory_order MemoryOrder) const
{
return Data.load(MemoryOrder);
}
/**
* Set the data, first performing a release fence.
* The release fence will ensure that any subsequent read will see this write.
*/
void Set(const T& NewData)
{
std::atomic_thread_fence(std::memory_order_release);
Data.store(NewData, std::memory_order_relaxed);
}
/**
* Set the data by first performing a release fence, then storing the data,
* then performing a full fence.
*/
void SetFullFence(const T& NewData)
{
std::atomic_thread_fence(std::memory_order_release);
Data.store(NewData, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_seq_cst);
}
void SetCustom(const T& NewData, const std::memory_order MemoryOrder)
{
Data.store(NewData, MemoryOrder);
}
/**
* Perform a CAS operation on the stored data.
* Uses release semantics if works.
* Uses relaxed semantics if failed.
*/
bool CompareAndSet(T& Expected, const T& NewValue)
{
return Data.compare_exchange_weak(Expected, NewValue,
std::memory_order_release, std::memory_order_relaxed);
}
protected:
uint_fast8_t PadToAvoidContention0[PLATFORM_CACHE_LINE_SIZE] = { };
/**
* An atomic variable which holds the data.
*/
std::atomic<T> Data;
uint_fast8_t PadToAvoidContention1[PLATFORM_CACHE_LINE_SIZE] = { };
private:
TSequentialContainer(const TSequentialContainer&) = delete;
TSequentialContainer& operator=(const TSequentialContainer&) = delete;
};
/**
* A simple child class of the @link TSequentialContainer which uses an int64 instead of a template.
* Providing some extra functions specific to modifying an integer.
*/
class FSequentialInteger : public TSequentialContainer<int_fast64_t>
{
public:
FSequentialInteger(const int_fast64_t InitialValue = 0)
: TSequentialContainer()
{
SetFullFence(InitialValue);
}
/**
* Uses a fetch_add with Acquire/Release semantics to increment the integer.
*
* @return Returns the original value of the integer.
*/
int_fast64_t AddAndGetOldValue(const int_fast64_t Value)
{
return Data.fetch_add(Value, std::memory_order_acq_rel);
}
/**
* @link AddAndGetOldValue()
*/
int_fast64_t AddAndGetNewValue(const int_fast64_t Value)
{
return AddAndGetOldValue(Value) + Value;
}
/**
* @link AddAndGetNewValue()
* @link AddAndGetOldValue()
*/
int_fast64_t IncrementAndGetOldValue()
{
return AddAndGetOldValue(1);
}
/**
* @link IncrementAndGetOldValue()
*/
void Increment()
{
IncrementAndGetOldValue();
}
void IncrementRelaxed()
{
Data.fetch_add(1, std::memory_order_relaxed);
}
void operator=(const int_fast64_t NewValue)
{
SetFullFence(NewValue);
}
};
/**
* Enum used to represent each status output from the Enqueue/Dequeue functions inside @link TMPMCQueue
*/
enum class EMPMCQueueErrorStatus : uint_fast8_t
{
TRANSACTION_SUCCESS,
BUFFER_FULL,
BUFFER_EMPTY,
BUFFER_NOT_INITIALIZED,
COPY_FAILED,
COPY_SUCCESS,
BUFFER_COPY_FAILED,
BUFFER_COPY_SUCCESS
};
/**
* A Lockless Multi-Producer, Multi-Consumer Queue that uses
* a bounded ring buffer to store the data.
* @link TSequentialContainer A sequential container of type T, which uses memory barriers to sync access to it's data.
* @link FSequentialInteger A sequential integer container, which uses memory barriers to sync access to it's data.
* @link EMPMCQueueErrorStatus Enum used to represent each status output from the Enqueue/Dequeue functions.
*
* @template T The type to use for the queue.
* @template TQueueSize The size you want the queue to be. This will be rounded UP to the nearest power of two.
*
* @biref A Lockless Multi-Producer, Multi-Consumer Queue.
*/
template <typename T, uint_fast64_t TQueueSize>
class TMPMCQueue final
{
private:
using FElementType = T;
using FCursor = FSequentialInteger;
public:
TMPMCQueue()
{
if(TQueueSize == 0 || TQueueSize == UINT64_MAX)
{
return;
}
/**
* Ceil the queue size to the nearest power of 2
* @cite https://graphics.stanford.edu/~seander/bithacks.html#RoundUpPowerOf2
*/
uint_fast64_t NearestPower = TQueueSize;
{
NearestPower--;
NearestPower |= NearestPower >> 1; // 2 bit
NearestPower |= NearestPower >> 2; // 4 bit
NearestPower |= NearestPower >> 4; // 8 bit
NearestPower |= NearestPower >> 8; // 16 bit
NearestPower |= NearestPower >> 16; // 32 bit
NearestPower |= NearestPower >> 32; // 64 bit
NearestPower++;
}
IndexMask.store(NearestPower - 1); // Set the IndexMask to be one less than the NearestPower
/** Allocate the ring buffer. */
RingBuffer = (FBufferNode*)calloc(NearestPower, sizeof(FBufferNode));
for(uint_fast64_t i = 0; i < NearestPower; ++i)
{
RingBuffer[i].Data = (FElementType*)malloc(sizeof(FElementType));
}
ConsumerCursor.SetFullFence(0);
ProducerCursor.SetFullFence(0);
}
~TMPMCQueue()
{
if(RingBuffer == nullptr)
return;
free(RingBuffer);
RingBuffer = nullptr;
}
/**
* Add a new element to the queue.
*
* @link Dequeue()
* @link FSequentialInteger::Get()
* @link TSequentialContainer::IncrementAndGetOldValue()
* @param NewElement The new element to add to the queue.
*
* @return An error status, used to check if the add worked.
*/
EMPMCQueueErrorStatus Enqueue(const FElementType& NewElement)
{
/** Get the Consumer & Producer cursor values, using an acquire fence */
const int_fast64_t CurrentConsumerCursor = ConsumerCursor.Get();
const int_fast64_t CurrentProducerCursor = ProducerCursor.Get();
/** Return false if the buffer is full */
if((CurrentProducerCursor + 1) == CurrentConsumerCursor)
{
return EMPMCQueueErrorStatus::BUFFER_FULL;
}
/** Perform a fetch_add with acquire_release semantics */
const int_fast64_t ClaimedIndex = ProducerCursor.IncrementAndGetOldValue(); // fetch_add
/** Calculate the index, avoiding the use of modulo */
const int_fast64_t ClaimedIndexMask = ClaimedIndex & IndexMask.load(std::memory_order_relaxed);
/** Update the index on the ring buffer with the new element */
*RingBuffer[ClaimedIndexMask].Data = NewElement;
return EMPMCQueueErrorStatus::TRANSACTION_SUCCESS;
}
EMPMCQueueErrorStatus EnqueueCAS(const FElementType& NewElement)
{
/** Get the Consumer & Producer cursor values, using an acquire fence */
const int_fast64_t CurrentConsumerCursor = ConsumerCursor.Get();
const int_fast64_t CurrentProducerCursor = ProducerCursor.Get();
/** Return false if the buffer is full */
if((CurrentProducerCursor + 1) == CurrentConsumerCursor)
{
return EMPMCQueueErrorStatus::BUFFER_FULL;
}
int_fast64_t ClaimedIndex = CurrentProducerCursor;
while(!ProducerCursor.CompareAndSet(ClaimedIndex, ClaimedIndex + 1)) // CAS loop
{
ClaimedIndex = ProducerCursor.Get();
_mm_pause();
}
/** Calculate the index, avoiding the use of modulo */
const int_fast64_t ThisIndexMask = ClaimedIndex & IndexMask.load(std::memory_order_relaxed);
/** Update the index on the ring buffer with the new element */
*RingBuffer[ThisIndexMask].Data = NewElement;
return EMPMCQueueErrorStatus::TRANSACTION_SUCCESS;
}
/**
* Claim an element from the queue.
*
* @link Enqueue()
* @link FSequentialInteger::Get()
* @link TSequentialContainer::IncrementAndGetOldValue()
* @param Output A reference to the variable to store the output in.
*
* @link EMPMCQueueErrorStatus
* @return An error status, used to check if the add worked.
*/
EMPMCQueueErrorStatus Dequeue(FElementType& Output)
{
/** Get the Consumer & Producer cursor values, using an acquire fence */
const int_fast64_t CurrentConsumerCursor = ConsumerCursor.Get();
const int_fast64_t CurrentProducerCursor = ProducerCursor.Get();
// Check if the buffer is empty
if(CurrentConsumerCursor == CurrentProducerCursor)
{
return EMPMCQueueErrorStatus::BUFFER_EMPTY;
}
/** Perform a fetch_add with acquire_release semantics */
const int_fast64_t ClaimedIndex = ConsumerCursor.IncrementAndGetOldValue(); // fetch_add
/** Calculate the index, avoiding the use of modulo */
const int_fast64_t ClaimedIndexMask = ClaimedIndex & IndexMask.load(std::memory_order_relaxed);
/** Store the claimed element from the ring buffer in the Output var */
Output = *RingBuffer[ClaimedIndexMask].Data;
return EMPMCQueueErrorStatus::TRANSACTION_SUCCESS;
}
EMPMCQueueErrorStatus DequeueCAS(FElementType& Output)
{
const int_fast64_t CurrentConsumerCursor = ConsumerCursor.Get();
const int_fast64_t CurrentProducerCursor = ProducerCursor.Get();
if(CurrentConsumerCursor == CurrentProducerCursor)
{
return EMPMCQueueErrorStatus::BUFFER_EMPTY;
}
int_fast64_t ClaimedIndex = CurrentConsumerCursor;
while(!ConsumerCursor.CompareAndSet(ClaimedIndex, ClaimedIndex + 1)) // CAS loop
{
ClaimedIndex = ConsumerCursor.Get();
_mm_pause();
}
/** Calculate the index, avoiding the use of modulo */
const int_fast64_t ThisIndexMask = ClaimedIndex & IndexMask.load(std::memory_order_relaxed);
/** Update the index on the ring buffer with the new element */
Output = *RingBuffer[ThisIndexMask].Data;
return EMPMCQueueErrorStatus::TRANSACTION_SUCCESS;
}
private:
struct FBufferNode
{
FBufferNode() noexcept
: Data(nullptr)
{
}
uint_fast8_t PadToAvoidContention0[PLATFORM_CACHE_LINE_SIZE] = { };
FElementType* Data;
uint_fast8_t PadToAvoidContention1[PLATFORM_CACHE_LINE_SIZE] = { };
};
private:
uint_fast8_t PadToAvoidContention0[PLATFORM_CACHE_LINE_SIZE] = { };
/** Stores a value that MUST be one less than a power of two e.g 1023.
* Used to calculate an index for access to the @link RingBuffer.
*/
std::atomic<uint_fast64_t> IndexMask;
uint_fast8_t PadToAvoidContention1[PLATFORM_CACHE_LINE_SIZE] = { };
/**
* This is the pointer to the ring buffer which holds the queue's data.
* This is allocated in the default constructor using calloc.
*/
FBufferNode* RingBuffer;
uint_fast8_t PadToAvoidContention2[PLATFORM_CACHE_LINE_SIZE] = { };
/**
* The cursor that holds the next available index on the ring buffer for Consumers.
*/
FCursor ConsumerCursor;
uint_fast8_t PadToAvoidContention3[PLATFORM_CACHE_LINE_SIZE] = { };
/**
* The cursor that holds the next available index on the ring buffer for Producers.
*/
FCursor ProducerCursor;
uint_fast8_t PadToAvoidContention4[PLATFORM_CACHE_LINE_SIZE] = { };
private:
TMPMCQueue(const TMPMCQueue&) = delete;
TMPMCQueue& operator=(const TMPMCQueue&) = delete;
};
#endif // MPMCQUEUE_H
A quick example usage, use TIMES_TO_CYCLE and THREAD_COUNT macros to try different workloads:
#include "MPMCQueue.h"
/** Define how many times to Produce/Consume an element. */
#define TIMES_TO_CYCLE 10000000
/** Total number of threads. Must be a multiple of two for this example. */
#define THREAD_COUNT 16
/** Declare a queue which stores data of type int, and has a size of 500,000.
* The size will be ceiled up to the nearest power of two. */
static TMPMCQueue<int, 500000> MyQueue;
/** Atomic counter used to track how many threads have completed. */
static FSequentialInteger ThreadsCompleteCount;
int main()
{
/** Create a producer & consumer each iteration. */
for(int_fast64_t i = 0; i < (THREAD_COUNT / 2); ++i)
{
// Create a Producer
std::thread([]()
{
const int NumberToAddLoads = 100;
/** Execute TIMES_TO_CYCLE Enqueue operations. */
for(int_fast64_t j = 0; j < TIMES_TO_CYCLE; ++j)
{
MyQueue.Enqueue(NumberToAddLoads); // fetch_add version
// MyQueue.EnqueueCAS(NumberToAddLoads); // CAS version
}
/** Increment the counter to indicate that this thread has finished. */
ThreadsCompleteCount.Increment();
}).detach(); // Detach the thread from the main thread.
// Create a Consumer
std::thread([]()
{
int NumberToHoldLoads = 0;
/** Execute TIMES_TO_CYCLE Dequeue operations. */
for(int_fast64_t j = 0; j < TIMES_TO_CYCLE; ++j)
{
MyQueue.Dequeue(NumberToHoldLoads); // fetch_add version
// MyQueue.DequeueCAS(NumberToHoldLoads); // CAS version
}
/** Increment the counter to indicate that this thread has finished. */
ThreadsCompleteCount.Increment();
}).detach(); // Detach the thread from the main thread.
}
/** Spin pause until threads are complete. */
while(ThreadsCompleteCount.GetRelaxed() < THREAD_COUNT)
{
_mm_pause();
}
return 0;
}
fetch_add enqueue assembly:
mov rdx, qword ptr [rip+0x3fb9]
nop
mov rcx, qword ptr [rip+0x4079]
nop
inc rcx
cmp rcx, rdx
jz 0x140001317 <Block 4>
Block 3:
mov rdx, r8
lock xadd qword ptr [rip+0x4064], rdx
mov rcx, qword ptr [rip+0x3ec5]
and rcx, rdx
imul rdx, rcx, 0x88
mov rcx, qword ptr [rip+0x3efc]
mov rdx, qword ptr [rdx+rcx*1+0x40]
mov dword ptr [rdx], 0x64
And here's the much larger enqueue using a CAS loop:
mov rdx, qword ptr [rip+0x3f89]
nop
mov rcx, qword ptr [rip+0x4049]
nop
lea rax, ptr [rcx+0x1]
cmp rax, rdx
jz 0x140001362 <Block 7>
Block 3:
nop dword ptr [rax], eax
Block 4:
lea rdx, ptr [rcx+0x1]
mov rax, rcx
lock cmpxchg qword ptr [rip+0x4028], rdx
jz 0x14000133e <Block 6>
Block 5:
mov rcx, qword ptr [rip+0x401f]
nop
pause
jmp 0x140001320 <Block 4>
Block 6:
mov rax, qword ptr [rip+0x3e7b]
nop
and rax, rcx
imul rcx, rax, 0x88
mov rax, qword ptr [rip+0x3eb1]
mov rcx, qword ptr [rcx+rax*1+0x40]
mov dword ptr [rcx], 0x64
I'm on Windows 10 with an i9 9900k. I hope the above information is useful. I'm not used to profiling something like this, so please forgive any naiveity.
Any help would be great, cheers.
static_assert
on the container type could be arequires
expression, or aconcept
. \$\endgroup\$