A reviewer left one comment on my PR: "swap these lock(_gate) to the new Lock type, it's faster." I'd been meaning to try System.Threading.Lock anyway, and "faster" is the kind of word that gets me to actually do a thing. So I expected to come back with a nice before/after number, paste it in the thread, and feel clever. That's not what the stopwatch said.
Quick refresher on what the type even is. Since .NET 9 there's a real System.Threading.Lock class, and in C# 13 the lock statement knows about it. When you write lock(x) and x is a Lock, the compiler stops emitting Monitor.Enter/Monitor.Exit and lowers it to x.EnterScope() instead, which returns a disposable scope. Same keyword you've typed a thousand times, different machinery underneath. The pitch is a leaner lock that doesn't lean on the object header the way monitors do.
What I measured
I wanted the simplest honest question first: single thread, no contention, how long does it take to just grab and release the lock 50 million times while bumping a counter. Uncontended acquire/release is the path that runs on every lock you've ever written, contended or not, so if there's a cheap win hiding anywhere it should show up here.
object _objectGate = new();
Lock _lockGate = new();
// A: the classic, Monitor under the hood
for (int i = 0; i < n; i++)
lock (_objectGate) { c++; }
// B: same keyword, Lock type -> compiler calls EnterScope()
for (int i = 0; i < n; i++)
lock (_lockGate) { c++; }
// C: the explicit form B lowers to
for (int i = 0; i < n; i++)
using (_lockGate.EnterScope()) { c++; }
Conditions, so you can weigh the numbers: .NET 10, Release build, 50M acquisitions per round, best-of-9 after a warmup, running in a small 2-core Linux container. That's not a lab bench and I'm not pretending it is. I care about the ratio between A, B and C on the same box in the same breath, not the absolute nanoseconds.
The numbers that didn't cooperate
First run:
[A: lock(object) ] best= 822.6 ms 16.45 ns/op
[B: lock(Lock) ] best= 882.4 ms 17.65 ns/op
[C: Lock.EnterScope()] best= 801.9 ms 16.04 ns/op
B vs A speedup: 0.93x
So the new type came out slower than the plain object. I ran it again figuring I'd caught a bad round:
[A: lock(object) ] best= 927.8 ms 18.56 ns/op
[B: lock(Lock) ] best= 802.9 ms 16.06 ns/op
B vs A speedup: 1.16x
Now Lock is ahead by 16%. Run it a third time and it's 1.12x. The honest read isn't "Lock is 0.93x" or "Lock is 1.16x" — it's that on this box, in the uncontended path, the two are a coin flip. Everything lands in the 16–18 ns/op band and the ordering swaps depending on which run I squint at. There is no free 2x here. There's barely a difference I'd stake a sentence on.
That's the part I want to be straight about, because it would've been easy to run until I got the 1.16x round and paste only that. The win I went looking for isn't in this benchmark.
So why switch, then?
Here's the turn, and it's why I still took the reviewer's advice. The reason to reach for Lock was never the raw speed of an uncontended acquire — it's that the type closes a door you didn't know was open. With lock(someObject) you can lock on anything: a string, a boxed value, a public field some other class can also see and lock on. Every one of those is a bug waiting for a bad day. With a Lock field, the type says exactly what it's for, and if you try to lock something that isn't a Lock the compiler's fine but you've clearly signaled intent. The bigger one for me: the analyzer now flags await inside a lock(Lock) scope, because you can't hold an EnterScope() across an await. The old pattern let that compile and then deadlock in production at 2am.
So the value is the stuff that doesn't show up on a stopwatch at all. Clearer ownership, a type that can't be misused as a public gate, a compiler that catches the await-inside-lock footgun. The performance is, at worst, a wash, and the design is better. That's a fine trade — I just can't sell it with the number I thought I'd have.
My honest opinion: migrate your locks to Lock as you touch the code, not in a big mechanical sweep. A dedicated Lock field is the better default for new code and I'd push back in review on a fresh lock(new object()). But if you're eyeing a repo-wide find-and-replace because someone told you it's faster, don't. You'll burn a day and a risky diff chasing a speedup that, uncontended, isn't there. And note what I did not test honestly: heavy multi-threaded contention is where Lock's design is actually supposed to pay off, and two cores in a container is the worst possible place to measure that. I'm not going to hand you a contention number I don't trust.
Here's the whole thing if you want to run it yourself — a net10.0 console app, dotnet run -c Release. Nudge Iterations and Rounds to fit your box:
using System.Diagnostics;
const int Iterations = 50_000_000;
const int Rounds = 9;
object _objectGate = new();
Lock _lockGate = new();
long counter = 0;
// warm up both paths before timing
RunObjectLock(1_000_000, ref counter);
RunTypedLock(1_000_000, ref counter);
RunTypedLockExplicit(1_000_000, ref counter);
counter = 0;
Console.WriteLine($"iterations per round: {Iterations:N0}, rounds: {Rounds}");
Console.WriteLine($"processor count: {Environment.ProcessorCount}\n");
var objectMs = BestOf(Rounds, () => RunObjectLock(Iterations, ref counter));
var typedMs = BestOf(Rounds, () => RunTypedLock(Iterations, ref counter));
var explicitMs = BestOf(Rounds, () => RunTypedLockExplicit(Iterations, ref counter));
Report("A: lock(object) ", objectMs, Iterations);
Report("B: lock(Lock) ", typedMs, Iterations);
Report("C: Lock.EnterScope()", explicitMs, Iterations);
Console.WriteLine($"\nB vs A speedup: {objectMs / typedMs:F2}x");
double RunObjectLock(int n, ref long c)
{
var sw = Stopwatch.StartNew();
for (int i = 0; i < n; i++) lock (_objectGate) { c++; }
sw.Stop();
return sw.Elapsed.TotalMilliseconds;
}
double RunTypedLock(int n, ref long c)
{
var sw = Stopwatch.StartNew();
for (int i = 0; i < n; i++) lock (_lockGate) { c++; }
sw.Stop();
return sw.Elapsed.TotalMilliseconds;
}
double RunTypedLockExplicit(int n, ref long c)
{
var sw = Stopwatch.StartNew();
for (int i = 0; i < n; i++) using (_lockGate.EnterScope()) { c++; }
sw.Stop();
return sw.Elapsed.TotalMilliseconds;
}
static double BestOf(int rounds, Func<double> run)
{
double best = double.MaxValue;
for (int r = 0; r < rounds; r++) best = Math.Min(best, run());
return best;
}
static void Report(string label, double ms, int iterations) =>
Console.WriteLine($"[{label}] best={ms,7:F1} ms {ms * 1_000_000.0 / iterations,5:F2} ns/op");
Have you swapped your locks over yet, and did you see anything on the clock, or was it a wash for you too? Curious whether a beefier box with real contention tells a different story.
— still benchmarking things nobody put on the sprint board
Top comments (0)