ConcurrentCircularBuffer<T> Class
Definition
- Namespace
- Bodu.Collections.Generic.Concurrent
- Assembly
- Bodu.Collections.Concurrent.dll
- Package
- Bodu.Collections.Concurrent 1.0.0
Provides a lock-free, bounded first-in, first-out (FIFO) buffer with optional overwrite semantics.
public sealed class ConcurrentCircularBuffer<T> : IProducerConsumerCollection<T>, ICollection, IReadOnlyCollection<T>, IEnumerable<T>, IEnumerable where T : class?
Type Parameters
TThe reference type stored in the buffer (constraint:
where T : class).
- Inheritance
-
ConcurrentCircularBuffer<T>
- Implements
-
IEnumerable<T>
- Inherited Members
- Extension Methods
Examples
var buffer = new ConcurrentCircularBuffer<string>(capacity: 3, allowOverwrite: true);
buffer.Enqueue("A");
buffer.Enqueue("B");
buffer.Enqueue("C");
buffer.Enqueue("D"); // "A" is evicted
if (buffer.TryPeek(out var head))
Console.WriteLine(head); // "B"
Console.WriteLine(buffer.Dequeue()); // "B"
Remarks
This type implements a multi-producer/multi-consumer (MPMC) circular buffer using per-slot sequence numbers (Vyukov pattern). The buffer capacity is fixed at construction time and must be at least 2.
The Vyukov MPMC sequence protocol uses two distinct sequence marks per slot: one written by the producer when data
is published (tail + 1), and one written by the consumer when the slot is released (head + capacity).
These marks must be numerically distinct so that concurrent producers can determine whether a slot is free or still
occupied. With a capacity of 1 they are always equal for every round, making the two states indistinguishable and
allowing a second concurrent producer to overwrite an occupied slot, permanently skipping a sequence number and
leaving consumers in an infinite spin. A minimum capacity of 2 is therefore required for the protocol to be correct.
When AllowOverwrite is true, enqueuing into a full buffer evicts the oldest element and raises the ItemEvicted event (after removal). Ordinary handler exceptions are caught and suppressed (a process-fatal OutOfMemoryException still propagates); this differs intentionally from the non-concurrent CircularBuffer<T>, which propagates handler exceptions. Under MPMC the eviction has already been committed by the time the event fires (the head counter has advanced and the slot has been freed by another consumer or producer running in parallel), so propagating a handler exception cannot abort the eviction and would only obscure the cause of failure for unrelated callers. Subscribers that need to react to a throwing handler should perform their own catch/log in the handler body.
The Count property is approximate under concurrency. For a stable point-in-time view of contents, use ToArray().
Enumeration iterates over a true snapshot captured at the moment the enumerator is created and does not reflect subsequent changes.
Slots are padded to 64 bytes - the standard cache-line size on x86/x64 hardware - to prevent false sharing between adjacent producer- and consumer-touched slots. Targets with larger cache lines (notably Apple Silicon and some ARM SoCs at 128 bytes) remain correct; the padding is conservative on those platforms but does not degrade throughput.
The generic type parameter is constrained to class? because the slot value is published and cleared through
Volatile overloads that target reference types. Value-type element support would
require a different publication mechanism and is intentionally out of scope for this type.
Any capacity of at least two is supported, including non-power-of-two values, and Capacity reports
the exact requested value. One caveat applies at extreme longevity: the head and tail counters are 32-bit, and the
physical slot index is their unsigned modulo the capacity. When the capacity is not a power of two, the counter's
wrap at 2^32 operations shifts that modulo by 2^32 mod capacity, which could misalign a single slot
once every ~4.29 billion enqueue/dequeue operations. A power-of-two capacity divides 2^32 evenly and is
therefore free of the caveat entirely; callers whose buffers process on the order of billions of operations without
recreation should prefer one. Eliminating the caveat for arbitrary capacities would require widening the counters to
64-bit and is intentionally out of scope for this type.
Constructors
ConcurrentCircularBuffer()
Initializes a new instance of the ConcurrentCircularBuffer<T> class with default capacity and overwriting enabled.
public ConcurrentCircularBuffer()
ConcurrentCircularBuffer(IEnumerable<T>, int, bool)
Initializes a new instance of the ConcurrentCircularBuffer<T> class. Initializes a new instance
by copying from collection, using the specified capacity and overwrite behavior.
public ConcurrentCircularBuffer(IEnumerable<T> collection, int capacity, bool allowOverwrite = true)
Parameters
collectionIEnumerable<T>The collection whose elements are copied into the buffer. Must not be null.
capacityintThe maximum number of elements the buffer can hold. Must be at least 2. See the class remarks for an explanation of why the Vyukov MPMC protocol requires a minimum capacity of 2.
allowOverwritebooltrue to evict the oldest element when the buffer is full; false to throw on overflow. Defaults to true.
Exceptions
- ArgumentNullException
collectionis null.- ArgumentOutOfRangeException
capacity< 2.- InvalidOperationException
allowOverwriteis false and the number of items incollectionexceedscapacity.
ConcurrentCircularBuffer(int)
Initializes a new instance of the ConcurrentCircularBuffer<T> class with the specified capacity and overwriting enabled.
public ConcurrentCircularBuffer(int capacity)
Parameters
capacityintThe maximum number of elements the buffer can hold. Must be at least 2. See the class remarks for an explanation of why the Vyukov MPMC protocol requires a minimum capacity of 2.
Exceptions
- ArgumentOutOfRangeException
capacity< 2.
ConcurrentCircularBuffer(int, bool)
Initializes a new instance of the ConcurrentCircularBuffer<T> class with the specified capacity and overwrite behavior.
public ConcurrentCircularBuffer(int capacity, bool allowOverwrite)
Parameters
capacityintThe maximum number of elements the buffer can hold. Must be at least 2. See the class remarks for an explanation of why the Vyukov MPMC protocol requires a minimum capacity of 2.
allowOverwritebooltrue to evict the oldest element when the buffer is full; false to throw or return false instead.
Exceptions
- ArgumentOutOfRangeException
capacity< 2.
Properties
AllowOverwrite
Gets or sets a value indicating whether enqueuing into a full buffer evicts the oldest element.
public bool AllowOverwrite { get; set; }
Property Value
- bool
true to evict the oldest element when full; false to throw or return false from the producer-side methods.
Remarks
Toggling this property concurrently with an in-flight Enqueue(T) or TryEnqueue(T) is safe but may have a benign window where a producer that observed the previous value commits its behavior (eviction or rejection) before the new value takes effect. The window does not corrupt buffer state.
Capacity
Gets the fixed capacity of the buffer.
public int Capacity { get; }
Property Value
Count
Gets an approximate count of elements currently contained in the buffer.
public int Count { get; }
Property Value
- int
The number of elements observed at the time of the call. Under concurrency, the value may be transiently stale. For a stable point-in-time count, call ToArray() and use its length.
Remarks
The count is computed as the difference between the tail and head positions, clamped to the range
[0, Capacity]. Because these two positions are read independently, the result is
approximate.
The clamping guards against both the transient appearance of a negative difference under concurrent modification and the correct handling of signed counter wrapping after sustained high-volume use.
this[int]
Gets the element at the specified zero-based logical index relative to the oldest element.
public T this[int index] { get; }
Parameters
indexintThe zero-based index of the element to retrieve. Must be non-negative and less than Count observed at the moment of the call.
Property Value
- T
The element that was at the specified logical position during the call.
Remarks
The accessor performs a sequence-validated single-slot read; it does not allocate a snapshot. Two consecutive
index reads (for example, buffer[i] followed by buffer[i + 1]) are not jointly atomic - concurrent
producers or consumers may modify the buffer between the two reads. Callers that require joint atomicity across
multiple positions should call ToArray() once and index the resulting array.
Exceptions
- ArgumentOutOfRangeException
indexis negative, or is greater than or equal to the number of elements in the buffer observed at the time of the call.- InvalidOperationException
The buffer is under sustained concurrent modification and a stable single-slot read could not be obtained within the retry budget.
Methods
Clear()
Removes elements from the buffer up to the number present at the time of the call.
public void Clear()
Remarks
This method drains at most the number of elements observed when Clear() is called. Elements added by concurrent producers after this point are not removed. This bounding prevents an indefinite loop when AllowOverwrite is true and producers are continuously enqueueing.
The ItemEvicted event is not raised by this operation, as clearing the buffer is not considered an eviction.
Contains(T?)
Determines whether the buffer contains the specified element using Default.
public bool Contains(T? item)
Parameters
itemTThe element to locate. May be null.
Returns
- bool
true if a sequence-stable read found a match within the live region during the call; otherwise false.
Remarks
Walks the live region using a sequence-validated direct slot scan; no array allocation occurs in the common case. Under sustained concurrent modification the scan restarts; if the retry budget is exhausted, a single coherent ToArray() snapshot is used as a fallback.
CopyTo(T[], int)
Copies a snapshot of the buffer to array starting at index.
public void CopyTo(T[] array, int index)
Parameters
arrayT[]The destination array. Must not be null.
indexintThe zero-based index in
arrayat which copying begins.
Exceptions
- ArgumentNullException
arrayis null.- ArgumentOutOfRangeException
indexis less than zero.- ArgumentException
The number of elements in the buffer exceeds the available space in
arrayfromindexonward.
Dequeue()
Removes and returns the oldest element.
public T Dequeue()
Returns
- T
The oldest element in the buffer.
Exceptions
- InvalidOperationException
The buffer is empty.
Enqueue(T)
Adds an element to the end of the buffer, throwing when full if overwriting is disabled.
public void Enqueue(T item)
Parameters
itemTThe element to add. May be null.
Exceptions
- InvalidOperationException
The buffer is full and AllowOverwrite is false.
GetEnumerator()
Returns an enumerator that iterates over a point-in-time snapshot of the buffer's contents.
public ConcurrentCircularBuffer<T>.Enumerator GetEnumerator()
Returns
- ConcurrentCircularBuffer<T>.Enumerator
An ConcurrentCircularBuffer<T>.Enumerator that iterates through the elements in FIFO order, from oldest to newest, as they existed at the moment the enumerator was created.
Remarks
Enumeration operates on an atomic snapshot captured via ToArray() at the moment this method is called. Any elements enqueued or dequeued after the enumerator is created are not reflected in the enumerated sequence.
Because the enumerator operates on a snapshot, it will never throw InvalidOperationException due to concurrent modification - unlike enumerators on non-concurrent collections.
Peek()
Returns the oldest element without removing it.
public T Peek()
Returns
- T
The oldest element in the buffer.
Exceptions
- InvalidOperationException
The buffer is empty.
ToArray()
Returns a snapshot of the buffer's contents in FIFO order.
public T[] ToArray()
Returns
- T[]
An array containing the elements observed in the buffer, ordered from oldest to newest. Returns an empty array if the buffer is empty.
Remarks
Each slot in the snapshot is read using a sequence-validated seqlock pattern: the slot's coordination sequence is read both before and after the value, and the read is committed only when both sequence observations match the expected published mark. This guarantees the value, when committed, was the element published at that logical position - never a value from an earlier or later generation.
If a slot cannot be stabilized within its retry budget, the entire snapshot is restarted. After the outer retry
budget is exhausted under sustained churn, a best-effort snapshot is returned in which individual slots that
still cannot be stabilized contribute the default value of T; every committed slot in
that fallback path is still sequence-validated, so a torn or stale-generation reference is never returned.
TryDequeue(out T?)
Attempts to remove and return the oldest element.
public bool TryDequeue(out T? item)
Parameters
Returns
TryEnqueue(T)
Attempts to add an element to the end of the buffer without throwing when full.
public bool TryEnqueue(T item)
Parameters
itemTThe element to add. May be null.
Returns
- bool
true if the element was enqueued; false if the buffer is full and AllowOverwrite is false.
TryPeek(out T?)
Attempts to return the oldest element without removing it.
public bool TryPeek(out T? item)
Parameters
Returns
Remarks
This method retries on transient races where another thread concurrently dequeues the head element between the head position read and the slot sequence check. It returns false only when the buffer is observed to be empty.
Events
ItemEvicted
Occurs immediately after an item has been evicted because a new item was enqueued into a full buffer while AllowOverwrite is true.
public event Action<T>? ItemEvicted
Event Type
- Action<T>
Remarks
Ordinary exceptions thrown by handlers are caught and suppressed; a process-fatal OutOfMemoryException is allowed to propagate to the caller of Enqueue(T) rather than being masked. Each subscriber's invocation is guarded independently so that a throwing handler cannot prevent later handlers from receiving the notification.
This differs from the non-concurrent ItemEvicted, which propagates handler exceptions to the caller of Enqueue(T). Under MPMC the eviction has already been committed by the time this event fires, so propagating the exception would block the caller for a failure unrelated to their own write and could not undo the eviction.
Count under contention. When multiple producers overwrite a full buffer concurrently, a single logical "enqueue into a full buffer" can trigger more than one eviction: a producer may evict the oldest element to free a slot, lose that freed slot to another producer before it can claim it, and evict again. Each eviction still removes a distinct real element in FIFO order and fires this event exactly once, so data is never lost or duplicated - but the total number of firings is an upper bound on the number of admissions, not a one-to-one signal. A subscriber counting evictions against enqueues will see them diverge under write pressure. In the uncontended (single-producer) case the ratio is exactly one eviction per overflow admission. Handlers run inside the lock-free dequeue path, so they must not perform heavy work.
Explicit Interface Implementations
IProducerConsumerCollection<T>.TryAdd(T)
Attempts to add item to the buffer using the producer-side path.
bool IProducerConsumerCollection<T>.TryAdd(T item)
Parameters
itemTThe element to add. May be null.
Returns
- bool
true if the element was enqueued; false if the buffer is full and AllowOverwrite is false.
Remarks
Forwards to TryEnqueue(T). Provided to satisfy IProducerConsumerCollection<T> so the buffer can be wrapped by BlockingCollection<T> for blocking-consumer scenarios.
IProducerConsumerCollection<T>.TryTake(out T)
Attempts to remove and return the oldest element from the buffer.
bool IProducerConsumerCollection<T>.TryTake(out T item)
Parameters
itemTWhen this method returns true, contains the removed element; when it returns false, contains the default value of
T.
Returns
Remarks
Forwards to TryDequeue(out T?). Because T is constrained to a reference
type, the MaybeNullWhenAttribute on the interface signature is honored: the out parameter is
null when the method returns false.
IEnumerable<T>.GetEnumerator()
Returns an enumerator that iterates over a point-in-time snapshot of the buffer's contents.
IEnumerator<T> IEnumerable<T>.GetEnumerator()
Returns
- IEnumerator<T>
An ConcurrentCircularBuffer<T>.Enumerator that iterates through the elements in FIFO order, from oldest to newest, as they existed at the moment the enumerator was created.
Remarks
Enumeration operates on an atomic snapshot captured via ToArray() at the moment this method is called. Any elements enqueued or dequeued after the enumerator is created are not reflected in the enumerated sequence.
Because the enumerator operates on a snapshot, it will never throw InvalidOperationException due to concurrent modification - unlike enumerators on non-concurrent collections.
ICollection.CopyTo(Array, int)
Copies the elements of the ConcurrentCircularBuffer<T> to a one-dimensional, zero-based Array, starting at the specified index.
void ICollection.CopyTo(Array array, int index)
Parameters
arrayArrayThe destination array. Must not be null, must be single-dimensional, and must have zero-based indexing.
indexintThe zero-based index in
arrayat which copying begins.
Remarks
This method takes an atomic snapshot of the buffer before copying. The destination array reflects the state of the buffer at the moment the snapshot was taken and is not affected by concurrent modifications made after that point.
This method supports the non-generic ICollection interface and is intended for interop scenarios. For strongly typed copies, use CopyTo(T[], int) instead.
Exceptions
- ArgumentNullException
arrayis null.- ArgumentException
arrayis multidimensional, does not have zero-based indexing, its element type is incompatible withT, or the number of elements in the buffer exceeds the available space fromindexto the end ofarray.- ArgumentOutOfRangeException
indexis less than zero.
ICollection.IsSynchronized
Gets a value indicating whether access to the ConcurrentCircularBuffer<T> is synchronized (thread safe).
bool ICollection.IsSynchronized { get; }
Returns
- bool
Always false. ConcurrentCircularBuffer<T> manages its own internal synchronization and does not expose a public lock object.
Remarks
Thread safety is achieved through internal lock-free or fine-grained locking mechanisms. Callers should not attempt to coordinate access externally via SyncRoot, as that property is not supported.
ICollection.SyncRoot
Gets an object that can be used to synchronize access to the collection. Not supported on this type - ConcurrentCircularBuffer<T> manages its own internal synchronization.
object ICollection.SyncRoot { get; }
Returns
Remarks
Exposing a SyncRoot would allow callers to take the same lock used internally, undermining the concurrency guarantees of the collection. This matches the behavior of ConcurrentQueue<T> and other BCL concurrent collections.
Exceptions
- NotSupportedException
Always thrown. Use the thread-safe members of this class directly.
IEnumerable.GetEnumerator()
Returns an enumerator that iterates over a point-in-time snapshot of the buffer's contents.
IEnumerator IEnumerable.GetEnumerator()
Returns
- IEnumerator
An ConcurrentCircularBuffer<T>.Enumerator that iterates through the elements in FIFO order, from oldest to newest, as they existed at the moment the enumerator was created.
Remarks
Enumeration operates on an atomic snapshot captured via ToArray() at the moment this method is called. Any elements enqueued or dequeued after the enumerator is created are not reflected in the enumerated sequence.
Because the enumerator operates on a snapshot, it will never throw InvalidOperationException due to concurrent modification - unlike enumerators on non-concurrent collections.
Applies to
| Product | Versions |
|---|---|
| .NET | 8, 10 |