The Lock Object: A Purpose-Built Synchronisation Primitive

For over two decades, C# developers have used lock (someObject) with a plain object instance as the synchronisation target. It works, but it has always been a slightly odd pattern — you create an object whose sole purpose is to be locked on, with no type system support for that intent. C# 13 and .NET 9 introduce System.Threading.Lock, a dedicated type designed specifically for the lock statement.

The Traditional Pattern

The canonical thread-safe pattern looks like this:

Example.cs
public class Cache<TKey, TValue>
{
    private readonly object _lock = new();
    private readonly Dictionary<TKey, TValue> _items = new();

    public void Add(TKey key, TValue value)
    {
        lock (_lock)
        {
            _items[key] = value;
        }
    }
}

This works, but has several drawbacks. The object allocates 24 bytes on 64-bit systems just for an object header and method table pointer. The lock statement compiles to Monitor.Enter/Monitor.Exit, which uses the object's sync block — a general-purpose mechanism not optimised specifically for mutual exclusion. And nothing in the type system prevents you from accidentally locking on the wrong thing.

The New Way

Replace object with Lock:

Example.cs
public class Cache<TKey, TValue>
{
    private readonly Lock _lock = new();
    private readonly Dictionary<TKey, TValue> _items = new();

    public void Add(TKey key, TValue value)
    {
        lock (_lock)
        {
            _items[key] = value;
        }
    }
}

The code looks nearly identical, but the behaviour underneath has changed. When the compiler sees lock used with a System.Threading.Lock instance, it generates a call to Lock.EnterScope() instead of Monitor.Enter. This returns a Lock.Scope ref struct that is disposed at the end of the block.

How It Works Underneath

The compiler lowers the lock statement differently for Lock:

Example.cs
// What you write
lock (_lock)
{
    _items[key] = value;
}

// What the compiler generates (approximately)
Lock.Scope scope = _lock.EnterScope();
try
{
    _items[key] = value;
}
finally
{
    scope.Dispose();
}

Lock.Scope is a ref struct, so it cannot be boxed or escape the stack. This provides a natural guarantee that the lock will be released.

Performance Benefits

Lock is implemented using more efficient primitives than Monitor. It avoids the sync block mechanism entirely, using a lightweight spinlock with thread ID tracking. Benchmarks from the .NET team show measurable improvements in contended scenarios, particularly when locks are held briefly — which is the common case.

The Lock object itself is also smaller than a general object since it does not need to carry the full object header infrastructure for features it will never use.

Manual Scope Management

You can also use Lock outside of a lock statement for more explicit control:

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

public void UpdateIfNeeded()
{
    if (!_lock.TryEnter(TimeSpan.FromMilliseconds(100)))
    {
        // Could not acquire within timeout
        return;
    }

    try
    {
        PerformUpdate();
    }
    finally
    {
        _lock.Exit();
    }
}

The TryEnter method with a timeout is something Monitor.TryEnter also supports, but the dedicated Lock API makes it more discoverable.

Migration

Migrating from object to Lock is straightforward — change the field type:

Example.cs
// Before
private readonly object _sync = new();

// After
private readonly Lock _sync = new();

All lock (_sync) statements in the class automatically benefit from the improved codegen. The compiler detects the Lock type and switches to the optimised path.

However, be aware that Lock does not support Monitor.Wait, Monitor.Pulse, or Monitor.PulseAll. If you use these methods for signalling, you need to keep using object or switch to a different synchronisation primitive like SemaphoreSlim or ManualResetEventSlim.

Analyser Support

A new analyser warns when you lock on a general object and suggests using Lock instead. This helps with gradual migration across a codebase. The analyser correctly ignores cases where Monitor.Wait/Pulse are used, since those are incompatible with Lock.

When to Adopt

For any new code that uses mutual exclusion via the lock statement, prefer Lock over object. For existing code, the migration is low-risk and can be done incrementally — change the field type and recompile. The only blockers are uses of Monitor.Wait/Pulse, which are relatively uncommon outside of producer-consumer patterns.