C# gives you at least five different tools for coordinating threads, and they look interchangeable until you pick the wrong one.
The fastest way to choose is to ask what problem each primitive was built to solve.
The Problem They All Address
When multiple threads touch shared state, even count++ is really three steps (read, add, write), and two threads can interleave them and lose an update.
private static int count;
// Run from 10 threads, 100,000 times each. Expected: 1,000,000.
// Actual: usually less. Updates are lost.
count++;Each primitive below fixes a different shape of this problem.
1. lock: Simple Mutual Exclusion
Problem solved: only one thread at a time may execute a block of code that touches shared state.
private readonly object sync = new();
private int balance;
public void Deposit(int amount)
{
lock (sync)
{
balance += amount;
}
}lockis the default choice for protecting a critical section inside a single process.Also the same thread can re-enter a lock it already holds without deadlocking itself.
In .NET 9 / C# 13, prefer the dedicated
System.Threading.Locktype overobject.lock:
private readonly Lock _sync = new(); // .NET 9+2. Monitor: lock With More Controls
Problem solved: the same mutual exclusion as lock, plus timeouts and thread signaling.
Reach for Monitor directly only when you need something lock can't express.
Timeout instead of waiting forever:
if (Monitor.TryEnter(sync, TimeSpan.FromSeconds(2)))
{
try { /* critical section */ }
finally { Monitor.Exit(sync); }
}
else
{
// Couldn't get the lock. Log.
}
Waiting for a condition (Wait/Pulse):
// Consumer
lock (queue)
{
while (queue.Count == 0)
Monitor.Wait(queue); // releases the lock and sleeps until pulsed
var item = queue.Dequeue();
}
// Producer
lock (queue)
{
queue.Enqueue(item);
Monitor.Pulse(queue); // notifies a waiting thread that the state of the locked object has changed, moving that thread from the waiting to the ready queue so it can a continue execution.
}This is the classic producer/consumer coordination. In modern code, Channel<T> or BlockingCollection<T> usually replaces hand-written Wait/Pulse, but understanding it explains how those types work.
3. SemaphoreSlim: Limiting Concurrency to N
Problem solved: allow up to N threads or tasks into a section at once, and, unlike lock, work correctly with async/await.
A lock is a semaphore with a count of 1. SemaphoreSlim generalizes that to any count:
private static readonly SemaphoreSlim semaphonre = new(initialCount: 5);
public async Task<string> CallApiAsync(string url)
{
await semaphonre.WaitAsync(); // at most 5 calls
try
{
return await _httpClient.GetStringAsync(url);
}
finally
{
semaphonre.Release();
}
}
It also solves the "no await inside lock" limitation.
private readonly SemaphoreSlim asyncLock = new(1, 1);
await asyncLock.WaitAsync();
try { await DoSomethingAsync(); }
finally { asyncLock.Release(); }
Quick Decision Guide
Primitive | Solves | Async-friendly |
|---|---|---|
| Mutual exclusion for a code block | No |
| Mutual exclusion + timeout + | No |
| Limit concurrency to N, async locking | Yes |
Choosing in practice:
Protecting a few fields in synchronous code? Use
lock.Need a timeout, or thread signaling? Use
Monitor.Need to
awaitinside the protected section, or cap concurrent operations? UseSemaphoreSlim.
Common Mistakes
Locking on an instance, a type, or a string. Outside code can take the same lock. Always use a private lock object.
Awaiting inside
lock. It won't compile, and the workaround isSemaphoreSlim.Forgetting
finallywhen usingSemaphoreSlimor manualMonitorcalls, which leaks the lock.

Join the conversation! Your thoughts help the community grow.