ZetCode

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.

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

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

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 _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.

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

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

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

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

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

Lock Class - Microsoft Learn

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

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.