C#idiomatic C#
concurrency-and-parallelismidioms

Idioms

Concurrency and parallelism

Work on several threads at once: what to share, what to lock, and what to leave to the pool.

Concurrency is about the state two threads can reach at the same moment; parallelism is about splitting one job over several cores. These entries cover the primitives that make shared state safe, the collections that do the synchronization for you, and the loops that avoid the sharing altogether.

Make shared state immutable, so it needs no lockCA2211

A lock protects only the code that remembers to take it, and every reader pays for the bookkeeping. State that cannot change after it is published needs neither: any number of threads can read the same value at once. The cheapest shared state is a snapshot of immutable values, held in a readonly field or a record whose properties are init-only, instead of a field that every call site has to synchronize. CA2211 makes the same point for the visible case: a public static field that is neither constant nor read-only is not thread-safe, and the fix is to make it constant or read-only.

Preferred

csharp
public sealed record RetryPolicy(int Attempts, TimeSpan Delay);

private static readonly RetryPolicy Default = new(3, TimeSpan.FromSeconds(1));

Avoid

csharp
public static int Attempts = 3;
public static TimeSpan Delay = TimeSpan.FromSeconds(1);

Corpus: CA2211: Non-constant fields should not be visible · Managed threading best practices · Synchronizing data for multithreading

Lock a dedicated object, never `this`, a type, or a stringCA2002

A lock is mutual exclusion only if every thread agrees on which object is the lock, and a lock object that escapes the type breaks that agreement. this is handed to callers, so any of them can lock the same instance and deadlock against these methods. A Type instance is a single object per application domain, so lock (typeof(Order)) shares a lock with code that has never seen this class. A string literal can be the same interned instance as an unrelated literal elsewhere in the process. The target that cannot be reached from outside is a private field whose only job is to be locked. On .NET 9 and later that field is a System.Threading.Lock, which the compiler turns into an EnterScope call.

Preferred

csharp
private readonly Lock _gate = new();

public void Add(Order order)
{
    lock (_gate) { _orders.Add(order); }
}

Avoid

csharp
public void Add(Order order)
{
    lock (this) { _orders.Add(order); }
}

Corpus: CA2002: Do not lock on objects with weak identity · The lock statement · Managed threading best practices

Keep the critical section short, and call out of it

Everything inside the lock runs one thread at a time, so the section holds the reads and writes of the shared state and nothing else. A call into code that this type cannot see can block on I/O while every other thread waits, and it can take a second lock, which is how two threads that acquire locks in different orders deadlock. A callback is the same hazard with a disguise: a monitor is reentrant on the thread that owns it, so a nested lock on the same object succeeds, and the cycle surfaces as a hang somewhere else. Take what the section needs, then make the call after the lock is released.

Preferred

csharp
string payload;
lock (_gate) { payload = _queue.Dequeue(); }
Send(payload);

Avoid

csharp
lock (_gate)
{
    Send(_queue.Dequeue());
}

Corpus: The lock statement · Managed threading best practices

Count with `Interlocked`, not with a lock around `++`

counter++ is three steps: read the field, add one, write it back. Two threads that interleave those steps lose an update, which is the race the best-practices page illustrates. A lock prevents the loss and charges every thread for the exclusion; the Interlocked methods perform the same update as a single atomic instruction, and the page recommends them for simple state changes. Increment and Decrement cover a step of one, Add covers any amount, and CompareExchange covers the update that applies only while the field still holds the value the caller read.

Preferred

csharp
Interlocked.Increment(ref _activeRequests);
Interlocked.Add(ref _bytesServed, payload.Length);

Avoid

csharp
lock (_gate)
{
    _activeRequests++;
    _bytesServed += payload.Length;
}

Corpus: Managed threading best practices · Overview of synchronization primitives

Mark a polled flag `volatile`

