Threads and Synchronization

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Where Async and Await is about not occupying a thread while waiting, this page is about using several threads at once: running CPU-bound work in parallel, and coordinating threads that share data. The two topics meet in the thread pool, which executes both the continuations of await and the work items of Task.Run.

The single most important rule: shared mutable state accessed from more than one thread must be synchronized, or not shared at all. Everything below is either a way to synchronize it, or a way to avoid sharing it.

Threads and the Thread Pool

A Thread is an OS thread, with its own stack (1 MB by default on Windows) and a real creation cost. Creating one directly is rarely the right answer in modern C#; it is justified for a long-running, dedicated loop, or when you need a specific apartment state, priority or stack size:

public static class DedicatedThread
{
    public static void Start()
    {
        var thread = new Thread(Loop)
        {
            IsBackground = true,        // does not keep the process alive
            Name = "device-poller",
            Priority = ThreadPriority.BelowNormal,
        };

        thread.Start();
        thread.Join(TimeSpan.FromSeconds(1));
    }

    private static void Loop()
    {
        Console.WriteLine(Environment.CurrentManagedThreadId);
    }
}

The thread pool maintains a managed set of worker threads and reuses them for short work items. It grows slowly under sustained load (this is why blocking pool threads is so damaging) and shrinks when idle. Task.Run is the modern way to queue work to it:

public static class PoolWork
{
    public static async Task<long> ComputeAsync(int[] data, CancellationToken cancellationToken)
    {
        // CPU-bound work moved off the caller's thread.
        return await Task.Run(() =>
        {
            long total = 0;
            foreach (int value in data)
            {
                cancellationToken.ThrowIfCancellationRequested();
                total += value * (long)value;
            }

            return total;
        }, cancellationToken).ConfigureAwait(false);
    }
}

Task.Run is for CPU-bound work. Wrapping an already asynchronous method in Task.Run adds a thread hop and buys nothing. And Task.Run belongs at the call site — a library method should not decide on its caller’s behalf that it wants a pool thread.

A genuinely long-running work item should not sit on a pool thread at all; ask for a dedicated one:

public static class LongRunning
{
    public static Task StartConsumerAsync(CancellationToken cancellationToken) =>
        Task.Factory.StartNew(
            () => { while (!cancellationToken.IsCancellationRequested) { /* consume */ } },
            cancellationToken,
            TaskCreationOptions.LongRunning,
            TaskScheduler.Default);
}

Mutual Exclusion

The lock Statement

lock (obj) { … } gives exclusive access to a block. It is re-entrant on the same thread, and it always releases on exit, including on an exception:

public sealed class Counter
{
    private readonly object _gate = new();
    private int _count;

    public void Increment()
    {
        lock (_gate)
        {
            _count++;
        }
    }

    public int Value
    {
        get
        {
            lock (_gate)
            {
                return _count;
            }
        }
    }
}

Rules for the lock object: make it private readonly, never lock on this, on a Type, or on a string — anything another piece of code could also lock on invites a deadlock you cannot see from here. Never await inside a lock (the compiler forbids it), and keep the locked region as short as possible — no I/O, no calls into code you do not control.

