Concurrent collections solve the "shared collection" problem. This lesson solves everything else — a critical section, in whatever shape it takes.
The last lesson gave you concurrent collections for the specific case of multiple threads sharing one collection. But real code has plenty of shared state that isn't a collection at all — a running total, a "has this already been initialized" flag, a multi-step sequence of related operations that all need to happen together without another thread interleaving in the middle. None of that is solved by ConcurrentDictionary.
This lesson covers the actual synchronization primitives .NET gives you for that broader problem: the lock statement and what it genuinely compiles down to, SemaphoreSlim for when a critical section needs to await something in the middle, and Interlocked for simple atomic operations that don't need a full lock at all. By the end, you'll know exactly which one to reach for, and why await inside a lock block is something the compiler flatly refuses to let you write.
A critical section is a piece of code that must never run on two threads at the exact same time, because it reads and modifies shared state in a way that would go wrong if interrupted or interleaved partway through. Synchronization is the general term for the techniques that enforce that — making sure only one thread is ever "inside" a critical section at once, while every other thread that wants in simply waits its turn.
.NET gives you several distinct synchronization tools, each suited to a different shape of problem:
| Tool | What it protects | Can await inside? |
|---|---|---|
lock statement | A short, synchronous critical section | No — compile error |
SemaphoreSlim | A critical section that includes awaited async work | Yes — that's exactly its purpose |
Interlocked | A single, simple atomic operation — an increment, a compare-and-swap | N/A — it's a single instruction, not a block |
All three exist to solve variations of the same underlying problem: multiple threads touching shared, mutable state, and needing some guarantee about how they're allowed to interleave while doing it.
Concurrent collections solve "multiple threads sharing one collection." But plenty of real critical sections aren't collections at all:
None of that is "just add this to a concurrent collection." What's needed is a general-purpose way to say: "while this specific block of code is running, no other thread may run this same block (or any other block guarding the same resource) at the same time."
What's needed is mutual exclusion — a mechanism that lets exactly one thread "hold" access to a critical section at a time, forces every other thread to wait its turn, and — just as importantly — releases that access reliably, even if something goes wrong partway through, so one failed operation can never permanently lock everyone else out.
.NET provides exactly that, in three shapes calibrated to three different situations: lock for the common case of a short, purely synchronous critical section; SemaphoreSlim for when that critical section needs to include an await; and Interlocked for the narrow but very common case of a single, simple atomic operation that doesn't need the overhead of a full lock at all.
lock) or asynchronously suspended (for SemaphoreSlim.WaitAsync) until A leaves.The lock statement is C# syntax sugar. Writing this:
private readonly object _gate = new();
private int _balance;
public void Deposit(int amount)
{
lock (_gate)
{
_balance += amount;
}
}is, in modern C#, essentially equivalent to the compiler generating this — a genuine, documented fact about how lock is implemented, not an approximation:
private readonly object _gate = new();
private int _balance;
public void Deposit(int amount)
{
bool lockTaken = false;
try
{
Monitor.Enter(_gate, ref lockTaken);
_balance += amount;
}
finally
{
if (lockTaken)
Monitor.Exit(_gate);
}
}Monitor.Enter blocks the calling thread until it can acquire exclusive access to _gate — the object you're locking on acts purely as a synchronization token, not something whose actual value matters. The critical part is the try/finally: because Monitor.Exit runs in a finally block, the lock is always released once the guarded code finishes — whether it completes normally or throws an exception partway through. Without that guarantee, one unhandled exception inside a locked block could leave the lock permanently held, freezing every other thread that ever tries to enter it.
Try to compile this, and the C# compiler rejects it outright:
lock (_gate)
{
await SomeAsyncOperation(); // Compiler error CS1996:
// "Cannot await in the body of a lock statement"
}This isn't an arbitrary restriction — it follows directly from what a lock is actually designed to protect. A lock is meant to guard a short, purely synchronous critical section, held by a single thread for a brief, predictable duration. But await can genuinely suspend the method and later resume its remaining code on a different thread than the one that started it. If that were allowed inside a lock, the thread that originally called Monitor.Enter might not be the same thread that eventually reaches Monitor.Exit — and Monitor's locking model requires the exact same thread to both enter and exit. Worse, the lock could end up held for the entire duration of whatever the await was waiting on — potentially a slow network call — freezing out every other thread that needs the same lock for far longer than a lock was ever meant to be held. The compiler refuses to compile this specifically to stop you from accidentally building that broken, deadlock-prone shape.
When a critical section genuinely needs to include awaited work, SemaphoreSlim is the tool built for exactly that. Its WaitAsync()/Release() pair achieves the same mutual-exclusion goal as lock, but without the thread-identity requirement that makes lock incompatible with await:
private readonly SemaphoreSlim _gate = new(initialCount: 1, maxCount: 1);
public async Task UpdateCachedDataAsync()
{
await _gate.WaitAsync();
try
{
Data fresh = await _httpClient.GetFromJsonAsync<Data>(url); // await INSIDE the critical section — fine here
_cachedData = fresh;
}
finally
{
_gate.Release();
}
}Constructed with initialCount: 1, maxCount: 1, a SemaphoreSlim behaves like a mutual-exclusion lock: only one caller can be "inside" between WaitAsync() and Release() at a time. Every other caller's await _gate.WaitAsync() suspends asynchronously — not blocking a thread — until Release() is called. Note the try/finally here is something you write yourself — unlike lock, there's no compiler-generated equivalent, so it's your responsibility to guarantee Release() always runs, even if the awaited work throws.
For the narrow but genuinely common case of a single, simple operation on a shared value — incrementing a counter, swapping a value only if it hasn't changed — System.Threading.Interlocked provides operations that are atomic at the CPU instruction level, with no lock object required at all:
private int _requestCount;
public void RecordRequest()
{
Interlocked.Increment(ref _requestCount); // atomic — no lock needed
}
public bool TrySetFlagIfUnset(ref int flag)
{
// Atomically: if flag == 0, set it to 1, and report whether the swap happened
return Interlocked.CompareExchange(ref flag, 1, 0) == 0;
}Interlocked.Increment, Interlocked.Add, Interlocked.CompareExchange, and their relatives map directly to atomic CPU instructions provided by the hardware itself. That's a real, documented characteristic — not just "faster in practice" — and it's exactly why they're genuinely faster than a full lock for these narrow cases: there's no OS-level synchronization object to acquire and release, no possibility of a waiting thread being suspended by the OS scheduler — just one hardware instruction that either completes as a single, uninterruptible unit or doesn't happen at all.
The same "increment a shared counter" problem, solved three different ways — showing why the choice actually matters:
// Option 1 — Interlocked: fastest, for this single atomic operation
private int _count1;
public void IncrementA() => Interlocked.Increment(ref _count1);
// Option 2 — lock: works, but pays for a full Monitor acquire/release
// for an operation that Interlocked could handle directly
private readonly object _gate = new();
private int _count2;
public void IncrementB()
{
lock (_gate) { _count2++; }
}
// Option 3 — plain count2++ with NO synchronization: genuinely broken
// under concurrent calls — some increments will be silently lost
private int _count3;
public void IncrementC() => _count3++; // DO NOT DO THIS from multiple threadsOption 1 and Option 2 are both correct — Option 1 is simply the more efficient tool for this specific, narrow shape of problem. Option 3 is the mistake this whole lesson exists to prevent: _count3++ is not a single atomic step, it's read-then-increment-then-write, and two threads doing that concurrently can genuinely lose an update.
A rate limiter that needs to allow only a fixed number of concurrent calls to a downstream service that itself has a strict concurrency limit — the classic real use case for SemaphoreSlim as a genuine concurrency gate, not just a one-at-a-time lock:
public class RateLimitedApiClient
{
private readonly SemaphoreSlim _gate;
private readonly HttpClient _httpClient;
public RateLimitedApiClient(HttpClient httpClient, int maxConcurrentCalls)
{
_httpClient = httpClient;
_gate = new SemaphoreSlim(initialCount: maxConcurrentCalls, maxCount: maxConcurrentCalls);
}
public async Task<string> FetchAsync(string url, CancellationToken ct)
{
await _gate.WaitAsync(ct); // waits asynchronously if maxConcurrentCalls is already in use
try
{
return await _httpClient.GetStringAsync(url, ct);
}
finally
{
_gate.Release(); // always runs, even if the call above throws
}
}
}With maxConcurrentCalls: 5, up to five calls to FetchAsync can be genuinely in flight at once; a sixth caller's await _gate.WaitAsync(ct) suspends asynchronously — no thread wasted — until one of the five in-progress calls finishes and releases its slot. This is exactly the kind of scenario a plain lock could never handle (you can't await the actual HTTP call inside one), and it's a genuinely different job than mutual exclusion: SemaphoreSlim here is capping concurrency at a chosen number, not restricting access to exactly one caller at a time.
lock is like a single-key restroom: there's one key, one person uses the restroom at a time, and they're expected to be quick — you wouldn't take that key and then go run an hour-long errand elsewhere, leaving everyone else waiting for something totally unrelated. That's exactly why await — which can genuinely take an unpredictable amount of time and might even resume as if a different person now holds the key — doesn't belong inside a lock.
SemaphoreSlim is more like a small parking garage with a fixed number of spots and a ticket machine — cars (callers) can take however long they need inside, including going off to run an errand and come back (the equivalent of awaiting something), and the garage simply tracks how many spots are currently taken. Once a spot frees up, whoever's been waiting for the next available space gets to pull in.
Interlocked isn't really a "space" at all — it's more like a single, tamper-proof mechanical counter that can only ever be turned by one hand at a time, purely by the nature of how the gear itself is built — no key, no ticket, no waiting room required for something that quick.
lock/Monitor is built on a kernel-level (or near-kernel-level) synchronization primitive: a thread that can't immediately acquire the lock is genuinely suspended by the operating system's scheduler, consuming no CPU while it waits, and is woken back up once the lock becomes available. That's real, useful behavior for short critical sections, but it comes with real overhead compared to the alternative below — acquiring and releasing an OS-level synchronization object isn't free, even when there's no actual contention.
Interlocked operations skip that machinery entirely. They compile down to specific CPU instructions — architectures generally expose something like a "compare-and-swap" or "fetch-and-add" instruction — that the processor itself guarantees will execute as a single, uninterruptible step, even with multiple cores racing to touch the same memory location at once. There's no OS scheduler involved, no thread ever gets suspended waiting, and no lock object needs to be allocated — which is exactly why Interlocked.Increment outperforms a full lock for something as simple as bumping a counter, and why that performance difference is a real, hardware-level fact rather than a rough rule of thumb.
SemaphoreSlim sits in between: for the common, low-contention case, it's able to avoid the full weight of an OS-level wait, but once a caller genuinely needs to wait for a slot, it registers a continuation and suspends asynchronously — the same non-blocking-wait mechanism as any other await in this course — rather than blocking an OS thread the way Monitor.Enter does.
Configured with a max count of 1, SemaphoreSlim does behave like a mutual-exclusion lock, and that's a legitimate, common use of it. But its more general identity is a counting primitive — it can allow any number of concurrent callers up to a configured maximum, which is a fundamentally different job (capping concurrency) than "exactly one at a time" (mutual exclusion). Reach for it either way it's needed, but don't lose sight of the fact that "async lock" is only the special case where the maximum happens to be 1.
Interlocked is not a general-purpose substitute for lock. It covers a genuinely narrow set of atomic operations — increment, add, exchange, compare-and-exchange — on a single value. The moment a critical section needs to update more than one related piece of shared state together, or run any logic beyond a single primitive operation, Interlocked can no longer help, and lock (or SemaphoreSlim, if awaiting is involved) is what you actually need.
Wrong — locking on this, on a public field, or on a boxed/interned value like a string:
lock (this) { ... } // any outside code could also lock on this same instance
lock ("shared-key") { ... } // string literals can be interned — you might be locking
// on the SAME object as unrelated code elsewhereCorrect — use a dedicated, private, non-shared object that exists purely as a lock token:
private readonly object _gate = new();
public void DoWork()
{
lock (_gate) { ... }
} Calling Release() only at the end of a method, missing an early return or an exception path that skips it entirely — permanently reducing the semaphore's available count.
Always wrap the guarded work in try/finally, with Release() in the finally block, exactly as shown in this lesson's examples — lock does this for you automatically; SemaphoreSlim requires you to write it yourself.
Wrapping a single counter increment in a lock block, paying for full mutual exclusion overhead for an operation that's already a single atomic primitive.
// Unnecessary overhead for this specific case:
lock (_gate) { _requestCount++; }
// Simpler and faster:
Interlocked.Increment(ref _requestCount); Reserve lock for critical sections that involve more than a single atomic operation — multiple related fields, conditional logic, several steps that must happen together. For a lone counter or flag, Interlocked is the right-sized tool.
Interlocked. A short synchronous critical section → lock. A critical section that must include await → SemaphoreSlim. Reaching for the heavier tool "just in case" adds overhead and complexity you don't need; reaching for the lighter tool when you actually need the heavier one produces code that either won't compile (await in a lock) or silently isn't correct (a single Interlocked call guarding a multi-step operation).
lock genuinely desugars to Monitor.Enter/Monitor.Exit wrapped in a try/finally, guaranteeing the lock is released even if an exception occurs.await inside a lock block — the compiler rejects it (CS1996), because await can resume on a different thread, breaking Monitor's same-thread enter/exit requirement and risking the lock being held far longer than intended.SemaphoreSlim's WaitAsync()/Release() is the async-compatible alternative for a critical section that must include awaited work — and, with a max count above 1, it also serves as a general concurrency gate, not just mutual exclusion.Interlocked operations map to single atomic CPU instructions — genuinely faster than a full lock for simple cases like a shared counter, but limited to single, narrow operations, not multi-step critical sections.Interlocked; a short synchronous section → lock; a section that needs await inside → SemaphoreSlim.You've seen what lock really compiles to, why await can't live inside one, and when SemaphoreSlim and Interlocked are the better fit. Let's check it's all solid.
1. What does the C# `lock` statement actually compile down to, in modern C#?
Correct: B
Why B is correct: This is a real, documented fact about how lock is implemented — the compiler generates a call to Monitor.Enter (with the ref bool overload), followed by the guarded code, with Monitor.Exit placed in a finally block so it always runs, even on an exception.
Why A is incorrect: SemaphoreSlim is a separate, distinct type from what lock/Monitor uses — lock does not go through SemaphoreSlim at all.
Why C is incorrect: Monitor does not busy-wait — a thread that can't acquire the lock is suspended by the OS scheduler, not spinning in a loop consuming CPU.
Why D is incorrect: Interlocked and Monitor are two separate synchronization mechanisms; lock specifically uses Monitor, not Interlocked.
Reinforcement: lock's try/finally-wrapped Monitor.Enter/Exit is exactly what guarantees the lock is always released, even when an exception occurs inside the guarded block.
2. Why does the C# compiler refuse to compile an await expression inside a lock block?
Correct: B
Why B is correct: This is the actual mechanical reason. Monitor's locking model assumes the same thread acquires and releases the lock. await can resume on a different thread-pool thread, which would violate that assumption — and it could also leave the lock held for however long the awaited operation takes, defeating the "short, synchronous critical section" model lock is built for.
Why A is incorrect: async methods can freely contain many other kinds of statements alongside await — this isn't a restriction on async methods in general.
Why C is incorrect: lock works fine inside instance methods — there's no such static-only restriction.
Why D is incorrect: Monitor.Enter itself is thread-safe; the issue is specifically about the enter/exit thread-identity mismatch that await could introduce, not a general thread-safety flaw in Monitor.
Reinforcement: This is a real, documented compiler restriction (CS1996) rooted in how Monitor's locking model actually works.
3. A method needs to make an awaited HTTP call inside a critical section that only one caller should be inside at a time. Which tool correctly fits this requirement?
Correct: B
Why B is correct: SemaphoreSlim.WaitAsync()/Release() exists precisely for this case — a critical section that includes awaited async work. Configured with maxCount 1, it behaves like a mutual-exclusion lock while remaining fully compatible with await inside the guarded section.
Why A is incorrect: lock cannot contain an await at all — the compiler rejects it, so it's not usable here regardless of preference.
Why C is incorrect: Interlocked handles single atomic operations on a value — it has no mechanism for guarding an arbitrary block of code, let alone one containing an awaited HTTP call.
Why D is incorrect: There is a way — SemaphoreSlim — which is exactly why it exists as the async-compatible alternative to lock.
Reinforcement: Whenever a critical section needs to await something, SemaphoreSlim is the tool, not lock.
4. Why is Interlocked.Increment genuinely faster than wrapping `_count++` in a lock, for a simple shared counter?
Correct: B
Why B is correct: This is the real, hardware-level reason. Interlocked operations correspond to atomic CPU instructions that the processor itself guarantees complete as a single, uninterruptible step — no OS-level lock object needs to be acquired or released, and no thread is ever suspended waiting.
Why A is incorrect: There's no validation being skipped — both approaches correctly increment the value; the difference is purely mechanical overhead.
Why C is incorrect: There's no such dedicated core — Interlocked instructions run on whichever core the calling thread happens to be executing on.
Why D is incorrect: lock doesn't require converting an int to an object for this purpose — the lock token (_gate) is a separate object; the int itself isn't boxed as part of using lock.
Reinforcement: The speed advantage is real and hardware-rooted: one atomic instruction versus a full OS-level synchronization object acquire/release.
5. A method needs to update two related fields together — a status enum and a timestamp — so that no other thread ever observes one updated without the other. Both updates are simple, synchronous field assignments with no awaited work involved. Which tool is the best fit?
Correct: B
Why B is correct: The requirement is that both fields update together, as a unit — exactly what Interlocked cannot provide (it only makes a single operation atomic) and exactly what lock is designed for: a short, synchronous critical section spanning multiple related statements, with no await involved.
Why A is incorrect: Calling Interlocked once per field makes each individual assignment atomic on its own, but another thread could still observe the state between the two calls — with the status updated but the timestamp not yet, or vice versa. That violates the "both together" requirement.
Why C is incorrect: SemaphoreSlim is the right tool specifically when await is involved inside the critical section; using it here adds unnecessary complexity for a purely synchronous case that lock already handles cleanly.
Why D is incorrect: Speed of the individual operations is irrelevant to the correctness problem — without synchronization, another thread can still observe the two fields in an inconsistent, half-updated state, regardless of how fast each assignment executes.
Reinforcement: Multiple related updates that must be observed together as a unit is exactly the "short synchronous critical section" shape lock is built for — not a job for Interlocked alone.
You now have the actual synchronization toolkit — lock, SemaphoreSlim, and Interlocked — and know exactly which one fits which shape of problem. With Parallel programming, Channels, Concurrent Collections, and Locking all in place, you're ready for the deeper pitfalls — race conditions and deadlocks — covered next in this Part.
dotnetmadeeasy.com — Learn C# and .NET, the right way.