Threading and Synchronization Patterns in Production .NET Libraries

Threading bugs occupy a uniquely miserable position in the taxonomy of software defects. They're intermittent, resistant to reproduction, and frequently invisible until the system is under real load in production. After maintaining dozens of .NET libraries that must be thread-safe, I've converged on a set of patterns that make concurrency explicit, testable, and, above all, predictable.

This post walks through the threading patterns I use across the ktsu.dev library ecosystem, illustrated with code drawn from production libraries.

Pattern 1: Lock Objects as Parameters

The most common threading mistake I encounter is synchronization hidden inside a class where callers can't see or coordinate with it. The ktsu.dev libraries take a different approach: make the lock object a parameter.

public static TDest DeepClone<TItem, TDest>(
    this IEnumerable<TItem> items, object lockObj)
    where TItem : class, IDeepCloneable<TItem>
    where TDest : ICollection<TItem>, new()
{
    ArgumentNullException.ThrowIfNull(items);
    ArgumentNullException.ThrowIfNull(lockObj);
    lock (lockObj)
    {
        return DeepClone<TItem, TDest>(items);
    }
}

The rationale for passing the lock object in from the caller is straightforward: the caller is the one who understands the broader synchronization context. If the lock is buried inside the method, callers must trust that the internal lock covers everything they need coordinated, and in practice, it rarely does. Two operations that are individually thread-safe can still produce a race condition when composed, unless they share the same lock.

This pattern makes threading contracts explicit in three ways:

The naming convention reinforces visibility. Methods that accept a lock object use signatures like ForEach(lockObj, action) or DeepClone<T>(items, lockObj), so the threading requirement is apparent at every call site without consulting documentation.

Pattern 2: Thread Dispatch Queues

When operations must execute on a specific thread (UI updates, GPU resource access, or single-threaded subsystems), a dispatch queue is a cleaner solution than scattering Invoke calls throughout the codebase. The Invoker library implements this pattern:

public class Invoker
{
    private int ThreadId { get; } = Environment.CurrentManagedThreadId;
    internal ConcurrentQueue<Task> TaskQueue { get; } = new();

    public async Task InvokeAsync(Action func)
    {
        Ensure.NotNull(func);

        if (ThreadId == Environment.CurrentManagedThreadId)
        {
            func();  // Same thread — execute immediately
            return;
        }

        Task task = new(func);
        TaskQueue.Enqueue(task);
        await task.ConfigureAwait(false);
    }

    public void DoInvokes()
    {
        if (ThreadId != Environment.CurrentManagedThreadId)
        {
            throw new InvalidOperationException(
                "This method must be called on the thread that created the Invoker instance.");
        }

        while (TaskQueue.TryDequeue(out Task? task))
        {
            task.RunSynchronously();
        }
    }
}

The design follows four principles:

  1. Capture the thread identity at construction. The Invoker remembers which thread owns it, establishing the contract for the lifetime of the object.
  2. Same-thread calls bypass the queue entirely. If the calling code is already on the correct thread, queueing would add latency for no benefit.
  3. Cross-thread calls enqueue and await. The caller blocks (asynchronously) until the owning thread processes the work, providing a clear completion signal.
  4. The owning thread drains the queue on its own schedule. Typically this happens in an update loop or frame tick, giving the owning thread full control over when queued work executes.

ConcurrentQueue<Task> handles the thread-safe producer-consumer mechanics. ConfigureAwait(false) prevents deadlocks by avoiding attempts to resume on the original synchronization context. The thread ID check in DoInvokes turns misuse (calling it from the wrong thread) into an immediate, diagnosable exception rather than a silent corruption.

This pattern is used throughout the ImGuiApp framework to marshal operations onto the render thread:

public static Invoker Invoker { get; internal set; } = null!;

// From any thread:
await ImGuiApp.Invoker.InvokeAsync(() => UpdateTexture(newData));

Pattern 3: Preventing Overlapping Execution

The IntervalAction library runs an action on a recurring timer, but it must guarantee that a slow execution doesn't overlap with the next scheduled run. Overlapping periodic work is a common source of resource contention and data corruption:

