While .NET provides powerful thread-safe collections in the System.Collections.Concurrent namespace—such as ConcurrentDictionary<TKey, TValue> and ConcurrentQueue<T>—it notably lacks a built-in ConcurrentList<T>.
If you have ever needed to read and write to a list from multiple background threads simultaneously, you quickly realize that standard collections like List<T> fall apart under concurrent pressure.
In this article, we will build a custom, production-grade ConcurrentList<T> from scratch in C# using ReaderWriterLockSlim, explore why dynamic lists are chosen over fixed arrays, test it with a multithreaded stress test, and analyze the performance costs involved.
1. Why Standard Collections Fail in Multithreaded Environments
When multiple threads read and write to a standard List<T> concurrently, several issues arise:
- Data Corruption: Interleaved writes corrupt internal array capacity tracking and element pointers.
- Race Conditions: Two threads inserting at the same index simultaneously will overwrite one another, resulting in lost data.
-
Exceptions: Modifying a collection while another thread is iterating over it throws an
InvalidOperationException.
2. Designing the Concurrent List: List vs. Array & Other Collections
When designing a thread-safe container, choosing a dynamic list (backed by a resizable array) instead of a fixed-size array or other concurrent structures involves critical trade-offs:
- Dynamic List vs. Fixed-Size Array: A fixed-size array requires you to know your exact maximum workload in advance. A dynamic list handles variable workloads automatically by resizing, but requires careful handling of internal array copying under locks.
-
Dynamic List vs. Queues/Bags: Built-in structures like
ConcurrentQueue<T>are strictly FIFO (First-In-First-Out) andConcurrentBag<T>is unordered. Neither supports index-based random access (Get(index)). If your multithreaded workers need to read or update specific items by index, a custom list is required.
To achieve high read concurrency while keeping writes safe, we will use ReaderWriterLockSlim, which allows multiple threads to read simultaneously while granting exclusive access to a single writer.
3. Complete C# Implementation
Here is the complete implementation of our thread-safe dynamic list, implementing IDisposable to properly clean up native synchronization handles:
using System;
using System.Threading;
public class ConcurrentList<T> : IDisposable
{
private T[] _elements;
private int _size = 0;
private const int DefaultCapacity = 10;
private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
public ConcurrentList()
{
_elements = new T[DefaultCapacity];
}
// Appends an element thread-safely (Write Lock)
public void Add(T item)
{
_rwLock.EnterWriteLock();
try
{
EnsureCapacity();
_elements[_size++] = item;
}
finally
{
_rwLock.ExitWriteLock();
}
}
// Retrieves an element at an index safely (Read Lock)
public T Get(int index)
{
_rwLock.EnterReadLock();
try
{
if (index < 0 || index >= _size)
{
throw new ArgumentOutOfRangeException(nameof(index), "Index is out of range.");
}
return _elements[index];
}
finally
{
_rwLock.ExitReadLock();
}
}
// Removes an element safely (Write Lock)
public void RemoveAt(int index)
{
_rwLock.EnterWriteLock();
try
{
if (index < 0 || index >= _size)
{
throw new ArgumentOutOfRangeException(nameof(index), "Index is out of range.");
}
int numMoved = _size - index - 1;
if (numMoved > 0)
{
Array.Copy(_elements, index + 1, _elements, index, numMoved);
}
_elements[--_size] = default!; // Clear reference for GC
}
finally
{
_rwLock.ExitWriteLock();
}
}
// Gets the current item count safely (Read Lock)
public int Count
{
get
{
_rwLock.EnterReadLock();
try
{
return _size;
}
finally
{
_rwLock.ExitReadLock();
}
}
}
private void EnsureCapacity()
{
if (_size == _elements.Length)
{
int newCapacity = _elements.Length * 2;
Array.Resize(ref _elements, newCapacity);
}
}
public void Dispose()
{
_rwLock?.Dispose();
}
}
4. Hands-On Threading Example: Testing Thread Safety
Let's put our ConcurrentList<T> to the test. We can use Task.Run and Task.WhenAll to spawn multiple background threads writing items concurrently while performing occasional reads:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
static async Task Main(string[] args)
{
const int numberOfTasks = 10;
const int itemsPerTask = 1000;
int expectedTotal = numberOfTasks * itemsPerTask;
Console.WriteLine($"Starting concurrent test: {numberOfTasks} tasks inserting {itemsPerTask} items each...");
using var concurrentList = new ConcurrentList<int>();
var tasks = new List<Task>();
var stopwatch = Stopwatch.StartNew();
// Spawn tasks writing to the concurrent list simultaneously
for (int i = 0; i < numberOfTasks; i++)
{
int taskId = i;
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < itemsPerTask; j++)
{
concurrentList.Add(taskId * itemsPerTask + j);
// Simulate occasional concurrent reads
if (j % 100 == 0 && concurrentList.Count > 0)
{
_ = concurrentList.Get(0);
}
}
}));
}
// Wait for all worker tasks to complete
await Task.WhenAll(tasks);
stopwatch.Stop();
Console.WriteLine($"\n--- Results ---");
Console.WriteLine($"Expected Item Count: {expectedTotal}");
Console.WriteLine($"Actual Item Count : {concurrentList.Count}");
Console.WriteLine($"Execution Time : {stopwatch.ElapsedMilliseconds} ms");
if (concurrentList.Count == expectedTotal)
{
Console.WriteLine("SUCCESS: All items inserted safely without data loss or exceptions!");
}
else
{
Console.WriteLine("FAILURE: Data corruption detected.");
}
}
}
Note: If you swap ConcurrentList<int> with a standard List<int> in the code above, the program will throw exceptions or finish with missing items due to data races.
5. The Performance Cost & Resizing Bottlenecks
While concurrent collections guarantee thread safety, they introduce a performance tax that you must manage carefully:
- Lock Contention: When multiple threads fight for write locks, blocked threads queue up and trigger expensive OS context switches.
- CPU Cache Line Invalidation: Updating shared memory forces CPU cores to invalidate and reload local L1/L2 caches across cores.
- Resizing Overhead: When a dynamic list hits capacity under heavy concurrency, a thread acquires the write lock, allocates a larger array, and copies existing items over—temporarily stalling all other threads.
Summary & When to Use
| Scenario | Impact | Recommended Solution |
|---|---|---|
| High Reads / Rare Writes | Low | Use ReaderWriterLockSlim or ImmutableList<T>. |
| High-Frequency Writes | High | Batch updates, use lock-free queues (ConcurrentQueue<T>), or partition data per thread. |
| Thread-Local Work | Avoid Locks | Use standard List<T> confined to a single thread, combining results later. |
Happy coding, and may your threads never deadlock!
Top comments (0)