← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

.NET gives you several distinct synchronization tools, each suited to a different shape of problem:

ToolWhat it protectsCan await inside?
lock statementA short, synchronous critical sectionNo — compile error
SemaphoreSlimA critical section that includes awaited async workYes — that's exactly its purpose
InterlockedA single, simple atomic operation — an increment, a compare-and-swapN/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.

Why Does It Exist?

The Problem — Not Every Shared-State Scenario Fits a Concurrent Collection

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."

The Need

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.

The Solution — lock, SemaphoreSlim, and Interlocked

.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.

Big Picture

A CRITICAL SECTION, GUARDED
Thread A arrives first, acquires the lock, enters the critical section
Thread B arrives while A is still inside — B waits
Thread A finishes and releases the lock — even if it threw an exception
Thread B is now free to enter — its turn to be the only thread inside

How It Works

lock — what it really desugars to

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.

Why you cannot await inside a lock block

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.

SemaphoreSlim — the async-compatible alternative

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.

Interlocked — atomic operations without a full lock

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.

Simple Example

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 threads

Option 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.

Real-World Example

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.

Analogy

A Single Restroom Key vs. a Parking Garage with a Ticket Machine

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.

Under the Hood

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.

Common Confusion

1. "SemaphoreSlim(1, 1) is just an async lock" — close, but the mental model matters

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.

2. "Interlocked replaces lock entirely" — it only covers single, simple operations

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.

Common Mistakes

Mistake 1 — Locking on a public, mutable, or shared object

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 elsewhere

Correct — use a dedicated, private, non-shared object that exists purely as a lock token:

private readonly object _gate = new(); public void DoWork() { lock (_gate) { ... } }

Mistake 2 — Forgetting to Release() a SemaphoreSlim in every code path

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.

Mistake 3 — Reaching for a full lock when Interlocked would do

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.

When Should I Use It?

A simple shared counter or flag
Interlocked.Increment/CompareExchange — fast, atomic, no lock object needed.
A short, purely synchronous critical section
lock — simple, automatic release via try/finally, no await allowed inside.
A critical section that needs await inside it
SemaphoreSlim.WaitAsync()/Release() — the async-compatible option.
Capping concurrency at more than one
SemaphoreSlim with maxCount > 1 — a genuine concurrency gate, not just mutual exclusion.
Rule of thumb: Start narrow and widen only if needed. A single atomic operation → Interlocked. A short synchronous critical section → lock. A critical section that must include awaitSemaphoreSlim. 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).

Mental Model

lock = Monitor.Enter/Exit in a try/finally — synchronous, short, no await allowed.
SemaphoreSlim = the async-safe version — WaitAsync()/Release(), safe to await inside.
Interlocked = one atomic CPU instruction — for a single counter or flag, no lock object needed.

Remember:
· await inside lock is a compile error (CS1996) — because a lock's thread-identity model breaks the moment execution can resume on a different thread.
· lock always releases, even on an exception, thanks to the compiler-generated try/finally.
· SemaphoreSlim's finally is yours to write — it isn't automatic.

Key Takeaway


Check Your Understanding

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#?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.