public class IntervalAction
{
    private Lock Lock { get; } = new();
    internal bool ShouldPoll { get; set; }
    internal Task? ActionTask { get; set; }
    internal DateTimeOffset LastRunTime { get; set; } = DateTimeOffset.MinValue;

    internal bool TryRun()
    {
        // Check if previous task completed (and propagate exceptions)
        if (ActionTask?.IsCompleted ?? false)
        {
            if (ActionTask.Exception is not null)
                throw ActionTask.Exception.GetBaseException();
            ActionTask = null;
        }

        DateTimeOffset lastRunTime;
        lock (Lock) { lastRunTime = LastRunTime; }

        if (ActionInterval >= TimeSpan.Zero
            && ActionTask is null
            && DateTimeOffset.Now - lastRunTime > ActionInterval)
        {
            ActionTask = Task.Run(() =>
            {
                if (IntervalType == IntervalType.FromLastStart)
                    lock (Lock) { LastRunTime = DateTimeOffset.Now; }

                Action();

                if (IntervalType == IntervalType.FromLastCompletion)
                    lock (Lock) { LastRunTime = DateTimeOffset.Now; }
            });

            return true;
        }
        return false;
    }
}

Several deliberate decisions shape this implementation:

Pattern 4: ReaderWriterLockSlim to Lock Migration

.NET 9 introduced the Lock type, which is simpler, faster, and doesn't require disposal. For libraries that multi-target across framework versions, both mechanisms must coexist. The MachineMonitor project demonstrates this with conditional compilation:

public record MetricHistory(TimeSpan Duration, string Unit = "") : IDisposable
{
#if NET9_0_OR_GREATER
    private readonly Lock _lock = new();
#else
    private readonly ReaderWriterLockSlim _lock = new();
#endif

    public float Current
    {
        get
        {
#if NET9_0_OR_GREATER
            lock (_lock) { return Values.Count > 0 ? Values.Back() : 0; }
#else
            try
            {
                _lock.EnterReadLock();
                return Values.Count > 0 ? Values.Back() : 0;
            }
            finally { _lock.ExitReadLock(); }
#endif
        }
    }

    public void Add(float value)
    {
#if NET9_0_OR_GREATER
        lock (_lock) { /* write logic */ }
#else
        try
        {
            _lock.EnterWriteLock();
            // same write logic
        }
        finally { _lock.ExitWriteLock(); }
#endif
    }
}

The selection criteria are straightforward:

The try/finally approach with ReaderWriterLockSlim is verbose, but it's non-negotiable. If the protected code throws an exception, the lock must still be released. Omitting the finally block risks deadlocking the entire application on the next access attempt.

Pattern 5: Thread-Safe Singletons

The CredentialCache library uses a singleton with an additional constraint: it must be configurable before first access, and configuration must be impossible afterward:

public sealed class CredentialCache : IDisposable
{
    private static readonly object _lock = new();
    private static CredentialCache? _instance;
    private static IPersistenceProvider<string>? _persistenceProvider;

    public static CredentialCache Instance
    {
        get
        {
            lock (_lock)
            {
                if (_instance is null)
                {
                    _persistenceProvider ??= CreateDefaultPersistenceProvider();
                    _instance = new CredentialCache(_persistenceProvider);
                }
                return _instance;
            }
        }
    }

    public static void ConfigurePersistenceProvider(
        IPersistenceProvider<string> persistenceProvider)
    {
        lock (_lock)
        {
            if (_instance is not null)
                throw new InvalidOperationException(
                    "Cannot configure after instance has been created.");
            _persistenceProvider = persistenceProvider;
        }
    }
}

The design centers on a single shared lock that governs both configuration and instance creation. ConfigurePersistenceProvider and Instance contend on the same lock, so the ordering invariant (configure before access) is enforced mechanically rather than by documentation alone. An InvalidOperationException at runtime makes violations immediately visible.

For the credential data itself, the class relies on ConcurrentDictionary rather than manual locking:

private ConcurrentDictionary<PersonaGUID, Credential> Credentials { get; }

public bool TryGet(PersonaGUID guid, out Credential? credential) =>
    Data.Credentials.TryGetValue(guid, out credential);

The guideline here is simple: use ConcurrentDictionary when individual operations are independent and self-contained. Use explicit locking when multiple operations must appear atomic, such as a read-then-write sequence where the write depends on the result of the read.

