An application can use await correctly and still lose inventory updates when two operations change the same state. Asynchrony describes how work waits and resumes; thread safety describes what happens when access overlaps. The useful question is not simply whether a collection is “thread-safe,” but whether the complete operation preserves the application's invariant.
This guide uses .NET 8 and C# 12. It uses a private object with the traditional lock statement, which remains valid in later versions. C# 13 and .NET 9 add dedicated System.Threading.Lock support, but that newer type is not required here. You will protect a bounded stock counter and distinguish an atomic collection operation from an unsafe multi-step workflow.
Define the invariant before selecting a primitive
An invariant is a condition that must remain true, such as stock never becoming negative. Reading a field, checking whether stock exists, and decrementing it are several operations. If two workers interleave them, each can observe the same available unit and both can claim it. Making the individual read or write atomic does not make the whole decision atomic.
First identify all state participating in the rule and all code that can access it. If the rule involves a quantity and an associated reservation record, they may need one coordinated boundary. Avoid scattering locks around individual fields without explaining the complete transition. A clear operation such as TryReserveOne is easier to protect and test than exposing public setters and asking every caller to coordinate them correctly.
A private lock protects one coherent boundary
A lock establishes mutual exclusion for code using the same lock object. Keep that object private and stable. Locking on publicly accessible objects, strings, or the current instance can let unrelated code participate in the synchronization and create surprising contention or deadlocks.
Every read or write that participates in the protected invariant must follow the same policy. A property getter that reads outside the lock can violate the intended consistent view. Keep the critical section short and avoid calling unknown callbacks while holding it. Multiple locks need a consistent acquisition order. The C# lock body cannot contain await; asynchronous coordination needs a suitable primitive and careful lifetime handling rather than holding a monitor across a suspension.
Interlocked handles selected single-value operations
Interlocked provides atomic operations such as increment, exchange and compare-exchange for supported types. It is a good fit for an independent counter where each increment is the whole required transition. It does not automatically protect related fields or guarantee a multi-step business rule.
Similarly, volatile is not a general replacement for mutual exclusion. Visibility and ordering concerns differ from compound atomicity. Do not mark an inventory field volatile and assume that check-then-decrement becomes indivisible. Use the simplest primitive that covers the actual operation. If synchronization code becomes difficult to explain, consider changing ownership so one component processes updates sequentially, for example through a bounded channel or queue.
Concurrent collections protect their own operations
A ConcurrentDictionary supports concurrent collection access and offers operations such as TryAdd, TryUpdate, and GetOrAdd. Use the operation that matches the requirement instead of combining ContainsKey with an indexer assignment and assuming the pair is atomic.
Factories supplied to GetOrAdd or AddOrUpdate can execute more than once under contention and run outside the dictionary's internal locks. Do not put a one-time payment, message send or other non-repeatable effect inside a factory. A stored Lazy<T> can help coordinate initialization of a chosen value, but its exception and lifetime policies still need thought. A concurrent collection also does not make the mutable objects stored inside it thread-safe. Their state needs its own ownership contract.
Test invariants without relying on fortunate timing
A stress test can expose a race, but a passing run does not prove all interleavings are safe. Review the synchronization boundary and use tests that assert the invariant after concurrent attempts. Avoid a test that passes only because each worker sleeps for an arbitrary duration. Such delays can change the race without establishing correctness.
Consider cancellation and exceptions in coordination code. A semaphore must be released only after a successful acquisition and normally in a finally block. An exception under lock releases the monitor through generated cleanup, but application state can still be partially changed if the transition itself is not designed carefully. Keep state transitions small, validate before mutation where possible, and make the ownership policy part of the class's public contract.
Official references
C# Thread Safety and Concurrent Collections Examples
Each example shows the code, the output it produces and what each part does.
Protect a reservation operation
The complete program launches many attempts against a fixed stock quantity. It counts successful reservations independently with Interlocked. Output is deterministic even though worker scheduling is not.
The check and decrement share one critical section. Which attempts succeed is scheduling-dependent, but the invariant and final count are not.
using System;
using System.Threading;
using System.Threading.Tasks;
var stock = new Stock(10);
int successes = 0;
Parallel.For(0, 100, _ =>
{
if (stock.TryReserveOne())
Interlocked.Increment(ref successes);
});
Console.WriteLine($"Reserved: {successes}");
Console.WriteLine($"Remaining: {stock.Remaining}");
public sealed class Stock
{
private readonly object gate = new();
private int remaining;
public Stock(int initial)
{
if (initial < 0)
throw new ArgumentOutOfRangeException(nameof(initial));
remaining = initial;
}
public bool TryReserveOne()
{
lock (gate)
{
if (remaining == 0)
return false;
remaining--;
return true;
}
}
public int Remaining
{
get { lock (gate) return remaining; }
}
}Reserved: 10
Remaining: 0C# Thread Safety and Concurrent Collections in Real Projects
Worked exercise: add one value without check-then-act
The task is to register a job identifier once. Use the dictionary's atomic TryAdd result to decide whether registration succeeded. The example uses sequential calls so the output is stable, but the same single operation is safe when callers overlap. Avoid a separate presence test.
using System;
using System.Collections.Concurrent;
var jobs = new ConcurrentDictionary<string, string>(
StringComparer.Ordinal);
static void Register(
ConcurrentDictionary<string, string> jobs,
string id, string status)
{
bool added = jobs.TryAdd(id, status);
Console.WriteLine(added ? "Registered" : "Already registered");
}
Register(jobs, "job-7", "Pending");
Register(jobs, "job-7", "Pending");
Console.WriteLine(jobs["job-7"]);Registered
Already registered
PendingThe return value describes this attempted transition, rather than an earlier observation that could become stale. If the real requirement includes persisting the registration or sending a notification exactly once across processes, an in-memory dictionary alone is insufficient; the durable operation needs its own transaction or idempotency design.
C# Thread Safety and Concurrent Collections FAQ
Does async code avoid shared-state races?
No. Awaited operations can overlap, continuations can run on different threads, and several requests can access one shared service. Even on one thread, logical operations can interleave across suspension points. Identify shared mutable state and protect complete transitions. Using Task.WhenAll changes coordination of task completion; it does not automatically serialize their state updates.
Is ConcurrentDictionary enough to make stored objects safe?
It protects the dictionary's supported operations, not arbitrary mutations inside each stored object. A dictionary value can still contain an unsafe counter or list. Prefer immutable values updated through a suitable atomic replacement operation, or give the value its own synchronization contract. Also review multi-key invariants, which are not automatically one atomic dictionary transaction.
Can GetOrAdd's factory run more than once?
Yes. Under contention, several callers can compute candidate values even though only one is stored. Therefore a factory should tolerate repeated execution and should not perform a non-repeatable external effect. If initialization needs coordinated ownership, consider an appropriate stored Lazy<T> and define error behavior. Do not infer exactly-once semantics merely from the method's name.
Why not lock on this?
Other code can hold a reference to the object and lock on the same instance, producing unexpected coupling or deadlock. A private dedicated gate makes the synchronization boundary controllable by the class. All participating operations must still use it consistently. Avoid exposing the gate or replacing it during the object's lifetime, because that would split access across unrelated locks.
C# Thread Safety and Concurrent Collections Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
A job identifier must be registered only if absent. Which operation fits?
C# Thread Safety and Concurrent Collections Quiz
2 questions to check what you learned.
Why is checking stock and then decrementing it outside one boundary unsafe?
- An individually atomic field read makes the entire check-and-decrement transition atomic.
- Private visibility prevents other methods on the same object from overlapping.
- Parallel.For serializes updates to shared objects without synchronization.
- Another worker can interleave between the check and update.
Which action is unsuitable inside a GetOrAdd factory without an additional design?
- Charging a customer with an assumption that the factory runs exactly once.
- Computing a deterministic label.
- Constructing a repeatable candidate immutable value.
- Returning a value already permitted by the dictionary type.