C# Lock
last modified October 4, 2026
C# Lock tutorial shows how to synchronize access to shared data with the
lock statement and the System.Threading.Lock type.
When several threads read and write the same variable at the same time, the
result is unpredictable. The lock statement defines a critical
section, a region of code that only one thread may execute at a time. Under the
hood the statement enters a Monitor for the given object and exits
it when the block ends, even when an exception is thrown. Starting with .NET 9,
locking on a System.Threading.Lock instance uses a dedicated and
more efficient implementation.
C# data race
The first example shows what happens when multiple threads update a shared counter without any synchronization.
using System.Threading.Tasks;
int counter = 0;
var tasks = new List<Task>();
for (int i = 0; i < 8; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 500_000; j++)
{
counter++;
}
}));
}
await Task.WhenAll(tasks);
Console.WriteLine($"Expected: {8 * 500_000}");
Console.WriteLine($"Actual: {counter}");
We start eight tasks. Each task increments the same counter
variable half a million times.
counter++;
The increment is not an atomic operation. It reads the current value into a register, adds one, and writes the value back. When two threads interleave these steps, one write overwrites the result of the other and updates are lost.
$ dotnet run Expected: 4000000 Actual: 1284753
The actual value is different on every run and is almost always smaller than the expected one. This kind of bug is called a race condition, and it must be fixed with synchronization.
C# lock statement
The lock statement takes an object and enters the lock associated
with it. Only one thread can hold that lock at a time, so the enclosed block
becomes a critical section that the threads execute one after another.
using System.Threading.Tasks;
var account = new BankAccount();
var tasks = new List<Task>();
for (int i = 0; i < 8; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 500_000; j++)
{
account.Deposit(1);
}
}));
}
await Task.WhenAll(tasks);
Console.WriteLine($"Balance: {account.Balance}");
class BankAccount
{
private readonly object _syncRoot = new();
private long _balance;
public long Balance
{
get
{
lock (_syncRoot)
{
return _balance;
}
}
}
public void Deposit(long amount)
{
lock (_syncRoot)
{
_balance += amount;
}
}
}
The BankAccount class keeps all access to the shared
_balance field inside the critical section guarded by
_syncRoot.
private readonly object _syncRoot = new();
The lock object is a private, dedicated instance that no other code can lock on.
It is marked readonly so that it cannot be replaced while other
threads hold the lock.
lock (_syncRoot)
{
_balance += amount;
}
The lock statement enters the lock before the block runs and
releases it afterwards. A thread that finds the lock already taken waits until
the owner releases it.
$ dotnet run Balance: 4000000
C# lock and Monitor
The lock statement is a language shortcut for the
Monitor class. The compiler rewrites the block into a
Monitor.Enter call, the protected code, and a
Monitor.Exit call in a finally block.
lock (_syncRoot)
{
_balance += amount;
}
The previous fragment is equivalent to the following code.
Monitor.Enter(_syncRoot);
try
{
_balance += amount;
}
finally
{
Monitor.Exit(_syncRoot);
}
Because the exit runs in a finally block, the lock is always
released, even when the protected code throws an exception. Writing the
lock statement is shorter and safer than calling
Monitor directly.
C# System.Threading.Lock
.NET 9 added the System.Threading.Lock type. It offers mutual
exclusion with a lighter implementation than locking on an arbitrary object.
When the expression passed to the lock statement is precisely of
type System.Threading.Lock, the C# 13 compiler uses pattern based
locking and emits calls to EnterScope and Exit
instead of Monitor.Enter and Monitor.Exit.
using System.Threading;
using System.Threading.Tasks;
var counter = new Counter();
var tasks = Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
{
for (int i = 0; i < 500_000; i++)
{
counter.Increment();
}
})).ToArray();
await Task.WhenAll(tasks);
Console.WriteLine($"Count: {counter.Value}");
class Counter
{
private readonly Lock _lock = new();
private int _value;
public int Value
{
get
{
lock (_lock)
{
return _value;
}
}
}
public void Increment()
{
lock (_lock)
{
_value++;
}
}
}
The only difference from the previous example is the type of the lock field,
which is now System.Threading.Lock instead of
object.
private readonly Lock _lock = new();
Because the field has the System.Threading.Lock type, the compiler
knows it can use the dedicated lock implementation.
lock (_lock)
{
_value++;
}
Each lock block is compiled into a using statement
over _lock.EnterScope(). The returned Lock.Scope is a
ref struct that releases the lock when it is disposed.
$ dotnet run Count: 4000000
The Lock type is a drop-in replacement for the dedicated object
pattern, and it also exposes members that the lock statement alone
does not reveal.
C# Lock.TryEnter
The TryEnter method tries to enter the lock and returns a Boolean
value instead of waiting indefinitely. Overloads accept a timeout as an integer
number of milliseconds or as a TimeSpan.
using System.Threading;
var gate = new Lock();
if (gate.TryEnter(TimeSpan.FromSeconds(1)))
{
try
{
Console.WriteLine("Lock acquired");
Console.WriteLine($"Held by current thread: {gate.IsHeldByCurrentThread}");
}
finally
{
gate.Exit();
}
}
else
{
Console.WriteLine("Could not acquire the lock in time");
}
We call TryEnter with a one second timeout.
if (gate.TryEnter(TimeSpan.FromSeconds(1)))
When the lock is free the method returns true immediately. When it
is taken, the call waits up to one second and then returns false.
finally
{
gate.Exit();
}
A lock entered with Enter or TryEnter must be released
with Exit. The finally block guarantees the release
even if the critical section throws.
$ dotnet run Lock acquired Held by current thread: True
C# Lock.EnterScope
The EnterScope method enters the lock and returns a
Lock.Scope value. Disposing the scope exits the lock, so it can be
used with a using statement in the same way the
lock statement is used.
using System.Threading;
var gate = new Lock();
using (gate.EnterScope())
{
Console.WriteLine("Inside the critical section");
Thread.Sleep(200);
}
Console.WriteLine("Outside the critical section");
The using statement disposes the scope at the end of the block,
which exits the lock.
using (gate.EnterScope())
{
...
}
The Microsoft documentation recommends EnterScope or the
lock keyword over a manual Enter/Exit
pair, because both release the lock in exceptional cases and can be faster.
$ dotnet run Inside the critical section Outside the critical section
C# Lock is reentrant
A lock is owned by a thread, and the same thread may enter it several times before exiting it. This property is called reentrancy and allows a locked method to call another locked method on the same thread without deadlocking itself.
using System.Threading; var gate = new Lock(); gate.Enter(); Console.WriteLine(gate.IsHeldByCurrentThread); gate.Enter(); gate.Exit(); Console.WriteLine(gate.IsHeldByCurrentThread); gate.Exit(); Console.WriteLine(gate.IsHeldByCurrentThread);
The thread enters the lock twice, so it must exit it twice.
gate.Enter(); gate.Enter(); ... gate.Exit(); gate.Exit();
The IsHeldByCurrentThread property stays true while
the thread still holds the lock, and it becomes false only after
the second Exit.
$ dotnet run True True False
C# lock and async code
A lock is owned by a thread, and the code that follows an await may
run on a different thread. The compiler therefore rejects an
await inside a lock block. For asynchronous code use
SemaphoreSlim, whose WaitAsync method can be awaited.
using System.Threading;
using System.Threading.Tasks;
var semaphore = new SemaphoreSlim(1, 1);
async Task WorkAsync(string name)
{
await semaphore.WaitAsync();
try
{
Console.WriteLine($"{name} entered");
await Task.Delay(200);
Console.WriteLine($"{name} leaving");
}
finally
{
semaphore.Release();
}
}
await Task.WhenAll(WorkAsync("task 1"), WorkAsync("task 2"));
The semaphore is created with a count of one, so it behaves like a lock, but it can be awaited.
$ dotnet run task 1 entered task 1 leaving task 2 entered task 2 leaving
C# Lock guidelines
Follow these rules when synchronizing with lock or
System.Threading.Lock:
- Lock on a private, dedicated object, or on a
Lockinstance. Never lock onthis, aTypeinstance, a string, or a value type, because other code can lock on the same instance and cause a deadlock. - Mark the lock field
readonlyso that the identity of the lock object never changes. - Keep the critical section as short as possible. Do not perform I/O, call event handlers, or await asynchronous work while holding a lock.
- Use the same lock object consistently for every access to the protected data. A write guarded by one lock and a read guarded by another lock is not synchronized.
- When several locks must be held at the same time, always acquire them in the same order in every code path to avoid deadlocks.
- Do not use
lockfor asynchronous operations; useSemaphoreSliminstead.
For new code that targets .NET 9 or later, prefer the
System.Threading.Lock type. It is more efficient than locking on a
plain object and makes the intent of the field explicit.
Source
The lock statement - Microsoft Learn
Monitor Class - Microsoft Learn
In this article we have worked with the C# lock statement and the
System.Threading.Lock type.
Author
List all C# tutorials.