Testing for Thread Safety

Thread safety that is not tested is thread safety that does not exist. The following patterns are designed to surface real concurrency bugs, not just verify single-threaded correctness.

Concurrent Access Stress Tests

The goal is to hammer a shared resource from multiple threads simultaneously, verifying that every operation produces the correct result:

[TestMethod]
public void CredentialCacheIsThreadSafeUnderConcurrentAccess()
{
    var cache = CredentialCache.Instance;
    int numberOfThreads = 10;
    int operationsPerThread = 100;
    List<Task> tasks = [];

    for (int i = 0; i < numberOfThreads; i++)
    {
        tasks.Add(Task.Run(() =>
        {
            for (int j = 0; j < operationsPerThread; j++)
            {
                var guid = CredentialCache.CreatePersonaGUID();
                var credential = factory.Create();
                cache.AddOrReplace(guid, credential);
                bool result = cache.TryGet(guid, out var retrieved);
                Assert.IsTrue(result);
                Assert.AreEqual(credential, retrieved);
            }
        }));
    }

    Task.WaitAll([.. tasks]);
}

Ten threads each performing a hundred add-then-retrieve cycles creates a high probability of interleaving. If any synchronization is missing, the assertions will fail intermittently. That's exactly the failure mode a test suite should catch before production does.

No-Overlap Verification

This test verifies that the overlap-prevention logic in IntervalAction actually works under timing pressure:

[TestMethod]
public async Task NoOverlappingExecutions()
{
    int executions = 0;
    var options = new IntervalActionOptions
    {
        PollingInterval = TimeSpan.FromMilliseconds(50),
        ActionInterval = TimeSpan.FromMilliseconds(100),
        Action = () =>
        {
            Interlocked.Increment(ref executions);
            Thread.Sleep(500);  // Simulate slow work
        }
    };

    var action = IntervalAction.Start(options);
    await Task.Delay(1200);
    action.Stop();

    // If overlapping occurred, executions would be much higher
    Assert.IsTrue(executions <= 3);
}

The action takes 500ms but is polled every 50ms with a 100ms interval. Without overlap prevention, the system would start many concurrent executions. The assertion that executions <= 3 verifies that the guard is working. Note the use of Interlocked.Increment. A bare executions++ would itself be a race condition, and the test would be unreliable for the exact reason it's trying to test.

Parallel Clone Independence

This test confirms that deep cloning under concurrent access produces truly independent copies:

[TestMethod]
public void ConcurrentDeepClone_ProducesIndependentCopies()
{
    var original = new ComplexObject { Id = 1, Name = "Parent" };
    ConcurrentBag<ComplexObject> results = [];

    Parallel.For(0, 100, _ =>
    {
        var clone = original.DeepClone();
        results.Add(clone);
    });

    Assert.AreEqual(100, results.Count);
    foreach (var clone in results)
    {
        clone.Id = 999;  // Mutate clone
    }
    Assert.AreEqual(1, original.Id);  // Original unchanged
}

ConcurrentBag<T> is the appropriate collection here because it's optimized for scenarios where threads both add and consume items, and it handles concurrent additions without external locking. The test mutates every clone and then verifies the original is untouched, confirming true value independence.

Summary of Patterns

Pattern When to Use Key Type
Lock as parameter Caller needs to coordinate multiple operations object + lock
Thread dispatch queue Operations must run on a specific thread ConcurrentQueue<Task>
Overlap prevention Periodic actions that shouldn't stack Task null-check + Lock
ReaderWriterLockSlim Many readers, few writers ReaderWriterLockSlim
Lock (.NET 9+) General-purpose mutual exclusion Lock
Thread-safe singleton Lazy initialization with pre-configuration lock + null check
Interlocked Simple counters and flags Interlocked.Increment

The common thread across all of these patterns is making concurrency visible. Lock parameters in method signatures, thread ID checks that throw on violation, naming conventions that call out synchronization requirements. These all resist the natural tendency for threading concerns to become invisible and, consequently, broken. When concurrency is explicit in the API surface, it becomes something the team reasons about deliberately rather than something that fails silently under load.

References