System.Threading.Lock (C# 13 / .NET 9)

NET 9 introduced a dedicated System.Threading.Lock type, and the C# compiler recognises it: a lock

statement over a Lock binds to its EnterScope() method instead of Monitor, which is faster and clearer about intent. Prefer it for new code on .NET 9 or later:

public sealed class ModernCounter
{
    private readonly System.Threading.Lock _gate = new();
    private int _count;

    public void Increment()
    {
        lock (_gate)            // binds to Lock.EnterScope(), not Monitor.Enter
        {
            _count++;
        }
    }

    public bool TryIncrement()
    {
        if (!_gate.TryEnter())
        {
            return false;
        }

        try
        {
            _count++;
            return true;
        }
        finally
        {
            _gate.Exit();
        }
    }
}

A subtlety worth knowing: if a Lock object is used where object is expected — assigned to an object variable, say — a lock on that variable reverts to Monitor semantics on the reference rather than using the dedicated lock, which is almost certainly not what was intended. The compiler warns about the conversion (CS9216).

Monitor Directly

lock is syntax over Monitor.Enter/Monitor.Exit in a try/finally. Using Monitor explicitly adds timeouts and the wait/pulse primitives:

public sealed class BoundedBox<T>
{
    private readonly object _gate = new();
    private T? _item;
    private bool _hasItem;

    public bool TryPutWithin(T item, TimeSpan timeout)
    {
        if (!Monitor.TryEnter(_gate, timeout))
        {
            return false;
        }

        try
        {
            while (_hasItem)
            {
                Monitor.Wait(_gate);            // release the lock and wait to be pulsed
            }

            _item = item;
            _hasItem = true;
            Monitor.PulseAll(_gate);            // wake waiters
            return true;
        }
        finally
        {
            Monitor.Exit(_gate);
        }
    }
}

Monitor.Wait must always be used in a while loop testing the condition, never an if: a pulsed thread is not guaranteed to find the condition still true by the time it reacquires the lock.

Mutex, Semaphore and SemaphoreSlim

Primitive Scope Use it for

lock / Lock / Monitor

In-process

General mutual exclusion. The default.

Mutex

Cross-process (when named)

Single-instance applications, machine-wide resources. Slow — a kernel object.

Semaphore

Cross-process (when named)

Limiting concurrency across processes.

SemaphoreSlim

In-process

Limiting concurrency, and the only one of these with an async wait.

public sealed class Throttled(HttpClient http) : IDisposable
{
    private readonly SemaphoreSlim _slots = new(initialCount: 4, maxCount: 4);

    public async Task<string> GetAsync(string url, CancellationToken cancellationToken)
    {
        // At most four requests in flight at once -- asynchronous, no thread blocked.
        await _slots.WaitAsync(cancellationToken).ConfigureAwait(false);

        try
        {
            return await http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            _slots.Release();
        }
    }

    public void Dispose() => _slots.Dispose();
}

public static class SingleInstance
{
    public static bool TryClaim(out Mutex mutex)
    {
        mutex = new Mutex(initiallyOwned: true, name: @"Global\Irurueta.Sample", out bool created);
        return created;
    }
}

SemaphoreSlim used with WaitAsync and a count of 1 is also the standard asynchronous lock, since lock cannot span an await:

public sealed class AsyncGate : IDisposable
{
    private readonly SemaphoreSlim _gate = new(1, 1);
    private int _state;

    public async Task UpdateAsync(Func<int, Task<int>> update, CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);

        try
        {
            _state = await update(_state).ConfigureAwait(false);
        }
        finally
        {
            _gate.Release();
        }
    }

    public void Dispose() => _gate.Dispose();
}

Note that a SemaphoreSlim lock is not re-entrant: a thread that already holds it and waits again deadlocks.

ReaderWriterLockSlim

When reads vastly outnumber writes and the critical section is long enough to matter, a reader-writer lock lets readers proceed concurrently:

public sealed class Registry : IDisposable
{
    private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion);
    private readonly Dictionary<string, string> _entries = new();

    public string? Get(string key)
    {
        _lock.EnterReadLock();
        try
        {
            return _entries.GetValueOrDefault(key);
        }
        finally
        {
            _lock.ExitReadLock();
        }
    }

    public void Set(string key, string value)
    {
        _lock.EnterWriteLock();
        try
        {
            _entries[key] = value;
        }
        finally
        {
            _lock.ExitWriteLock();
        }
    }

    public void Dispose() => _lock.Dispose();
}

For a plain dictionary, ConcurrentDictionary<TKey, TValue> is usually simpler and faster than hand-rolling this. Reach for ReaderWriterLockSlim when the protected state is more than one collection, or the read work itself is substantial.

Lock-Free Operations

