Table of Contents

Bodu.Collections.Concurrent - Getting started

Install

dotnet add package Bodu.Collections.Concurrent

Targets net8.0. No external runtime dependencies - the package references Bodu.Collections (which in turn references Bodu.Core), so both are pulled in automatically. The types live in the Bodu.Collections.Generic.Concurrent namespace.

Minimal samples

Concurrent circular buffer (ConcurrentCircularBuffer<T>)

A lock-free multi-producer / multi-consumer FIFO ring - no external lock needed:

using Bodu.Collections.Generic.Concurrent;

var ring = new ConcurrentCircularBuffer<Message>(capacity: 1024, allowOverwrite: true);

// Producers - may run on many threads concurrently.
ring.Enqueue(message);            // overwrites the oldest entry when full
ring.ItemEvicted += dropped => log.Warn("Dropped {Id}", dropped.Id);

// Consumers - also concurrent.
while (ring.TryDequeue(out Message? item))
    Process(item);

With allowOverwrite: false, Enqueue throws when full and TryEnqueue returns false. The buffer implements IProducerConsumerCollection<T> (TryAdd / TryTake), so wrap it in a BlockingCollection<T> when consumers should block for work. T must be a reference type, and the minimum capacity is 2 - see concepts for why.

Concurrent hash set (ConcurrentHashSet<T>)

A lock-free split-ordered set of unique elements - every operation is lock-free, so writers never block each other or readers:

using Bodu.Collections.Generic.Concurrent;

var seen = new ConcurrentHashSet<string>(StringComparer.OrdinalIgnoreCase);

// Dedup a concurrent stream: Add returns true only for the first arrival.
Parallel.ForEach(events, e =>
{
    if (seen.Add(e.CorrelationId))
        ProcessFirstOccurrence(e);
});

bool active = seen.Contains("req-42");   // lock-free - never blocks a writer
int count   = seen.Count;                // lock-free counter - exact at quiescence

Enumeration and ToArray() are weakly consistent lock-free traversals and never throw on concurrent modification.

Concurrent evicting dictionary (ConcurrentEvictingDictionary<TKey,TValue>)

A lock-striped bounded cache - the thread-safe variant of EvictingDictionary<TKey,TValue>, with the same six eviction policies and optional TTL expiry:

using Bodu.Collections.Generic.Concurrent;

var cache = new ConcurrentEvictingDictionary<string, Report>(
    capacity: 1024, EvictingDictionaryPolicy.LeastRecentlyUsed);

// Single-flight GetOrAdd: under concurrent misses the factory runs at most
// once per key, so an expensive load is never duplicated.
Parallel.ForEach(requests, request =>
{
    Report report = cache.GetOrAdd(request.Key, key => BuildReport(key));
    Serve(request, report);
});

cache.ItemEvicted += (key, _) => log.Debug("Evicted {Key}", key);

bool hit  = cache.TryGetValue("q-42", out Report? cached); // counts as an LRU access
int hot   = cache.ApproximateCount;                        // lock-free estimate
int exact = cache.Count;                                   // coherent - acquires every segment lock

The capacity bound is strict - the cache never stores more than capacity entries - while eviction order is exact within each internal segment and approximate globally. Pass an EvictingDictionaryExpiration to add absolute or sliding TTL on top of the capacity policy.

Concurrent LRU cache (ConcurrentLruCache<TKey,TValue>)

The read-optimized bounded cache - lookups take no lock at all:

using Bodu.Collections.Generic.Concurrent;

var cache = new ConcurrentLruCache<string, Report>(capacity: 1024);

Parallel.ForEach(requests, request =>
{
    // Lock-free on a hit. Note this is NOT single-flight: racing callers may each
    // run the factory, and one produced value is stored and returned to all.
    Report report = cache.GetOrAdd(request.Key, key => BuildReport(key));
    Serve(request, report);
});

cache.ItemEvicted += (key, _) => log.Debug("Evicted {Key}", key);

bool hit = cache.TryGetValue("q-42", out Report? cached);  // lock-free; records a hit or a miss
Console.WriteLine($"Hit ratio: {cache.HitRatio:P1}");      // striped counters, aggregated on demand

Use this when reads dominate and approximate recency is acceptable. Use ConcurrentEvictingDictionary<TKey,TValue> instead when you need a policy other than LRU, a strict capacity bound, TTL expiry, or a genuine cache-stampede guard.

Where to go next