Every .NET developer has written this line at least once:

Example.cs
private readonly object _lock = new();

It works. It has worked for over twenty years. But it has always been a hack. You are allocating a bare object with no purpose other than to act as a synchronisation token, and nothing in the type system tells you — or the compiler — that this particular object is meant to be locked on. Accidentally passing it to a method that calls ToString() on it will compile and run without complaint. Accidentally locking on this or a Type instance is a well-documented footgun that code reviews still catch regularly.

Starting with .NET 9 and C# 13, there is a proper answer: System.Threading.Lock. It is a dedicated, purpose-built type for mutual exclusion that the compiler recognises and optimises. If you are running .NET 9, .NET 10, or .NET 11, you should be reaching for it by default.

What was wrong with locking on object?

Locking on a bare object delegates all the work to Monitor.Enter and Monitor.Exit under the hood. The C# compiler transforms the classic lock statement into something like this:

Example.cs
object __lockObj = _lock;
bool __lockWasTaken = false;
try
{
    Monitor.Enter(__lockObj, ref __lockWasTaken);
    // Critical section
}
finally
{
    if (__lockWasTaken) Monitor.Exit(__lockObj);
}

This is correct, but it has a few drawbacks:

None of these problems are catastrophic in isolation, but they add up — especially in performance-sensitive code.

Enter System.Threading.Lock

System.Threading.Lock is a sealed class in the System.Threading namespace, available from .NET 9 onwards. It provides the same mutual-exclusion guarantees as Monitor but through a purpose-built implementation.

Services/OrderProcessor.cs
public class OrderProcessor
{
    private readonly Lock _lock = new();

    public void Process(Order order)
    {
        lock (_lock)
        {
            // Critical section — only one thread at a time
            ValidateInventory(order);
            DeductStock(order);
            RecordTransaction(order);
        }
    }
}

The syntax is identical. You still use the lock keyword. The difference is entirely in what the compiler generates.

How the compiler transforms it

When the compiler sees a lock statement where the expression is precisely of type System.Threading.Lock, it generates fundamentally different IL. Instead of calling Monitor.Enter/Monitor.Exit, it produces:

Example.cs
Lock.Scope scope = _lock.EnterScope();
try
{
    // Critical section
}
finally
{
    scope.Dispose();
}

Lock.Scope is a ref struct, which means it lives on the stack and incurs no heap allocation. The Dispose() call releases the lock. This is semantically equivalent to using (_lock.EnterScope()) { ... }, and you can use that form explicitly if you prefer:

Services/OrderProcessor.cs
public class OrderProcessor
{
    private readonly Lock _lock = new();

    public void Process(Order order)
    {
        using (_lock.EnterScope())
        {
            ValidateInventory(order);
            DeductStock(order);
            RecordTransaction(order);
        }
    }
}

Both forms are functionally identical. The lock keyword is still the clearest expression of intent for most code.

Performance: roughly 25% faster

The Lock type does not use Monitor under the hood. It implements its own locking mechanism with spin loops for the fast (uncontended) path, fair but non-FIFO release ordering to avoid lock convoys, and preemption logic that allows non-waiting threads to acquire an available lock without a context switch.

Benchmark results from community testing show a consistent improvement in contended scenarios:

Approach Mean Allocated
lock (object) via Monitor ~107 us ~1.05 KB
lock (Lock) ~76 us ~1.05 KB

That is roughly a 25% reduction in time for a contended workload of ten concurrent tasks counting to 1,000. The memory allocation is similar because the difference is in the lock mechanism itself, not in what gets allocated per operation.

// TIP

The improvement is most noticeable under contention. If your lock is rarely contested, the difference may be negligible in practice — but there is no reason not to use Lock regardless, since it is never slower.

TryEnter and timeout patterns

Lock exposes TryEnter() overloads for non-blocking and timeout-based acquisition, which map directly to common patterns:

Services/RateLimitedWriter.cs
public class RateLimitedWriter
{
    private readonly Lock _lock = new();

    public bool TryWrite(byte[] data)
    {
        // Non-blocking: return immediately if the lock is held
        if (!_lock.TryEnter())
            return false;

        try
        {
            WriteToBuffer(data);
            return true;
        }
        finally
        {
            _lock.Exit();
        }
    }

    public bool TryWriteWithTimeout(byte[] data, TimeSpan timeout)
    {
        // Wait up to the specified duration
        if (!_lock.TryEnter(timeout))
            return false;

        try
        {
            WriteToBuffer(data);
            return true;
        }
        finally
        {
            _lock.Exit();
        }
    }
}

// WARNING

