CLR & Concurrency · Lesson 12

Monitor conditions require a predicate loop

Separate durable state from a notification and test a real Wait/Pulse protocol externally.

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

A condition-variable protocol waits for protected state, not for a remembered notification. The portable companion intentionally demonstrates only the queue predicate and its state changes; it does not impersonate Monitor.Wait. The external project uses a real object monitor, a bounded waiting consumer, and a producer that changes the queue while holding the same lock before pulsing. The consumer rechecks the predicate in a loop after reacquiring the monitor. This separates durable state from the notification that tells a waiter it may be worth checking again, without claiming an ordering or fairness guarantee among arbitrary competing waiters.

Trace the portable program

The portable queue begins empty, so ready-before is false. Enqueuing seven makes one value available, and the guarded dequeue returns seven. The queue is empty again afterward, producing ready-before:False, value:7, and ready-after:False. These are sequential state observations only. In the desktop experiment the consumer signals that it has reached the protected waiting protocol, then releases the monitor through Wait; the producer can acquire that monitor, enqueue seven, and PulseAll. After owned threads finish, the actual desktop result is value seven with zero remaining items, rather than an assertion about which thread the scheduler prefers.

Execute the actual desktop lab

The desktop protocol uses one monitor for both the queue predicate and its state change. The consumer reaches that protected protocol before the producer proceeds, and Wait releases the same monitor so the producer can acquire it. After enqueuing, the producer pulses while still holding the lock. The consumer cannot continue protected work until it reacquires the monitor and checks the queue again. This is a real condition-wait experiment with bounded owned threads, not a stored event flag renamed Pulse. The final value and queue count are observed only after both workers finish.

Pitfalls and boundary limits

A pulse sent without a waiter is not durable state that automatically wakes a future Wait. The state predicate must be checked first, under the same synchronization discipline, so an already available item does not require a remembered notification. Use a loop because notification and lock reacquisition are not a reservation of the desired condition. Timed waits also need a deliberate deadline policy: resetting a fresh timeout after every wake is different from one overall operation budget. The bounded teaching protocol has one producer and a host join bound; it is not a complete general-purpose condition-variable queue with cancellation and fairness guarantees.

Practice one: isolate behavior

First enqueue the item before starting the consumer and verify that its predicate permits immediate removal without waiting for another pulse. Hint: durable queue state, not notification history, should decide readiness. Then use two items and a consumer that removes one, predicting one remains after the operation. Keep direct expected values and counts. Do not replace the while guard with an unconditional Wait simply because the baseline producer happened to run after the consumer in the controlled experiment.

Practice two: design and verify

For another exercise, add a genuine overall deadline using a monotonic time source and compute remaining wait time after each wake. Specify the outcome when the deadline expires with no item. Hint: preserve the predicate check when the monitor is reacquired, and keep timeout separate from producer completion. If cancellation is required, define who wakes the waiter and how it learns the cancellation state without losing protected-state ownership. Test the supported protocol with bounded workers; deliberately invalid lost-notification experiments must also have timeouts so an educational failure cannot hang the process.

Review the evidence

Review condition synchronization by identifying protected state, the monitor shared by readers and writers, the predicate loop, notification owner, and termination policy. The portable example proves only sequential predicate transitions. The external project adds actual Wait/Pulse behavior and thread ownership but does not promise which contender is scheduled first. A correct queue protocol needs state, completion, cancellation, and deadline decisions beyond a pulse call. Keeping those responsibilities explicit makes notifications useful hints to recheck a condition rather than imaginary durable messages whose history the monitor was never designed to store. Producer completion is also state: a consumer must distinguish temporarily empty from permanently finished before deciding whether another wait is meaningful.

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 conditions require a predicate loop — 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();
var queue = new Queue<int>();
using var waiting = new ManualResetEventSlim();
int value = -1;
OwnedThreads.Run(
    () => { lock (sync) { waiting.Set(); while (queue.Count == 0) if (!Monitor.Wait(sync, TimeSpan.FromSeconds(5)) && queue.Count == 0) throw new TimeoutException(); value = queue.Dequeue(); } },
    () => { if (!waiting.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException(); lock (sync) { queue.Enqueue(7); Monitor.PulseAll(sync); } });
Console.WriteLine($"value:{value}");
Console.WriteLine($"remaining:{queue.Count}");

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: value:7 remaining:0 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 queue = new Queue<int>();
Console.WriteLine($"ready-before:{queue.Count != 0}");
queue.Enqueue(7);
int value = queue.Count != 0 ? queue.Dequeue() : throw new InvalidOperationException();
Console.WriteLine($"value:{value}");
Console.WriteLine($"ready-after:{queue.Count != 0}");
Open C# practice

C# practice output

ready-before:False
value:7
ready-after:False
Try this
This companion checks the state predicate only. The desktop project uses a real monitor, bounded waits, and owned worker threads.

Further reading

Interview questions for this lesson