C# Monitor
last modified October 4, 2026
C# Monitor tutorial shows how to synchronize threads with the
Monitor class from the System.Threading namespace.
Monitor provides both mutual exclusion and thread signaling. Every
object has an associated monitor, and at most one thread may hold the monitor of
an object at a time. A thread enters the monitor with
Monitor.Enter, executes the critical section, and releases the
monitor with Monitor.Exit. The lock statement is
compiler sugar for exactly that pair wrapped in a try/finally block,
so the monitor is released even when the protected code throws an exception.
Starting with .NET 9, the System.Threading.Lock type offers a
lighter alternative to monitor based locking.
C# Monitor example
The first example protects a shared counter with Monitor.Enter and
Monitor.Exit.
using System.Threading;
using System.Threading.Tasks;
int counter = 0;
object gate = new();
var tasks = new List<Task>();
for (int i = 0; i < 8; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 500_000; j++)
{
Monitor.Enter(gate);
try
{
counter++;
}
finally
{
Monitor.Exit(gate);
}
}
}));
}
await Task.WhenAll(tasks);
Console.WriteLine($"Counter: {counter}");
Console.WriteLine($"Expected: {8 * 500_000}");
Monitor.Enter(gate);
try
{
Console.WriteLine($"IsEntered: {Monitor.IsEntered(gate)}");
}
finally
{
Monitor.Exit(gate);
}
Eight tasks increment the same counter variable half a million
times each. The increment and everything around it runs inside a critical
section guarded by the gate object.
Monitor.Enter(gate);
Monitor.Enter tries to acquire the monitor of gate and
blocks the calling thread until the monitor is free. When the call returns, the
thread owns the monitor and no other thread can enter the same section.
finally
{
Monitor.Exit(gate);
}
Monitor.Exit must be called by the thread that entered the monitor,
and it must always run, even when the protected code fails. The
finally block guarantees the release on every path out of the
critical section.
Console.WriteLine($"IsEntered: {Monitor.IsEntered(gate)}");
Monitor.IsEntered returns true when the current thread
already holds the monitor of the given object.
$ dotnet run Counter: 4000000 Expected: 4000000 IsEntered: True
The counter reaches the expected value of four million, because the critical section serializes the updates. Without synchronization the same program would lose updates and print an unpredictable number.
C# Monitor.TryEnter
TryEnter tries to enter the monitor, but instead of waiting forever
it returns when the attempt fails. The simplest overload returns a Boolean, and
other overloads accept a timeout given as a number of milliseconds or as a
TimeSpan. The overloads that end with a ref bool
lockTaken parameter return void and report the outcome
through that parameter.
if (Monitor.TryEnter(gate)) { /* ... */ }
if (Monitor.TryEnter(gate, 500)) { /* ... */ }
if (Monitor.TryEnter(gate, TimeSpan.FromSeconds(1))) { /* ... */ }
bool lockTaken = false;
Monitor.TryEnter(gate, TimeSpan.FromMilliseconds(200), ref lockTaken);
The following program holds the monitor on the main thread and lets a second thread try to enter it with a short timeout.
using System.Threading;
using System.Threading.Tasks;
object gate = new();
Monitor.Enter(gate);
try
{
var other = Task.Run(() =>
{
bool lockTaken = false;
Monitor.TryEnter(gate, TimeSpan.FromMilliseconds(200), ref lockTaken);
try
{
if (lockTaken)
{
Console.WriteLine("other thread acquired the lock");
}
else
{
Console.WriteLine("other thread gave up");
}
}
finally
{
if (lockTaken)
{
Monitor.Exit(gate);
}
}
});
other.Wait();
}
finally
{
Monitor.Exit(gate);
}
bool acquired = false;
Monitor.TryEnter(gate, 100, ref acquired);
try
{
Console.WriteLine($"main thread acquired: {acquired}");
}
finally
{
if (acquired)
{
Monitor.Exit(gate);
}
}
The main thread enters the monitor and holds it. The task then attempts to enter the same monitor with a timeout of 200 milliseconds, while the main thread is still inside its critical section.
bool lockTaken = false; Monitor.TryEnter(gate, TimeSpan.FromMilliseconds(200), ref lockTaken);
The lockTaken variable is set to true only when the
monitor was acquired. This overload is the safest form, because the
try block starts immediately after the call: the monitor may
already have been taken if the call throws an exception such as
ThreadInterruptedException.
finally
{
if (lockTaken)
{
Monitor.Exit(gate);
}
}
The monitor is released only when it was actually acquired. Calling
Monitor.Exit without holding the monitor throws
SynchronizationLockException.
$ dotnet run other thread gave up main thread acquired: True
The task waits 200 milliseconds, gives up, and reports the failure. The main thread then releases the monitor, and the last attempt succeeds immediately because the monitor is free.
C# Monitor.Wait and Pulse
Monitor.Wait and Monitor.Pulse implement thread
signaling. Wait releases the monitor and blocks the calling thread
until another thread pulses the monitor; the woken thread reacquires the monitor
before it continues. Pulse wakes a single waiting thread, while
PulseAll wakes all of them. Both methods must be called by a thread
that holds the monitor, otherwise they throw
SynchronizationLockException.
The next program connects a producer and a consumer through a shared queue. The consumer never hangs, because it waits with a timeout.
using System.Threading;
using System.Threading.Tasks;
object gate = new();
var queue = new Queue<int>();
bool finished = false;
var consumer = Task.Run(() =>
{
var received = new List<int>();
lock (gate)
{
while (!finished || queue.Count > 0)
{
if (queue.Count > 0)
{
received.Add(queue.Dequeue());
}
else
{
Monitor.Wait(gate, 500);
}
}
}
Console.WriteLine($"received: {string.Join(", ", received)}");
});
var producer = Task.Run(() =>
{
for (int i = 1; i <= 5; i++)
{
lock (gate)
{
queue.Enqueue(i);
Monitor.Pulse(gate);
}
Thread.Sleep(100);
}
lock (gate)
{
finished = true;
Monitor.PulseAll(gate);
}
});
await Task.WhenAll(consumer, producer);
The producer enqueues the numbers one to five and pulses the monitor after every
item. When all items are produced, it sets the finished flag and
calls PulseAll to wake every remaining waiter.
while (!finished || queue.Count > 0)
{
...
Monitor.Wait(gate, 500);
}
The condition is always re-checked in a while loop instead of a
single if. A waiting thread can be woken without a matching state
change, so it must verify the condition again before it continues. Because the
loop also ends when the queue is drained and the producer has finished, the
consumer terminates.
Monitor.Wait(gate, 500);
This overload releases the monitor and blocks for at most 500 milliseconds. It returns when the monitor is pulsed or when the timeout expires. The timeout makes the program robust: a lost pulse can only delay the consumer, it can never hang it forever.
lock (gate)
{
queue.Enqueue(i);
Monitor.Pulse(gate);
}
The pulse is sent while the monitor is held. The woken consumer cannot run
immediately: it must first reacquire the monitor, which the producer releases
when the lock block ends.
$ dotnet run received: 1, 2, 3, 4, 5
The consumer drains the queue in the order of production and prints all five
items. The same signaling pattern can be used with PulseAll when
more than one thread waits on the same condition.
C# lock vs Monitor
The lock statement is a language shortcut for
Monitor.Enter and Monitor.Exit. The compiler rewrites
the block into an enter call, the protected code, and an exit call in a
finally block.
lock (gate)
{
_value++;
}
The previous fragment is exactly equivalent to the following code.
Monitor.Enter(gate);
try
{
_value++;
}
finally
{
Monitor.Exit(gate);
}
The following program uses both forms on the same counter and shows that they protect the data in the same way.
using System.Threading;
using System.Threading.Tasks;
var counter = new Counter();
var tasks = new List<Task>();
for (int i = 0; i < 8; i++)
{
int id = i;
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 500_000; j++)
{
if (id % 2 == 0)
{
counter.IncrementWithLock();
}
else
{
counter.IncrementWithMonitor();
}
}
}));
}
await Task.WhenAll(tasks);
Console.WriteLine($"Count: {counter.Value}");
class Counter
{
private readonly object _gate = new();
private int _value;
public int Value => _value;
public void IncrementWithLock()
{
lock (_gate)
{
_value++;
}
}
public void IncrementWithMonitor()
{
Monitor.Enter(_gate);
try
{
_value++;
}
finally
{
Monitor.Exit(_gate);
}
}
}
Half of the tasks call the method that uses lock, the other half
call the method that uses Monitor directly. Both methods enter the
monitor of the same _gate object.
public int Value => _value;
The counter is only read after all tasks have completed, so the property does not need a critical section of its own.
$ dotnet run Count: 4000000
Pick the lock statement for ordinary critical sections, and use
Monitor directly only when you need features that
lock does not expose, such as TryEnter,
Wait, and Pulse. Keep in mind that a monitor is
thread-affine: the thread that entered it must also exit it, so a monitor can
never span an await. For asynchronous code use
SemaphoreSlim instead.
C# Monitor vs Lock
.NET 9 introduced the System.Threading.Lock type, a dedicated
mutual exclusion primitive. When the expression passed to the
lock statement is exactly of type
System.Threading.Lock, the C# 13 compiler uses pattern based
locking and emits calls to EnterScope instead of
Monitor.Enter. The dedicated implementation is faster than monitor
based locking on a plain object.
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 _gate = new();
private int _value;
public int Value
{
get
{
lock (_gate)
{
return _value;
}
}
}
public void Increment()
{
using (_gate.EnterScope())
{
_value++;
}
}
}
The _gate field is now a System.Threading.Lock
instance. The getter uses the lock statement, and the increment
method calls EnterScope explicitly.
private readonly Lock _gate = new();
Because the field has the exact type System.Threading.Lock, the
compiler knows it can use the dedicated implementation for every
lock statement on this instance.
using (_gate.EnterScope())
{
_value++;
}
EnterScope returns a Lock.Scope value, a
ref struct that releases the lock when it is disposed. It is the
manual form of the code that the lock statement generates.
$ dotnet run Count: 4000000
The two mechanisms are not interchangeable. A Lock instance must be
entered with its own API, because Monitor.Enter on a
Lock object uses monitor based locking on the object itself, which
is completely independent of the internal state of the Lock. A
thread could then hold the Lock while another thread enters the
same instance through Monitor, and no mutual exclusion would take
place. The compiler reports this mistake with warning CS9216. Also,
Lock has no equivalent of Wait and
Pulse, so signaling still requires Monitor.
C# Monitor guidelines
Follow these rules when synchronizing with Monitor:
- Lock on a private, dedicated
readonlyobject. Never lock onthis, aTypeinstance, a string, or a value type, because other code can lock on the same instance and cause a deadlock. - Keep the critical section as short as possible. Do not perform I/O, raise events, or await asynchronous work while holding a monitor.
- Enter and exit the monitor in the same thread. A monitor is thread-affine,
so it cannot be released by another thread and cannot span an
await. - Always release the monitor in a
finallyblock, or uselock, which does it for you. - When several monitors must be held at the same time, acquire them in the same order in every code path to avoid deadlocks.
- Use the
ref bool lockTakenoverload ofMonitor.TryEnterand release the monitor only when the lock was actually taken. - Call
Monitor.Wait,Pulse, andPulseAllonly while holding the monitor, and re-check the shared condition in awhileloop after returning fromWait. - Prefer
lockorSystem.Threading.Lockover manualEnterandExitunless you needTryEnter,Wait, orPulse. For asynchronous operations useSemaphoreSliminstead of a monitor.
Source
Monitor Class - Microsoft Learn
The lock statement - Microsoft Learn
In this article we have worked with the Monitor class in C#.
Author
List all C# tutorials.