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) and ConcurrentBag<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:

  1. Lock Contention: When multiple threads fight for write locks, blocked threads queue up and trigger expensive OS context switches.
  2. CPU Cache Line Invalidation: Updating shared memory forces CPU cores to invalidate and reload local L1/L2 caches across cores.
  3. 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!