ZetCode

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.

Program.cs
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.

Program.cs
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.

Program.cs
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.

Program.cs
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.

Program.cs
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:

Source

Monitor Class - Microsoft Learn

The lock statement - Microsoft Learn

Lock Class - Microsoft Learn

In this article we have worked with the Monitor class in C#.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all C# tutorials.