When using Enter(), TryEnter(), or Exit() directly, you must ensure every Enter/TryEnter that succeeds is paired with an Exit in a finally block. The lock keyword and EnterScope() handle this automatically — prefer those unless you genuinely need the non-blocking TryEnter path.

Asserting lock ownership

Lock includes an IsHeldByCurrentThread property that returns true if the calling thread currently holds the lock. This is invaluable for debug assertions in code that assumes it is running inside a critical section:

Services/ConnectionPool.cs
public class ConnectionPool
{
    private readonly Lock _lock = new();
    private readonly List<DbConnection> _available = [];

    public DbConnection Acquire()
    {
        lock (_lock)
        {
            return TakeConnection();
        }
    }

    private DbConnection TakeConnection()
    {
        Debug.Assert(_lock.IsHeldByCurrentThread,
            "TakeConnection must be called under _lock");

        if (_available.Count == 0)
            return CreateNewConnection();

        var connection = _available[^1];
        _available.RemoveAt(_available.Count - 1);
        return connection;
    }
}

With the old object approach, there was no built-in way to check lock ownership. You either relied on conventions or added a separate bool field to track it manually.

Migrating from object

For most codebases, migration is straightforward: change the field type and you are done.

Example.cs
// Bad — the old way
private readonly object _sync = new();

// Good — the new way
private readonly Lock _sync = new();

The lock keyword works identically with both types. The compiler detects the field type and generates the appropriate code path.

If your code uses Monitor explicitly — for example, Monitor.Enter, Monitor.Exit, Monitor.TryEnter, Monitor.Wait, or Monitor.Pulse — the migration is not a simple rename. Lock does not support Wait and Pulse signalling. If you rely on those, you will need to keep Monitor or move to a different synchronisation primitive.

// NOTE

If you need to support older target frameworks alongside .NET 9+, the Backport.System.Threading.Lock NuGet package provides a polyfill from .NET Standard 2.0 upwards. It will not deliver the same performance gains on older runtimes, but it lets you write code against the new API today.

Common pitfalls

Casting defeats the purpose

If you cast a Lock to object and then lock on it, the compiler falls back to the Monitor code path. Worse, you now have two different locking mechanisms on the same instance, which will not synchronise with each other:

Example.cs
private readonly Lock _lock = new();

public void Broken()
{
    // Bad — casts to object, uses Monitor instead of Lock
    lock ((object)_lock)
    {
        // This critical section uses Monitor, not Lock
    }
}

public void Correct()
{
    // Good — compiler sees System.Threading.Lock, uses EnterScope
    lock (_lock)
    {
        // This critical section uses the Lock's own mechanism
    }
}

The compiler will issue a warning if it detects you are casting a known Lock to another type before locking. Pay attention to it.

No await inside lock

This is not new to Lock — it has always been the case with lock statements. You cannot use await inside a lock block because locks are thread-affine and await may resume on a different thread:

Example.cs
private readonly Lock _lock = new();

public async Task BrokenAsync()
{
    lock (_lock)
    {
        // Compiler error: cannot await in the body of a lock statement
        await Task.Delay(100);
    }
}

If you need asynchronous mutual exclusion, use SemaphoreSlim with WaitAsync() instead. Lock is strictly for synchronous scenarios.

No Pulse or Wait

System.Threading.Lock deliberately omits the signalling methods Monitor.Pulse, Monitor.PulseAll, and Monitor.Wait. If your code uses the producer-consumer pattern built on Monitor.Wait/Monitor.Pulse, you cannot migrate to Lock. Use System.Threading.Channels or ManualResetEventSlim instead — they are better primitives for those patterns in modern .NET anyway.

Lock is reentrant

Like Monitor, Lock supports reentrancy. A thread that already holds the lock can enter it again without deadlocking. Each Enter must be paired with an Exit:

Example.cs
private readonly Lock _lock = new();

public void Outer()
{
    lock (_lock)
    {
        // This thread already holds _lock
        Inner(); // Re-enters the same lock — this is fine
    }
}

private void Inner()
{
    lock (_lock)
    {
        // Reentrant acquisition succeeds
    }
}

This is sometimes useful but can mask design problems. If you find yourself relying on reentrancy, consider whether the code structure could be simplified so that the inner method does not need to acquire the lock independently.

Deadlocks still apply

Lock does not protect you from deadlocks. If thread A holds lock 1 and waits for lock 2, while thread B holds lock 2 and waits for lock 1, you have the same classic deadlock as with Monitor. Always acquire multiple locks in a consistent order across all code paths.

Adoption in ASP.NET Core

The ASP.NET Core team has been migrating their own codebase to use System.Threading.Lock internally. This is a strong signal that the type is ready for production use and that the performance characteristics hold up at scale. If you are writing middleware, background services, or any concurrent server-side code, Lock is the recommended default.

Summary