DEV Community

Cover image for Building a Thread-Safe Concurrent List in C# from Scratch
Spyros Ponaris
Spyros Ponaris

Posted on

Building a Thread-Safe Concurrent List in C# from Scratch

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();
    }
}

Enter fullscreen mode Exit fullscreen mode

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.");
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)