CLR & Concurrency · Lesson 19

Semaphore permits are not monitor ownership

Observe nonreentrancy and a maximum, then test cross-thread release 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

SemaphoreSlim manages permits rather than monitor-style thread ownership. A waiter consumes a permit, and correct application code must release only what it successfully acquired. A maximum can detect over-release, but the semaphore does not remember which thread owns a permit or promise general FIFO admission. The portable program uses genuine asynchronous wait calls that complete immediately under its arranged state. The external desktop project demonstrates a release from a different owned thread. These examples focus on ownership semantics and nonreentrancy, extending beyond the earlier course's basic use of SemaphoreSlim as a concurrency bound.

Trace the portable program

The first wait acquires the only permit. A second zero-timeout wait cannot acquire another and returns false instead of becoming reentrant. Releasing the owned permit restores the count to one. A further release exceeds the configured maximum and throws SemaphoreFullException, which the observation records. Stdout is nested:False, permits:1, and over-release:True. The desktop experiment deliberately acquires on one thread and releases on another, which this API permits. That does not make arbitrary release calls correct; it shows why the application must track successful acquisition rather than relying on thread ownership enforcement.

Execute the actual desktop lab

The desktop project acquires one permit on one owned thread and releases it on another after a controlled signal. Unlike Monitor.Exit, that release is permitted by SemaphoreSlim's documented ownership model. The result is observed after both workers join, so permits returns to one. This is actual thread-related evidence, not a sequential model pretending to prove cross-thread behavior. It still does not establish a fair order among multiple waiters. The portable nested zero-timeout call demonstrates lack of reentrancy separately, with no need to create an indefinitely blocked second wait or rely on a timeout race.

Pitfalls and boundary limits

The one-argument and two-argument constructors express different maximum-count policies. This example supplies both initial and maximum values as one; omitting the explicit maximum can allow a larger count instead of detecting the same over-release. Even with a maximum, an incorrect caller can release someone else's currently held permit without exceeding it. That is why a successful Wait result needs a matching owner in application code. Dispose has its own lifetime requirement too: do not destroy the shared semaphore while legitimate waiters or leases are still using it. Thread safety of selected methods does not remove resource ownership obligations.

Practice one: isolate behavior

First compare the current two-argument construction with a one-argument construction in a separate experiment. Predict which extra release is rejected and inspect the resulting count instead of assuming the initial value is always the maximum. Hint: read the constructor contract, not just the first numeral. Then add a canceled acquisition on an exhausted semaphore and verify that its failure path does not release a permit it never obtained. Keep cleanup of the original successful acquisition separate so the fixture itself remains correctly owned.

Practice two: design and verify

For another exercise, wrap successful acquisition in a lease object and transfer the release responsibility to that object exactly once. Define what repeated disposal and cancellation before acquisition mean. Hint: a finally block should release only when ownership was actually established. Test a waiting second operation without assuming it is selected fairly among arbitrary competitors. If fairness is a product requirement, choose a protocol that explicitly provides it and verify that contract rather than interpreting one observed waiter order as a guarantee supplied by SemaphoreSlim.

Review the evidence

Review permit-based synchronization by naming initial count, maximum, acquisition result, release owner, shared-resource lifetime, and fairness requirement. The portable output proves nonreentrancy and one configured over-release check. The external project proves cross-thread release is allowed under a controlled ownership handoff. Neither means releases may occur arbitrarily or that waiter order is FIFO. Choose the primitive for the operation's lifetime and then implement ownership clearly; a synchronization maximum can catch some mistakes, but it cannot infer which logical operation deserved to release the resource. CurrentCount itself is an observation, not a reservation: another participant can acquire or release between a count check and a subsequent wait, so use the actual acquisition result.

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.

Semaphore permits are not monitor ownership — 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;

using var gate = new SemaphoreSlim(1, 1);
using var acquired = new ManualResetEventSlim();
bool released = false;
OwnedThreads.Run(
    () => { gate.Wait(); acquired.Set(); },
    () => { if (!acquired.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException(); gate.Release(); released = true; });
Console.WriteLine($"cross-thread-release:{released}");
Console.WriteLine($"permits:{gate.CurrentCount}");

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: cross-thread-release:True permits:1 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;

using var gate = new SemaphoreSlim(1, 1);
await gate.WaitAsync();
bool nested = await gate.WaitAsync(0);
gate.Release();
Console.WriteLine($"nested:{nested}");
Console.WriteLine($"permits:{gate.CurrentCount}");
bool rejected = false;
try { gate.Release(); } catch (SemaphoreFullException) { rejected = true; }
Console.WriteLine($"over-release:{rejected}");
Open C# practice

C# practice output

nested:False
permits:1
over-release:True
Try this
Release only after a successful acquisition. SemaphoreSlim does not track the acquiring thread and promises no general FIFO fairness.

Further reading

Interview questions for this lesson