CLR & Concurrency · Lesson 20

One lease owns one asynchronous release

Make repeated disposal harmless without confusing a permit with thread affinity.

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 synchronization lease can make ownership explicit: one successful acquisition creates one object responsible for releasing one permit. The custom Lease uses an atomic exchange to detach its semaphore reference on disposal, so a repeated DisposeAsync call cannot release again. This is actual application code built around SemaphoreSlim and IAsyncDisposable, not a simulated async-lock framework. It neither makes the semaphore reentrant nor grants fairness. The portable and desktop programs execute the same bounded sequence, with a pending second acquisition that makes the release transfer observable without sleeps or assumptions about thread identity.

Trace the portable program

The first lease holds the sole permit, so the second acquisition is genuinely pending and waiting prints true. Disposing the first lease twice releases only once. The pending acquisition completes and the second lease owns the permit, so inside prints zero. Disposing that lease twice similarly restores exactly one permit, and final prints one. Output is waiting:True, inside:0, and final:1. A common wrong answer puts Release in every disposal call and over-releases, or releases after an acquisition that never succeeded because it was canceled or failed.

Execute the actual desktop lab

The exact same bounded lease program runs in the external desktop project. Its second acquisition cannot finish before the first releases because the shared semaphore has one permit. The lease's private constructor prevents callers from manufacturing an owner without acquisition, and Interlocked.Exchange detaches the stored reference before release so only one disposer wins that responsibility. This is a small ownership protocol over a real synchronization primitive, not a claim of general lock-free application behavior. The semaphore itself outlives both leases because its outer using scope ends only after every pending operation and release in the example has finished.

Pitfalls and boundary limits

Idempotent disposal does not mean the protected work can be repeated safely or that reentrant acquisition succeeds. A release can also fail if some other owner destroys the semaphore prematurely; repeated disposal is not a recovery strategy for a broken shared lifetime. The current DisposeAsync has no asynchronous work because Release is synchronous, so it returns a completed ValueTask rather than inventing a delay to look asynchronous. If cancellation is added to TakeAsync, only a successful wait should construct a lease. Do not release in a catch or finally path that never acquired a permit.

Practice one: isolate behavior

First remove the duplicate DisposeAsync calls and predict the same three output lines. Then restore the duplicates and verify they remain harmless rather than increasing the available count. Hint: the observed ownership transfer happens at the atomic detachment, not at the number of times a cleanup method is invoked. Add a third deliberately pending acquisition and release each lease in a controlled order, checking only the ownership and final count you arranged. Do not use that chosen sequence as evidence of fairness among an unrelated set of waiting callers.

Practice two: design and verify

For another exercise, add a CancellationToken parameter to acquisition and test cancellation while the first lease still owns the permit. Predict no newly constructed lease and no extra release from the canceled path. Hint: keep the original lease's eventual disposal owned separately. Then test two callers attempting to dispose the same lease in a desktop variant with bounded coordination, verifying one release responsibility is detached. Keep disposal of the underlying semaphore outside all such operations so a lifetime defect cannot be misreported as a failure of the lease's repeated-disposal rule.

Review the evidence

Review an async synchronization wrapper by tracing successful acquisition, lease creation, responsibility transfer, cancellation, disposal, and final shared-resource destruction. The sample gives each step a concrete owner and checks the final count, without promising fairness or reentrancy. The external run executes the same real protocol rather than a framework imitation. A useful wrapper narrows opportunities for misuse while keeping its limits understandable. It cannot supply a complete operation transaction or repair a semaphore disposed by the wrong owner; those larger lifetime and failure contracts must remain explicit in the application that uses it. Make the lease's underlying resource lifetime part of its documented contract.

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.

One lease owns one asynchronous release — 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);
var first = await Lease.TakeAsync(gate);
Task<Lease> pending = Lease.TakeAsync(gate).AsTask();
Console.WriteLine($"waiting:{!pending.IsCompleted}");
await first.DisposeAsync(); await first.DisposeAsync();
var second = await pending;
Console.WriteLine($"inside:{gate.CurrentCount}");
await second.DisposeAsync(); await second.DisposeAsync();
Console.WriteLine($"final:{gate.CurrentCount}");
sealed class Lease : IAsyncDisposable
{
    private SemaphoreSlim? gate;
    private Lease(SemaphoreSlim gate) { this.gate = gate; }
    public static async ValueTask<Lease> TakeAsync(SemaphoreSlim gate) { await gate.WaitAsync().ConfigureAwait(false); return new Lease(gate); }
    public ValueTask DisposeAsync() { Interlocked.Exchange(ref gate, null)?.Release(); return ValueTask.CompletedTask; }
}
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: waiting:True inside:0 final: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);
var first = await Lease.TakeAsync(gate);
Task<Lease> pending = Lease.TakeAsync(gate).AsTask();
Console.WriteLine($"waiting:{!pending.IsCompleted}");
await first.DisposeAsync(); await first.DisposeAsync();
var second = await pending;
Console.WriteLine($"inside:{gate.CurrentCount}");
await second.DisposeAsync(); await second.DisposeAsync();
Console.WriteLine($"final:{gate.CurrentCount}");
sealed class Lease : IAsyncDisposable
{
    private SemaphoreSlim? gate;
    private Lease(SemaphoreSlim gate) { this.gate = gate; }
    public static async ValueTask<Lease> TakeAsync(SemaphoreSlim gate) { await gate.WaitAsync().ConfigureAwait(false); return new Lease(gate); }
    public ValueTask DisposeAsync() { Interlocked.Exchange(ref gate, null)?.Release(); return ValueTask.CompletedTask; }
}
Open C# practice

C# practice output

waiting:True
inside:0
final:1
Try this
The lease object uses one atomic ownership transfer to prevent a second release. This does not make acquisition reentrant or establish waiter fairness.

Further reading

Interview questions for this lesson