# 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.

- Published: 2026-10-25
- Tags: runtime, concurrency, diagnostics
- Source: https://csharpmind.com/blog/lock-monitor-and-the-cost-of-contention-nobody-measures/
- Language: en-GB
- Author: Callum Reeve

---

## TL;DR

- 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.
- 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.
- 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.
- 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](/blog/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

### 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.

