Concurrency

A Shared lock Cost the Same as a Private One at One Thread, and Not at Two

On .NET 10.0.12 a shared lock matched a per-thread lock at one thread and cost more from two up, where Monitor.LockContentionCount left zero.

A worn metal brake caliper clamped on a steel disc on a workshop bench, with two metal rods meeting it from the left, the nearer one with a small purple mark.

In short

  1. On .NET 10.0.12 (Linux arm64), a shared lock and a lock per thread differed by about 0.1 ns per acquisition at one thread, under 1.4% of the mean. From two threads upward the shared lock was slower in every run.
  2. Monitor.LockContentionCount stayed at 0 at one thread and in every uncontended arm. It left zero only in the contended arm, at two threads and above.
  3. The counter moves rarely relative to the work done. In the two runs it recorded one contention for roughly every 2,400 to 7,900 acquisitions.
  4. The size of the gap moved between two identical runs, so only its direction is reported. A lock typed as Lock lowers to EnterScope, but held in an object variable it falls back to Monitor with warning CS9216.

At one thread, a lock on an object shared with nobody and a lock on an object per thread differed by about 0.1 ns per acquisition, under 1.4% of the mean. From two threads upward the shared lock was slower in every run, and Monitor.LockContentionCount left zero at exactly those thread counts and nowhere else. The source reads the same in all of these cases: the counter is the only place the difference shows up without a benchmark.

What the two arms measured

A BenchmarkDotNet job ran a fixed 1,048,576 lock acquisitions per invocation, divided evenly among 1, 2, 4 or 8 threads. The critical section was a single increment. In the contended arm every thread locked one shared object. In the uncontended arm thread i locked its own object, and the body was identical. Each lock object was itself padded, 128 bytes either side of its Count field, so that neighbouring locks did not share a cache line.

The reported mean is wall-clock time divided by that fixed acquisition count. At more than one thread it is therefore the inverse of throughput, not the latency of one acquisition, and it falls in the uncontended arm as threads share the work.

Threads Contended mean (ns) Error (ns) StdDev (ns) Uncontended mean (ns) Error (ns) StdDev (ns)
1 7.214 0.0172 0.0161 7.117 0.0133 0.0111
2 9.488 0.0386 0.0301 3.763 0.0027 0.0025
4 13.567 0.0690 0.0612 2.092 0.0050 0.0044
8 12.563 0.2462 0.4248 2.749 0.0549 0.1326

A second run with the same command repeated the direction and not the magnitudes: the contended mean at two threads was 14.848 ns instead of 9.488 ns, and the gap there was 11.1 ns instead of 5.7 ns. The ordering of the contended arm across thread counts also changed. Only the direction is therefore reported: equal at one thread, contended slower from two. The uncontended rise at eight threads was not investigated, and those rows carry the widest standard deviations. Allocated read zero in every row.

Where the counter leaves zero

Monitor.LockContentionCount is documented as “the number of times there was contention when trying to take the monitor’s lock”. Microsoft’s metrics reference describes contention the same way: one thread already holds a lock while a second tries to acquire it. The counter was read once before and once after each level, in both arms; the uncontended reading served as a control.

At one thread the difference was 0. At two, four and eight threads it was positive in the contended arm, and the uncontended arm read 0 at every level, as it must when no two threads ever want the same object. The counter and the cost therefore changed together: the level at which the gap opened was the level at which the counter began to count.

The absolute values depend on how many invocations BenchmarkDotNet chose and are not compared between levels or runs. Divided by the number of acquisitions, the two runs recorded between 1.27E-4 and 4.21E-4 contentions per acquisition, which is one event for roughly every 2,400 to 7,900 acquisitions. A small reading is therefore not by itself evidence of a small cost.

The same behaviour appeared in a live process. A load program ran in phases (idle, one thread, four threads on separate locks, four threads on one lock, idle again) while dotnet-counters collect recorded System.Runtime. Both monitor-lock-contention-count and the meter dotnet.monitor.lock_contentions produced data on .NET 10.0.12. Both read 0 in every phase except the one with four threads on one lock, apart from one partial-second sample at the edge of the idle phase that followed. The per-second rate in that phase differed between runs, so no rate is quoted.

