CLR & Concurrency · Lesson 11

Monitor locks are reentrant and thread-owned

Observe same-thread nesting, then test wrong-thread release only in the desktop lab.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

The runtime contract

An object monitor has thread ownership and permits reentrant entry by the same thread. Nested lock statements over the same object therefore work when entered and exited by that owner, but another thread cannot legally release the owner's monitor. The portable program exercises genuine same-thread nesting and Monitor.IsEntered without creating OS threads. The external desktop project adds a bounded wrong-thread-release experiment using owned threads. These are monitor semantics for an object-typed lock; they should not be generalized blindly to every synchronization API or every compiler lowering for newer dedicated lock types.

Trace the portable program

Before entering the object lock, Monitor.IsEntered is false. Inside two nested entries on the same thread it is true. Leaving both scopes balances the entries and makes the final observation false. Stdout is before:False, nested:True, and after:False. The external lab separately verifies that releasing the monitor from another owned thread throws SynchronizationLockException, then checks the original thread has exited normally. The browser companion does not establish contention or fairness. A common wrong answer confuses reentrancy by an existing owner with permission for an unrelated thread to enter or release the critical section.

Execute the actual desktop lab

The desktop experiment holds the monitor on the main thread while a separate owned thread attempts Exit. That worker does not attempt to acquire the lock and therefore does not need the main thread to release it before the test can finish; it observes the wrong-owner exception instead. The main thread then leaves its own lock scopes normally. All workers are joined with bounded waits, and errors are propagated to the host. This is actual thread-affinity evidence for the object monitor, separate from the portable same-thread observations and separate from an assumption that a generic synchronization interface enforces the same ownership rule.

Pitfalls and boundary limits

Reentrant entry still needs balanced exit ownership. Acquiring on an object and releasing another object cannot work, and using publicly shared lock objects can expose unexpected contention or lock-order cycles. C# also rejects await directly inside a lock statement body; manually using Monitor around an await does not make crossing thread ownership safe. New dedicated lock types can have their own compiler-recognized lowering, so be explicit about the static type and mechanism under discussion. This sample uses object and Monitor deliberately rather than claiming every source occurrence of lock is a call to precisely the same runtime API.

Practice one: isolate behavior

First add another nested lock over the same private object and predict that IsEntered remains true until every matching scope has exited. Hint: distinguish recursion depth from ownership by another thread. Then query IsEntered on a different untouched object while inside the first lock and predict false for that other object. Keep these metadata-free state observations separate from the wrong-thread desktop experiment. A result saying the current thread owns one monitor is not evidence that it owns every object used somewhere in the operation.

Practice two: design and verify

For another exercise, design an asynchronous critical operation that can suspend and identify why a thread-owned monitor is the wrong lifetime boundary. Use a permit or lease-based abstraction with an explicit successful-acquisition rule instead of manually entering and exiting across await. Hint: account for cancellation before acquisition and repeated cleanup, not only the happy path. In a separate desktop test, retain the actual acquiring and releasing thread relationship and verify the intended synchronization primitive's documented contract. Do not force a scheduler-dependent thread switch and then treat one passing run as proof that monitor ownership may migrate.

Review the evidence

Review locking by naming the synchronization object, current owner, entry/exit balance, scope, and other locks or callbacks involved. The portable output proves reentrant ownership on one flow, while the external project proves a wrong-thread release is rejected. Neither establishes fairness or the safety of exposing a lock to arbitrary callers. A useful lock design keeps the protected invariant and ownership path small enough to inspect. Choose a different primitive when the operation's lifetime can cross asynchronous suspension, and avoid treating familiar syntax as a substitute for the mechanism's real thread and scope rules.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Monitor locks are reentrant and thread-owned — desktop runtime lab

Set up the SDK project

  1. Save both files at their exact paths in a fresh owned directory.
  2. Use a .NET 10 SDK, then run dotnet run --project RuntimeLab.csproj. This project intentionally uses C# 13 syntax.
  3. Workers and waits are bounded; a timeout or a violated invariant fails the process. No benchmark or NativeAOT publication is implied by an ordinary run.

Download complete SDK project ↗

Project files

Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var sync = new object();
bool rejected = false;
lock (sync)
{
    lock (sync) { if (!Monitor.IsEntered(sync)) throw new InvalidOperationException(); }
    OwnedThreads.Run(() => { try { Monitor.Exit(sync); } catch (SynchronizationLockException) { rejected = true; } });
}
Console.WriteLine($"wrong-owner:{rejected}");
Console.WriteLine($"after:{Monitor.IsEntered(sync)}");

public static class OwnedThreads
{
    public static void Run(params Action[] actions)
    {
        var errors = new ConcurrentQueue<Exception>();
        var threads = actions.Select(action => new Thread(() => { try { action(); } catch (Exception error) { errors.Enqueue(error); } }) { IsBackground = true }).ToArray();
        foreach (var thread in threads) thread.Start();
        foreach (var thread in threads) if (!thread.Join(TimeSpan.FromSeconds(8))) throw new TimeoutException("Owned worker did not finish");
        if (!errors.IsEmpty) throw new AggregateException(errors);
    }
}
RuntimeLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>13.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PublishAot>false</PublishAot>
  </PropertyGroup>
</Project>

Expected result

Exact behavioral stdout on the stated desktop lab: wrong-owner:True after:False Variable diagnostics, where emitted, go to stderr and must not be treated as portable timing or allocation guarantees.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var sync = new object();
Console.WriteLine($"before:{Monitor.IsEntered(sync)}");
lock (sync) { lock (sync) { Console.WriteLine($"nested:{Monitor.IsEntered(sync)}"); } }
Console.WriteLine($"after:{Monitor.IsEntered(sync)}");
Open C# practice

C# practice output

before:False
nested:True
after:False
Try this
The same thread can enter again and must balance exits. The browser sample does not create OS threads or establish cross-thread contention.

Further reading

Interview questions for this lesson