Interlocked

Interlocked performs atomic reads, writes and read-modify-write operations without a lock:

public sealed class Stats
{
    private long _processed;
    private int _initialised;
    private string? _instance;

    public void RecordProcessed() => Interlocked.Increment(ref _processed);

    public long Processed => Interlocked.Read(ref _processed);

    public void AddBatch(int count) => Interlocked.Add(ref _processed, count);

    public bool ClaimInitialisation() =>
        Interlocked.CompareExchange(ref _initialised, 1, 0) == 0;   // exactly one winner

    public string GetOrCreate(Func<string> factory)
    {
        string created = factory();
        // Publish only if nobody else got there first; otherwise keep theirs.
        return Interlocked.CompareExchange(ref _instance, created, null) ?? created;
    }
}

CompareExchange is the building block of every lock-free algorithm: "if the value is still what I read, swap it; tell me what was actually there". The classic shape is a retry loop around it. Lock-free code is far harder to get right than it looks — prefer a lock, or a concurrent collection, unless profiling says otherwise.

volatile and Memory Ordering

On a modern CPU and with a modern JIT, reads and writes may be reordered, and a value written by one thread may sit in a register or store buffer where another thread cannot see it. volatile constrains that: a volatile read has acquire semantics, a volatile write has release semantics, and neither is elided:

public sealed class Worker
{
    private volatile bool _stopRequested;

    public void RequestStop() => _stopRequested = true;

    public void Run()
    {
        while (!_stopRequested)     // without `volatile`, this may never observe the write
        {
            // …work…
        }
    }
}

volatile is not a substitute for a lock: it makes individual reads and writes visible and ordered, but _count++ on a volatile field is still three operations and still races. Use Interlocked for that. The explicit barriers Volatile.Read/Volatile.Write and Thread.MemoryBarrier exist for cases where only some accesses need the ordering. In practice, a cancellation token is a better "stop" signal than a volatile flag.

Avoiding Sharing

Thread-Local and Async-Local State

public static class PerThreadState
{
    // One instance per thread, created on first use.
    private static readonly ThreadLocal<Random> Randoms = new(() => new Random());

    public static int Next() => Randoms.Value!.Next();

    // Flows with the async control flow, not with the thread.
    private static readonly AsyncLocal<string?> CorrelationId = new();

    public static async Task HandleAsync(string id)
    {
        CorrelationId.Value = id;
        await Task.Yield();
        Console.WriteLine(CorrelationId.Value);      // still `id`, on whatever thread resumed
    }
}

ThreadLocal<T> is keyed by thread; AsyncLocal<T> flows down the logical asynchronous call chain, which is what ambient context (correlation ids, scopes) actually needs. [ThreadStatic] on a static field is the older, lower-level equivalent of ThreadLocal<T> without lazy initialisation.

Lazy<T>

Lazy<T> computes a value once, on first access, with a thread-safety mode you choose:

public sealed class Settings
{
    private static readonly Lazy<Settings> Instance =
        new(() => Load(), LazyThreadSafetyMode.ExecutionAndPublication);

    public static Settings Current => Instance.Value;

    private static Settings Load() => new();
}

ExecutionAndPublication (the default for the parameterless overloads) guarantees the factory runs exactly once. PublicationOnly lets several threads race to produce a value and publishes the first, which suits a cheap, side-effect-free factory. LazyInitializer.EnsureInitialized is the allocation-free variant for a field.

Concurrent Collections

The System.Collections.Concurrent types are designed for concurrent access and are usually the right answer before any explicit lock:

Type Shape Notes

ConcurrentDictionary<TKey, TValue>

Key/value

GetOrAdd, AddOrUpdate, TryUpdate. The factory in GetOrAdd may run more than once.

ConcurrentQueue<T>

FIFO

Enqueue/TryDequeue.

ConcurrentStack<T>

LIFO

Push/TryPop, plus range operations.

ConcurrentBag<T>

Unordered