What the counter cannot say is how long anything waited. It counts contention events, and no wait time was measured. Whether each event is a lock being promoted from a lightweight state to a heavier one was not observed either; the measurement shows the two moving together, not the mechanism between them. A thread that waits on a contended lock is a blocked thread, which is the state where thread pool starvation actually comes from traces back to, and a rising counter is one place that state may begin.

What lock lowers into

A lock on an object becomes a call to Monitor.Enter(object, bool&) and, in a finally block, Monitor.Exit. The IL decoded from the compiled assembly on .NET 10.0.12:

Text
IL_000b: call         void System.Threading.Monitor::Enter(object, bool&)
IL_0022: call         void System.Threading.Monitor::Exit(object)

A lock on a System.Threading.Lock takes a different path. It calls EnterScope() and disposes the returned scope, with no Monitor call and no lockTaken flag:

Text
IL_0005: callvirt     instance System.Threading.Lock/Scope System.Threading.Lock::EnterScope()
IL_001b: call         instance void System.Threading.Lock/Scope::Dispose()

The difference is decided by the static type at the lock statement. A Lock held in a variable typed as object lowers to the Monitor pattern again, with the same IL as the plain object case, and the compiler says so:

Text
warning CS9216: A value of type 'System.Threading.Lock' converted to a different type will use likely unintended monitor-based locking in 'lock' statement.

That is the extent of what was measured about Lock. Its time under contention was not measured, so no claim is made that it is faster or slower than a Monitor.

What this changes

A hot-path lock that looks free in a single-threaded benchmark gives no evidence about the same code under two threads, because the two arms were equal at one thread and diverged at two. The test that separates them is the counter, and it can be watched on a running process:

Text
dotnet-counters monitor -n <app> --counters System.Runtime[monitor-lock-contention-count]

The measurement ran collect rather than monitor, with the same selection syntax. A reading that sits at 0 and then climbs marks the point at which the lock stopped being uncontended. The counter says that a lock is contended and how often, not how long the waits last.

A field meant to be a Lock but declared as object silently becomes a monitor again, and CS9216 is the only signal.

Not measured: the Lock type under contention, x64, more than eight threads, critical sections longer than one increment, Monitor.Wait and Monitor.Pulse, and the duration of any wait.


Measured on .NET 10.0.12, SDK 10.0.401, Linux arm64 (Ubuntu 24.04.5) in a Docker container on a Docker Desktop virtual machine (Apple silicon, kernel 7.0.14-linuxkit) with the network disabled, all capabilities dropped, no-new-privileges, a 512-process limit and no CPU or memory limit, with 12 CPUs visible to the container. Harness: BenchmarkDotNet 0.15.8, DefaultJob, Arm64 RyuJIT armv8.0-a, one official run and one repeat of the same command; dotnet-counters 10.0.745401 for the phased load. The table is the official run. Each result is a mean of wall-clock time over a fixed acquisition count with the BenchmarkDotNet error and standard deviation as printed in the table, which describe the iterations within one run and not the spread between runs. The IL was decoded with System.Reflection.Metadata. The Monitor.LockContentionCount page was read on Microsoft Learn on 1 October 2026 and the runtime metrics page on the same date. The measurement is synthetic and says nothing about production workloads.

Frequently asked

Questions this raises

Does an uncontended lock cost anything extra in .NET 10?

In this measurement the difference between a shared lock and a per-thread lock at one thread was about 0.1 ns per acquisition, under 1.4% of the 7.2 ns mean, and the contention counter read 0. Contention needs a second thread to ask for the lock.

Which counter shows lock contention in .NET 10?

Monitor.LockContentionCount, the legacy counter monitor-lock-contention-count and the meter dotnet.monitor.lock_contentions all produced data on .NET 10.0.12. They stayed at 0 until a second thread contended for the lock.

Is System.Threading.Lock faster than lock on an object?

Not measured here. The IL differs, since lock on a Lock calls EnterScope and Dispose with no Monitor call, but no timing of the Lock type under contention was taken, so no speed claim is made.

Does the contention counter report how long a thread waited?

No. It counts contention events. The wait itself was not timed in this measurement, so the counter cannot be converted into milliseconds of delay.

Arrow keys to move, Enter to open.