A loop that reads the same field on every pass gives the JIT and the processor permission to keep that value in a register, so a write from another thread never reaches the loop. volatile excludes the field from that optimization and orders each access against the surrounding ones, which is what makes it usable as a flag that one thread sets and another polls. The guarantee is narrow: one field, no atomicity for ++, and no ordering for the state that travels with it. The keyword is not even allowed on long or double, because those accesses are not guaranteed to be atomic. Use Interlocked or a lock for anything larger than a flag, and Volatile.Read or Volatile.Write where the modifier cannot be written.

Preferred

csharp
private volatile bool _stopRequested;

public void Run()
{
    while (!_stopRequested) Pump();
}

Avoid

csharp
private bool _stopRequested;

public void Run()
{
    while (!_stopRequested) Pump();
}

Corpus: volatile keyword · Overview of synchronization primitives

Take `ReaderWriterLockSlim` when reads dominate writes

A monitor admits one thread at a time, including for a read that changes nothing, so a cache that is read constantly and written rarely serializes work that does not conflict. ReaderWriterLockSlim admits any number of readers and one writer, and a writer waits for the readers to drain. The type carries more state than a monitor does, so it earns its place only when reads are frequent and long enough to overlap; for a short read, a plain lock is the cheaper primitive.

Preferred

csharp
_cacheLock.EnterReadLock();
try { return _items[index]; }
finally { _cacheLock.ExitReadLock(); }

Avoid

csharp
lock (_cacheGate)
{
    return _items[index];
}

Corpus: Overview of synchronization primitives · Managed threading best practices

Throttle with `SemaphoreSlim`, not with a lock

A lock is a count of one and remembers which thread holds it. A semaphore holds a count of N and no owner, so it admits N callers at once and any thread may release it, which is what a pool of interchangeable resources needs. Semaphore wraps an operating system handle and can be named for use across processes; SemaphoreSlim is the in-process version and the one to use otherwise. Put the release in a finally: a path that throws between Wait and Release leaves the count lowered for the rest of the run.

Preferred

csharp
private readonly SemaphoreSlim _slots = new(4, 4);

_slots.Wait();
try { Render(frame); }
finally { _slots.Release(); }

Avoid

csharp
lock (_gate)
{
    Render(frame);
}

Corpus: Semaphore and SemaphoreSlim · Overview of synchronization primitives

Let the concurrent collection do the check and the change in one call

Every method of ConcurrentDictionary and ConcurrentQueue is safe to call from any thread, and that safety covers the single call. ContainsKey followed by an assignment is two calls with a window between them, and so is IsEmpty followed by TryDequeue: another thread can change the collection inside that window, so the second call acts on a decision that is already out of date. TryAdd, TryRemove, TryGetValue, and TryDequeue do the test and the change as one step and report the outcome through the return value, which is the shape a concurrent caller can act on. GetOrAdd and AddOrUpdate are the pair worth knowing better: their delegate runs outside the internal lock, so it can run more than once and only one result is stored.

Preferred

csharp
_runs.AddOrUpdate(file, 1, (_, count) => count + 1);
if (_work.TryDequeue(out var job))
{
    Run(job);
}

Avoid

csharp
_runs[file] = _runs.ContainsKey(file) ? _runs[file] + 1 : 1;
if (!_work.IsEmpty)
{
    _work.TryDequeue(out var job);
    Run(job);
}

Corpus: How to add and remove items from a ConcurrentDictionary · When to use a thread-safe collection · Thread-safe collections

Keep a plain `Dictionary` under one lock when the group must agree

Atomic calls compose into no atomicity at all: a thread can remove from the queue and update the count while a reader sees one change and not the other. When each operation is a few instructions and every thread both adds and removes, a plain Dictionary behind one lock is the cheaper shape, because the synchronization is paid once per group of changes rather than at every call, and the measurement page reaches the same conclusion for a queue under a mixed producer-consumer load. It also names the case with no writes at all: a collection that is only read needs no synchronization, and the plain type wins there outright.

Preferred

csharp
lock (_gate)
{
    _pending.Remove(id);
    _counts[id] = _counts.GetValueOrDefault(id) + 1;
}

Avoid

