CLR & Concurrency · Lesson 13

A global lock order prevents a specific cycle

Order the same resources consistently instead of relying on request direction.

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

When an operation needs several monitors, a consistent global resource order can eliminate the specific cycle caused by acquiring the same resources in opposite directions. Transfer chooses the lower account identifier first regardless of transfer direction. The portable companion performs sequential transfers using the actual nested locks, while the external desktop project runs bounded opposing workers through the same ordered protocol. The identifiers must form a stable appropriate order, and every participating access path must honor it. This is not proof that an entire application is deadlock-free in the presence of other locks, callbacks, or blocking dependencies.

Trace the portable program

The portable accounts begin with balances three and two. One unit moves from the first to the second, then one moves back. Final balances are three and two, with total five, so stdout is a:3, b:2, and total:5. The external workers each perform one hundred opposing transfers from larger initial balances, finishing at one thousand each and total two thousand. The result is checked only after both owned workers join. A common wrong answer assumes reversing transfer direction should reverse acquisition order, which is exactly how a two-resource wait cycle can be introduced.

Execute the actual desktop lab

The desktop project runs two real owned threads, each repeatedly transferring in the opposite direction. Both choose the lower identifier's synchronization object first, so direction does not create the familiar first-lock/second-lock cycle. Final reads occur after both threads join, not while one is halfway through an update. The bounded balances prevent an expected insufficient-funds branch during the experiment. This is a focused synchronization example with visible teaching fields, not a complete account domain model. A production owner should encapsulate all accesses and validate arithmetic and construction rather than allowing arbitrary callers to mutate balances outside the protocol.

Pitfalls and boundary limits

A lock protects data only when all relevant accesses follow the same synchronization discipline. Another method acquiring high before low, invoking an unknown callback while holding both, or waiting on unrelated work can reintroduce a cycle. Stable unique resource ordering must be a real invariant, not a transient hash code or a label that callers can change. The sample rejects equal identifiers rather than resolving an ambiguous ordering silently. It also does not provide database durability or a transaction across processes. Arithmetic correctness, access encapsulation, deadlock avoidance, and persistent consistency remain separate responsibilities even when a nested lock makes one local update look compact.

Practice one: isolate behavior

First perform only the first portable transfer and predict balances two and three with total five. Then restore the second transfer and verify the original result. Hint: separate arithmetic expectations from acquisition direction; the ordering rule stays the same for both commands. Add a rejected excessive transfer on an independent valid fixture and observe whether either balance changed. The baseline checks availability before mutation, but new failure paths need their own no-partial-update contract. Avoid judging correctness only from the total, since two wrong balances can still happen to sum correctly.

Practice two: design and verify

For another exercise, redesign Account so callers cannot write Balance or lock Sync directly, and expose a controlled operation that enforces the global order internally. Hint: preserve a stable resource identity and define same-account behavior explicitly. Add checked candidate arithmetic before storing both new balances so an overflow cannot leave one side changed. Plan a real concurrent test with bounded owned workers and final invariant checks, but do not deliberately introduce an unbounded deadlock. If persistence is added, specify its transaction and concurrency model instead of assuming the local monitor order transfers to a database.

Review the evidence

Review a multi-lock operation by drawing its resource-order edges and inspecting every path that can acquire those resources. Verify state access, failure-before-mutation behavior, callback boundaries, and the lifetime of the chosen order identifiers. The portable sequence proves only its arithmetic under actual sequential locking. The desktop project adds bounded opposing workers and progress evidence for this protocol, not a theorem about an entire application. A global order is useful when consistently enforced; hidden lock acquisition inside helpers or external calls can invalidate the reasoning without changing the visible pair of lock statements.

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.

A global lock order prevents a specific cycle — 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 first = new Account(1, 1000);
var second = new Account(2, 1000);
OwnedThreads.Run(() => { for (int i = 0; i < 100; i++) Transfer(first, second, 1); }, () => { for (int i = 0; i < 100; i++) Transfer(second, first, 1); });
Console.WriteLine($"a:{first.Balance}");
Console.WriteLine($"b:{second.Balance}");
Console.WriteLine($"total:{first.Balance + second.Balance}");
static void Transfer(Account from, Account to, int amount)
{
    if (amount < 1 || from.Id == to.Id) throw new ArgumentException("Distinct ordered accounts and positive amount required");
    Account low = from.Id < to.Id ? from : to, high = from.Id < to.Id ? to : from;
    lock (low.Sync) lock (high.Sync) { if (from.Balance < amount) throw new InvalidOperationException(); from.Balance -= amount; to.Balance += amount; }
}
sealed class Account(int id, int balance) { public int Id { get; } = id; public object Sync { get; } = new(); public int Balance = balance; }

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: a:1000 b:1000 total:2000 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 first = new Account(1, 3);
var second = new Account(2, 2);
Transfer(first, second, 1); Transfer(second, first, 1);
Console.WriteLine($"a:{first.Balance}");
Console.WriteLine($"b:{second.Balance}");
Console.WriteLine($"total:{first.Balance + second.Balance}");
static void Transfer(Account from, Account to, int amount)
{
    if (amount < 1 || from.Id == to.Id) throw new ArgumentException("Distinct ordered accounts and positive amount required");
    Account low = from.Id < to.Id ? from : to, high = from.Id < to.Id ? to : from;
    lock (low.Sync) lock (high.Sync) { if (from.Balance < amount) throw new InvalidOperationException(); from.Balance -= amount; to.Balance += amount; }
}
sealed class Account(int id, int balance) { public int Id { get; } = id; public object Sync { get; } = new(); public int Balance = balance; }
Open C# practice

C# practice output

a:3
b:2
total:5
Try this
IDs must define a stable unique order, and every path must use it. Two sequential transfers do not prove an arbitrary application is deadlock-free.

Further reading

Interview questions for this lesson