Optimised for the same thread adding and removing.

BlockingCollection<T>

Producer/consumer

Blocking Add/Take, bounded capacity, CompleteAdding. Synchronous.

Channel<T>

Producer/consumer

The asynchronous equivalent; prefer it in async code.

public sealed class Tally
{
    private readonly ConcurrentDictionary<string, int> _counts = new();

    public void Record(string key) => _counts.AddOrUpdate(key, 1, static (_, current) => current + 1);

    public int Get(string key) => _counts.GetValueOrDefault(key);
}

Beware the common trap: GetOrAdd’s value factory is not run under a lock, so it may execute several times for the same key under contention — only one result is published. If the factory is expensive or has side effects, store a `Lazy<T> in the dictionary instead.

Channels

Channel<T> is the modern producer/consumer pipe: asynchronous, optionally bounded, with backpressure:

using System.Threading.Channels;

public static class Pipeline
{
    public static async Task RunAsync(CancellationToken cancellationToken)
    {
        Channel<int> channel = Channel.CreateBounded<int>(new BoundedChannelOptions(capacity: 100)
        {
            FullMode = BoundedChannelFullMode.Wait,     // producers wait instead of dropping
            SingleReader = true,
            SingleWriter = false,
        });

        Task producer = ProduceAsync(channel.Writer, cancellationToken);
        Task consumer = ConsumeAsync(channel.Reader, cancellationToken);

        await Task.WhenAll(producer, consumer).ConfigureAwait(false);
    }

    private static async Task ProduceAsync(ChannelWriter<int> writer, CancellationToken cancellationToken)
    {
        try
        {
            for (int i = 0; i < 1_000; i++)
            {
                await writer.WriteAsync(i, cancellationToken).ConfigureAwait(false);
            }
        }
        finally
        {
            writer.Complete();          // signals the reader that no more items are coming
        }
    }

    private static async Task ConsumeAsync(ChannelReader<int> reader, CancellationToken cancellationToken)
    {
        await foreach (int item in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
        {
            Process(item);
        }
    }

    private static void Process(int item) => _ = item;
}
flowchart LR subgraph Producers P1["Producer 1
WriteAsync"] P2["Producer 2
WriteAsync"] end C["Channel<int>
bounded, capacity 100"] subgraph Consumer R["await foreach
reader.ReadAllAsync"] end P1 -->|"item"| C P2 -->|"item"| C C -->|"item"| R C -.->|"full: WriteAsync awaits
(backpressure)"| P1 C -.->|"empty: ReadAllAsync awaits"| R R -->|"writer.Complete() ends the loop"| D["done"]

Bounding the channel is what gives you backpressure: when consumers fall behind, producers are slowed down rather than the queue growing without limit.

The Task Parallel Library

Parallel partitions a data set across the thread pool for CPU-bound work:

public static class DataParallel
{
    public static long SumSquares(int[] data)
    {
        long total = 0;
        object gate = new();

        Parallel.For(
            0, data.Length,
            () => 0L,                                   // per-thread local state
            (i, _, local) => local + data[i] * (long)data[i],
            local => { lock (gate) { total += local; } });   // combine once per thread

        return total;
    }

    public static void EachFile(IEnumerable<string> paths) =>
        Parallel.ForEach(paths, new ParallelOptions { MaxDegreeOfParallelism = 4 }, Handle);

    // The asynchronous overload: bounded concurrency over async work, no thread blocked.
    public static Task EachUrlAsync(IEnumerable<string> urls, HttpClient http, CancellationToken cancellationToken) =>
        Parallel.ForEachAsync(
            urls,
            new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = cancellationToken },
            async (url, token) => _ = await http.GetStringAsync(url, token).ConfigureAwait(false));

    public static void Independent() =>
        Parallel.Invoke(() => StepA(), () => StepB(), () => StepC());

    private static void Handle(string path) => _ = path.Length;
    private static void StepA() { }
    private static void StepB() { }
    private static void StepC() { }
}

The per-thread-local overload of Parallel.For matters: accumulating directly into a shared variable under a lock on every iteration makes the parallel version slower than the sequential one. Accumulate locally, combine once.

Parallel.ForEachAsync (.NET 6+) is the one to use for asynchronous work with bounded concurrency — it is the throttled Task.WhenAll that people otherwise write by hand with a SemaphoreSlim.

PLINQ

PLINQ parallelises a LINQ query over the thread pool by inserting AsParallel():

public static class ParallelQueries
{
    public static int[] HeavyFilter(int[] data) =>
        data.AsParallel()
            .WithDegreeOfParallelism(4)
            .Where(static n => IsInteresting(n))
            .Select(static n => n * 2)
            .ToArray();

    public static int[] OrderPreserving(int[] data) =>
        data.AsParallel()
            .AsOrdered()                        // costs throughput; only when order matters
            .Where(static n => IsInteresting(n))
            .ToArray();

    private static bool IsInteresting(int n) => n % 3 == 0;
}

PLINQ pays off only when the per-element work is substantial and the source is large; for cheap predicates the partitioning overhead dominates and sequential LINQ wins. Results are unordered unless you ask for AsOrdered, exceptions come back wrapped in an AggregateException, and side effects inside the query must themselves be thread-safe. See LINQ.

Timers

PeriodicTimer is the async-friendly timer: no callback, no captured state, no re-entrancy:

public static class Polling
{
    public static async Task RunAsync(CancellationToken cancellationToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));

        while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
        {
            await PollAsync(cancellationToken).ConfigureAwait(false);
        }
    }

    private static Task PollAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

Because the loop awaits the work before waiting for the next tick, ticks cannot overlap — the classic bug with System.Threading.Timer, whose callback fires on schedule whether or not the previous one has finished.

Diagnosing Races and Deadlocks

Race conditions come from unsynchronized access to shared mutable state. The symptoms are non-deterministic: a count that is occasionally short, a collection that throws InvalidOperationException: Collection was modified, a field that is null only under load. Approaches that work:

  • Make the state immutable, or thread-confined, so there is nothing to race on.

  • Hold one lock for the whole logical operation — not one lock per field access. Two atomic operations in sequence are not an atomic operation.

  • Use a concurrent collection rather than a plain one behind a lock.

  • Write a stress test: run the operation from many tasks in a tight loop and assert the invariant afterwards.

Deadlocks come from two threads acquiring the same two locks in different orders, or from blocking on a task whose continuation needs the blocked thread. Approaches that work:

  • Establish a global lock ordering and never violate it.

  • Prefer Monitor.TryEnter/SemaphoreSlim.WaitAsync with a timeout so a deadlock surfaces as a failure rather than a hang.

  • Never call out to unknown code while holding a lock.

  • Never block on async code (see the sync-over-async pitfall in Async and Await).

When it happens in production, dotnet-dump plus dotnet-stack shows what every thread is waiting on:

dotnet-stack report --process-id 1234           # managed stacks of every thread
dotnet-dump collect --process-id 1234           # a full dump for post-mortem analysis
dotnet-counters monitor --process-id 1234 --counters System.Runtime
    # watch ThreadPool Thread Count and ThreadPool Queue Length:
    # a queue that grows while the count climbs means blocked pool threads

Practical Guidance

  • Prefer not sharing: immutable data, per-thread state, and message passing over a Channel<T>.

  • When you must share, prefer a concurrent collection; then a lock (System.Threading.Lock on .NET 9+); then Interlocked; and only then anything lock-free of your own.

  • Never block a thread on asynchronous work; never await inside a lock.

  • Keep critical sections short and free of calls you do not control.

  • Use Parallel.ForEachAsync for bounded-concurrency async work and Parallel.For/PLINQ for CPU-bound data parallelism — and measure, because both can be slower than the sequential version.

  • Thread the CancellationToken through everything.

See Also