When several threads read and write the same data at the same time, updates can be lost or seen half-finished. The lock statement makes a block of code mutually exclusive: while one thread is inside it, every other thread that reaches a lock on the same object waits its turn.
The problem: a race condition
Four tasks each add 100,000 to a shared counter. The answer should be 400,000:
Example output:
Expected 400000, got 245609
The total usually comes out short, and by a different amount each run (on a single-core machine it may occasionally be right, which is what makes these bugs hard to catch). count++ looks like one operation but is three: read count, add one, write it back. Two threads can both read 500, both compute 501 and both write 501, and one increment is gone.
The fix: lock
Wrap the read-modify-write in a lock on a shared object:
Output:
Expected 400000, got 400000
Now only one thread at a time can be inside the block, so each increment completes before the next one reads the value. The lock also guarantees visibility: a thread entering the lock sees every write made by the previous holder before it left.
The protection only works if every access to count goes through the same lock. One unlocked count++ elsewhere in the program brings the race back.
What lock compiles to
lock is shorthand for the Monitor class, with a try/finally so the lock is released even when the block throws:
lock (sync)
{
count++;
}
// is compiled roughly as:
bool taken = false;
try
{
Monitor.Enter(sync, ref taken);
count++;
}
finally
{
if (taken) Monitor.Exit(sync);
}
Monitor also offers TryEnter(obj, timeout), which gives up after a timeout instead of waiting forever, and Wait/Pulse for signaling between threads. A thread that already holds a lock can enter it again (locks are reentrant), so a locked method can call another locked method on the same object.
Choosing the lock object
The object in lock (...) is only a token that threads agree on. The rules:
- Use a private, read-only field of type
object.private readonly object sync = new object();. Private means no outside code can take the same lock;readonlymeans the token cannot be swapped out while a thread holds it. - Never
lock (this). Anyone holding a reference to your object can lock on it too, and their code then blocks yours or deadlocks with it. - Never lock on a string or
typeof(...). String literals are interned (every"orders"in the process is the same object), andTypeobjects are shared across the whole app, so unrelated code can end up contending for the same lock. - Never lock on a value type.
lock (count)on anintdoes not compile (error CS0185), and boxing it by hand creates a new object every time, so it would not lock anything. - Use one lock per set of data that must stay consistent, a static lock field for static data, an instance field for per-instance data.
Protecting multi-step operations
lock is most needed where a check and an action must happen together. A thread-safe account shows both the check-then-act pattern and a consistent snapshot of two fields:
Output:
10 left after 33 withdrawals
Without the lock, two threads could both see a balance of 40, both pass the check, and both withdraw 30, leaving the account at minus 20. The Summary method locks too, so it never reports a balance from after a withdrawal together with a withdrawals count from before it.
Dictionary<TKey, TValue> and List<T> are not thread safe either: concurrent writes can corrupt their internal arrays, not just lose updates. Guard them with a lock or use ConcurrentDictionary<TKey, TValue> and the other types in System.Collections.Concurrent.
Interlocked for single values
When the shared state is one integer or one reference and the update is a single step, the Interlocked class does it atomically without a lock:
Output:
Visits: 10000
Bytes: 5120000
Peak: 10000
Increment, Decrement, Add and Exchange map to atomic processor operations (a single instruction on x64). CompareExchange(ref location, newValue, expected) writes only if the location still holds expected, and returns what it found, which lets you build any update as a retry loop. Anything touching two variables at once still needs a lock.
Deadlocks
A deadlock happens when two threads each hold a lock the other needs:
// Thread 1 // Thread 2
lock (accountA) lock (accountB)
{ {
lock (accountB) { /* ... */ } lock (accountA) { /* ... */ }
} }
If thread 1 takes accountA while thread 2 takes accountB, each then waits forever for the other. There is no exception and no timeout; the program hangs. A transfer between two accounts that locks "from" and then "to" produces exactly this when two opposite transfers run at once.
The standard defenses:
- Lock in a fixed global order. For a transfer, lock the account with the smaller ID first, whichever direction the money moves.
- Hold locks briefly and do slow work (I/O, logging, network calls) outside them.
- Do not call unknown code while holding a lock: events, callbacks and virtual methods may take locks of their own.
- Use
Monitor.TryEnterwith a timeout where a hang would be worse than a failure.
No await inside lock: use SemaphoreSlim
await is not allowed inside a lock block (compiler error CS1996). After an await the method may continue on a different thread, and a Monitor lock must be released by the thread that took it. For async code, SemaphoreSlim with a count of 1 acts as an async-compatible lock:
Output:
5 saved, one at a time
Always pair WaitAsync with Release in a finally, or an exception leaves the gate closed for good. A SemaphoreSlim(3, 3) lets three callers in at once, which is how you cap concurrent requests to a rate-limited API.
System.Threading.Lock (.NET 9)
.NET 9 with C# 13 adds a dedicated System.Threading.Lock type. When the object in a lock statement is a Lock, the compiler uses its faster EnterScope API instead of Monitor:
private readonly Lock sync = new Lock();
public void Add(decimal amount)
{
lock (sync) // uses Lock.EnterScope(), not Monitor
{
balance += amount;
}
}
The rules for choosing the object stay the same. On earlier versions, private readonly object is the correct choice.
Common mistakes
- Locking some accesses but not all. Every read and write of the shared data must take the same lock.
lock (this),lock (typeof(X)),lock ("name"). Outside code can take the same lock.- Doing slow I/O inside a lock. Every other thread waits; keep the block short.
- Taking two locks in different orders in different places. The classic deadlock.
- Using
lockaroundawait. It does not compile; useSemaphoreSlim. - A lock per call.
lock (new object())protects nothing; the object must be shared.
Frequently Asked Questions
What does lock do in C#?
lock (obj) { ... } lets only one thread at a time run the block for a given lock object. A second thread reaching a lock on the same object waits until the first leaves the block. The lock is released even if the block throws, because lock compiles to Monitor.Enter and Monitor.Exit inside a try/finally.
What object should I lock on in C#?
A dedicated private field: private readonly object _sync = new object();. It must be a reference type, shared by every thread that touches the protected data, and not reachable from outside your class. Never lock on this, a Type (typeof(MyClass)) or a string, because other code can lock on the same object and block or deadlock you.
When should I use Interlocked instead of lock?
When the shared state is a single number or reference and the operation is one step: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange or CompareExchange. These are atomic hardware operations and are faster than a lock. For anything touching several fields or a collection, use lock.
Can I use await inside a lock in C#?
No, the compiler rejects await inside a lock block (error CS1996), because the code after the await may resume on a different thread than the one holding the lock. Use SemaphoreSlim with a count of 1 instead: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.
How do deadlocks happen with lock?
Thread 1 holds lock A and waits for lock B, while thread 2 holds lock B and waits for lock A. Neither can proceed, and the program hangs with no exception. Prevent it by always taking multiple locks in the same global order, by holding locks for as short a time as possible, and by never calling unknown code while holding a lock.