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;
    }
}
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

lock

Mutual exclusion for a code block

No

Monitor

Mutual exclusion + timeout + Wait/Pulse

No

SemaphoreSlim

Limit concurrency to N, async locking

Yes

Choosing in practice:

Common Mistakes