I Changed object to Lock and Stopped Paying the Monitor Tax
I had a counter behind a lock that a profiler kept flagging, and I couldn't figure out why. The lock was basically never contended. One thread wrote to it almost all the time; the lock was there for the rare second writer that might show up. So I expected it to cost close to nothing. It didn't cost nothing. It cost about 24 nanoseconds every single acquire, and on a hot path that adds up to a line you can see in a flame graph.
The thing I'd stopped thinking about is what lock (someObject) actually compiles to. It's Monitor.Enter and Monitor.Exit wrapped in a try/finally, and Monitor hangs its state off the object's sync block. Even with no contention at all, you pay for that bookkeeping on every acquire and release. For years that was just the price of the lock keyword and there was nothing to do about it.
Since .NET 9 there is. There's a real type now, System.Threading.Lock, and the compiler special-cases it: if you lock on a variable whose type is Lock, it doesn't go through Monitor at all. It calls the type's own fast path instead. The part that surprised me is how little the code changes.
// before private readonly object _gate = new(); lock (_gate) { _count++; } // after private readonly Lock _gate = new(); lock (_gate) { _count++; } // same statement, different field type One word. The lock statement is byte-for-byte identical. The only thing I changed is object to Lock on the field, and the compiler routes the whole thing through the new mechanism instead.
Making it tell on itself
I didn't want to trust a hunch, so I hammered both in the dullest way possible: one thread, a tight loop, fifty million lock-increment-unlock cycles, best of seven runs with the JIT already warm. This is a container with two cores, not a lab bench, so I care about the ratio between the two numbers, not the absolute nanoseconds.
for (long i = 0; i < Ops; i++) { lock (objectGate) { c++; } // object + Monitor } for (long i = 0; i < Ops; i++) { lock (realLock) { c++; } // System.Threading.Lock } Ops per trial: 50,000,000 Trials: 7 (reporting best) Processors: 2 Server GC: False Uncontended, single thread (best of 7): lock(object) 1230 ms 24.60 ns/op lock(Lock) 1084 ms 21.68 ns/op Lock.EnterScope() 1083 ms 21.66 ns/op speedup vs object: 1.13x So about 13% off every uncontended acquire, for changing one word. I'll be honest about the size of that: 13% of 24 nanoseconds is three nanoseconds. If your lock gets taken a few hundred times a second, you will never, ever notice this, and you shouldn't go rewriting working code to chase it. Where it starts to matter is the hot inner path that grabs a lock millions of times, and in that spot three nanoseconds times millions is a real slice of a frame.
I also ran the contended case, two threads actually fighting over the same lock, and the gap narrowed to about 1.05x. That tracks. Once threads are genuinely blocking each other, the cost is dominated by the waiting, not by the per-acquire overhead, so the fast path has less room to help. The uncontended path is where the new type earns its keep, which is lucky, because uncontended is what most locks are most of the time.
The explicit form, and what it buys
If you'd rather not use the lock statement, the type gives you a scope you dispose:
using (realLock.EnterScope()) { _count++; } EnterScope() returns a ref struct called Scope. That's not a detail to skim past. A ref struct can't be boxed, can't be stored in a field, can't escape to the heap, so you physically cannot hold onto the scope past the block it lives in. The object-plus-Monitor pattern never gave you that guarantee; you could always forget an Exit or get clever and leak the hold. Here the compiler won't let you. My run clocked EnterScope() at the same speed as the statement, so there's no penalty for the clearer form.
The caveat that actually bit me
One thing caught me out, because I briefly thought the new type would save me from it and it doesn't. You still can't await while holding a Lock:
error CS1996: Cannot await in the body of a lock statement That's not a new rule and it's not something the Lock type introduced. The exact same error fires on a plain lock (object) and always has. I mention it because when I first reached for Lock, some corner of my brain assumed a shiny new synchronization primitive might finally let me hold it across an await. Nope. A Lock is for short, synchronous critical sections, same as Monitor always was. If what you actually need is mutual exclusion around an await, that's still SemaphoreSlim with WaitAsync, and reaching for Lock there just gets you a compile error pointing at the real problem.
So my rule, and this is an opinion rather than a benchmark: for new code, I default to Lock now. It says what it means, it hands me a scope I can't misuse, and it's a hair faster for free. For existing code that works, I only swap it when I'm already in that file for another reason, or when the profiler put me there. Three nanoseconds is not worth a diff on its own.
The full benchmark
Here's the whole thing. It's a top-level program targeting net10.0 (the Lock type works on .NET 9+), so drop it in a fresh console project and dotnet run -c Release. The numbers above came from a 2-core container with workstation GC, so treat them as ratios, not absolutes.
using System.Diagnostics; const long Ops = 50_000_000; // lock/unlock pairs per trial const int Trials = 7; Console.WriteLine($"Ops per trial: {Ops:N0} Trials: {Trials} (reporting best)"); Console.WriteLine($"Processors: {Environment.ProcessorCount} Server GC: {System.Runtime.GCSettings.IsServerGC}"); Console.WriteLine(); // uncontended: object + Monitor (what lock(_gate) has always meant) var objectGate = new object(); long objectBest = long.MaxValue; for (int t = 0; t < Trials; t++) { long c = 0; var sw = Stopwatch.StartNew(); for (long i = 0; i < Ops; i++) lock (objectGate) { c++; } sw.Stop(); GC.KeepAlive(c); objectBest = Math.Min(objectBest, sw.ElapsedMilliseconds); } // uncontended: the new System.Threading.Lock var realLock = new Lock(); long lockBest = long.MaxValue; for (int t = 0; t < Trials; t++) { long c = 0; var sw = Stopwatch.StartNew(); for (long i = 0; i < Ops; i++) lock (realLock) { c++; } // same keyword, different field type sw.Stop(); GC.KeepAlive(c); lockBest = Math.Min(lockBest, sw.ElapsedMilliseconds); } // the explicit EnterScope form long scopeBest = long.MaxValue; for (int t = 0; t < Trials; t++) { long c = 0; var sw = Stopwatch.StartNew(); for (long i = 0; i < Ops; i++) using (realLock.EnterScope()) { c++; } sw.Stop(); GC.KeepAlive(c); scopeBest = Math.Min(scopeBest, sw.ElapsedMilliseconds); } double ObjNs(long ms) => ms * 1_000_000.0 / Ops; Console.WriteLine("Uncontended, single thread (best of 7):"); Console.WriteLine($" lock(object) {objectBest,6} ms {ObjNs(objectBest):F2} ns/op"); Console.WriteLine($" lock(Lock) {lockBest,6} ms {ObjNs(lockBest):F2} ns/op"); Console.WriteLine($" Lock.EnterScope() {scopeBest,6} ms {ObjNs(scopeBest):F2} ns/op"); Console.WriteLine($" speedup vs object: {(double)objectBest / lockBest:F2}x"); Console.WriteLine(); // contended: several threads fighting over the same lock static long RunContended(int threads, long perThread, Action<long> body) { long best = long.MaxValue; for (int t = 0; t < 3; t++) { var tasks = new Thread[threads]; var sw = Stopwatch.StartNew(); for (int k = 0; k < threads; k++) { tasks[k] = new Thread(() => body(perThread)); tasks[k].Start(); } foreach (var th in tasks) th.Join(); sw.Stop(); best = Math.Min(best, sw.ElapsedMilliseconds); } return best; } int nthreads = Math.Max(2, Environment.ProcessorCount); long perThreadOps = 10_000_000; long sharedCounter = 0; var objGate2 = new object(); long objContended = RunContended(nthreads, perThreadOps, n => { for (long i = 0; i < n; i++) lock (objGate2) { sharedCounter++; } }); var lockGate2 = new Lock(); long lockContended = RunContended(nthreads, perThreadOps, n => { for (long i = 0; i < n; i++) lock (lockGate2) { sharedCounter++; } }); Console.WriteLine($"Contended, {nthreads} threads x {perThreadOps:N0} ops (best of 3):"); Console.WriteLine($" lock(object) {objContended,6} ms"); Console.WriteLine($" lock(Lock) {lockContended,6} ms"); Console.WriteLine($" speedup vs object: {(double)objContended / lockContended:F2}x"); Console.WriteLine($" final counter: {sharedCounter:N0}"); What's your rule for touching working lock code? Mine is "only when I'm already there," but I know people who'd rewrite the whole file on sight. Curious where you land.
— still benchmarking the stuff nobody filed a ticket for