For two decades, the pattern has been the same: declare a private readonly object _lock = new(), wrap your critical section in lock(_lock), and hope nobody accidentally locks on this, a string literal, or some other shared reference. It works, but it’s a pattern held together by convention, not by the type system.

.NET 9 introduces System.Threading.Lock, a purpose-built type that the compiler recognizes and optimizes. No more locking on arbitrary objects.

The Old Way

public class ConnectionPool
{
private readonly object _lock = new();
private readonly List<Connection> _connections = [];
public Connection Acquire()
{
lock (_lock)
{
var conn = _connections.FirstOrDefault(c => !c.InUse);
if (conn is not null)
conn.InUse = true;
return conn ?? CreateNew();
}
}
}

The compiler turns that lock into Monitor.Enter / Monitor.Exit. It works, but there’s no type safety — you could accidentally pass _lock to something that locks on it elsewhere, or some junior dev writes lock(this) in a code review at 5 PM.

The New Way

using System.Threading;
public class ConnectionPool
{
private readonly Lock _lock = new();
private readonly List<Connection> _connections = [];
public Connection Acquire()
{
lock (_lock)
{
var conn = _connections.FirstOrDefault(c => !c.InUse);
if (conn is not null)
conn.InUse = true;
return conn ?? CreateNew();
}
}
}

Looks almost identical, right? The difference is under the hood. When the compiler sees lock on a System.Threading.Lock, it emits a call to Lock.EnterScope() which returns a ref struct that releases the lock on Dispose. This is more efficient than Monitor.Enter/Exit because the JIT can optimize the scope-based pattern better.

Scoped Locking

You can also use the scope explicitly when you need more control:

public void TransferAll(ConnectionPool other)
{
using (_lock.EnterScope())
{
foreach (var conn in _connections)
{
other.Add(conn);
}
_connections.Clear();
}
}

The Lock.Scope is a ref struct, so it can’t escape the method or get boxed. The lock is guaranteed to release when the scope ends, even if an exception fires.

Why Bother?

  1. Type safety. You can’t accidentally lock on a string, a type, or this. The type system prevents misuse.
  2. Performance. EnterScope() avoids the overhead of Monitor’s thread-affinity tracking in the uncontended case.
  3. Intent. Seeing Lock in a field declaration screams “synchronization primitive.” Seeing object screams nothing.

Key Takeaway

Replace private readonly object _lock = new() with private readonly Lock _lock = new(). Same lock keyword, same syntax, better codegen, and the type system finally prevents the classic locking mistakes. One find-and-replace, zero behavior changes.