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!