csharp
_pending.TryRemove(id, out _);
_counts.AddOrUpdate(id, 1, (_, n) => n + 1);

Corpus: When to use a thread-safe collection · Thread-safe collections

Spread independent CPU work with `Parallel.For` and `Parallel.ForEach`

Parallel.For and Parallel.ForEach run the body on pool threads and return when every iteration has finished. That shape suits CPU work whose elements do not depend on each other, and the loop partitions the source so several threads can work at once. An iteration that waits on I/O, or on another iteration, occupies a pool thread while it waits, and the loop can deadlock when the threads it needs for the work it is waiting on are all blocked. The loop is also free to run the iterations on one thread and in any order, so nothing in the body may rely on parallelism or on sequence.

Preferred

csharp
Parallel.For(0, rows, i =>
{
    results[i] = Transform(source[i]);
});

Avoid

csharp
Parallel.ForEach(values, value =>
{
    total += value;
});

Corpus: Data parallelism · Potential pitfalls in data and task parallelism · How to write a simple Parallel.For loop

Accumulate per thread and merge once

A loop whose iterations all write to one accumulator synchronizes on every pass. The generic overload of Parallel.For keeps a private value per thread, updates it without contention, and hands it to a final delegate once that thread has no more iterations. The final delegate runs on several threads, so the merge into the shared total is where the atomic operation belongs.

Preferred

csharp
long total = 0;
Parallel.For(0, values.Length,
    () => 0L,
    (i, state, local) => local + values[i],
    local => Interlocked.Add(ref total, local));

Avoid

csharp
long total = 0;
Parallel.For(0, values.Length, i =>
{
    total += values[i];
});

Corpus: How to write a Parallel.For loop with thread-local variables · Potential pitfalls in data and task parallelism

Ask PLINQ for order, and know what it costs

PLINQ does not preserve the order of the source unless the query asks with AsOrdered, so Take, First, and Skip mean some of the elements, not the first ones in the source. Order preservation costs bookkeeping through every partition and an ordered merge at the end, and AsUnordered turns that cost off again once the part that needed order has passed. Parallel execution also pays for partitioning and merging, so a small source or a cheap delegate runs slower than the sequential query, and PLINQ falls back to sequential mode on its own for query shapes that would not benefit.

Preferred

csharp
var top = data.AsParallel()
    .AsOrdered()
    .Take(10)
    .ToArray();

Avoid

csharp
var top = data.AsParallel()
    .Take(10)
    .ToArray();

Corpus: Order preservation in PLINQ · Understanding speedup in PLINQ · Potential pitfalls with PLINQ

Move CPU work off the UI thread

The handler of a UI event runs on the UI thread, and a computation started there holds the window frozen until it returns. Task.Run queues the work to the pool and hands back a task the handler awaits, so the UI thread returns to its message loop and sets the control after the work finishes. Waiting for the pool from the UI thread brings the freeze back, and creating a thread by hand gives the same work a startup cost the pool does not charge.

Preferred

csharp
private async void Open_Click(object sender, EventArgs e)
{
    _digest.Text = await Task.Run(() => ComputeDigest(_path));
}

Avoid

csharp
private void Open_Click(object sender, EventArgs e)
{
    _digest.Text = ComputeDigest(_path);
}

Corpus: Potential pitfalls in data and task parallelism · The managed thread pool

Never hold a lock across an `await`

lock takes a monitor that belongs to a thread, and the compiler refuses an await inside its body: the suspension would return the thread, not the lock, and the continuation would resume on a thread that never took it. When the section has to wait, the gate is a SemaphoreSlim, entered with WaitAsync and released in a finally. A semaphore holds a count rather than an owner, so the release is legal on the thread the continuation lands on.

Preferred

csharp
await _gate.WaitAsync();
try { await _store.SaveAsync(order); }
finally { _gate.Release(); }

Avoid

csharp
lock (_gate)
{
    await _store.SaveAsync(order);
}

Corpus: Semaphore and SemaphoreSlim · The lock statement