Advanced C# A 10 16 topics

Concurrency

Two threads touch the same data. One of them must wait, or one of them must win cleanly. Every tool on this page is a different way to make that happen.

This page assumes you know async and await from A 05. Async is about waiting without a thread. Concurrency is about many threads running at once and sharing state. Each topic follows the same order: what it is, why it exists, a compiled sample, its pitfalls, and interview questions.

Contents

  1. Threads, the pool, and tasks
  2. Race conditions
  3. lock and System.Threading.Lock
  4. Monitor: TryEnter, Wait, Pulse
  5. Interlocked and CAS loops
  6. volatile and the memory model
  7. SemaphoreSlim
  8. ReaderWriterLockSlim
  9. Deadlock and lock ordering
  10. Concurrent collections
  11. Immutable and frozen collections
  12. Lazy<T>
  13. ThreadLocal vs AsyncLocal
  14. Parallel.For, ForEach and PLINQ
  15. Interview task: bounded blocking queue
  16. Interview task: print in order
  17. Recap
How the tests stay deterministic. Thread timing changes on every run. So each demo returns a fact that timing cannot change. Examples are a final total, a count of factory calls, or an order forced by signals. Where a demo shows a bug, the test checks a bound, such as “the unsafe count is at most the true count”.

1. Threads, the pool, and tasks

What

Why

Creating threads is slow and each one holds memory. The pool amortizes that cost. Tasks add composition on top: WhenAll, continuations, cancellation, and error flow. In modern code you almost always want a task.

Your code Task.Run, await, Parallel.For, PLINQ Thread pool queues one global queue plus one local queue per worker worker 1 worker 2 worker 3 ... few threads, reused for many work items new Thread(...) one dedicated OS thread, about 1 MB of stack. You Start and Join it.
Figure 10.1 — Tasks share a small pool of reused threads, while a raw Thread owns one OS thread for its whole life.

Reading the figure. Blue is the code you write. Amber is the queue that holds pending work. Green boxes are pool threads that pick work off the queues. The violet box is a raw Thread. It skips the pool and keeps its own OS thread.

public static class ThreeWays
{
    /// <summary>Runs the same tiny job on a Thread, the ThreadPool, and a Task.</summary>
    /// <returns>The three results in a fixed order.</returns>
    /// <example><c>ThreeWays.Demo()</c> returns "thread=1 pool=2 task=3".</example>
    public static string Demo()
    {
        // 1. A raw Thread. You own its life, so Join waits for it to end.
        int fromThread = 0;                        // 0 means "not run yet"
        var thread = new Thread(() => fromThread = 1) { IsBackground = true };
        thread.Start();
        thread.Join();                             // Join also makes the write visible here

        // 2. The pool gives no handle back, so signal with an event.
        int fromPool = 0;
        using var done = new ManualResetEventSlim(false);   // false: starts unsignaled
        ThreadPool.QueueUserWorkItem(_ => { fromPool = 2; done.Set(); });
        done.Wait();

        // 3. A Task runs on the pool and carries the result back.
        // .Result blocks. That is fine in a console demo, not in UI or ASP.NET code.
        int fromTask = Task.Run(() => 3).Result;

        // The values 1, 2, 3 only mark which path ran.
        return $"thread={fromThread} pool={fromPool} task={fromTask}";
    }
}

Pitfalls

Interview questions

Q. When would you still create a raw Thread?
A. When you need thread identity or settings the pool does not give. Examples are a COM STA thread, a custom stack size, or a priority change. For normal work use Task.Run.
Q. Is a Task a thread?
A. No. It is a promise of a result. A CPU task borrows a pool thread while it runs. An I/O task uses no thread while it waits.

2. Race conditions

What

A race happens when the result depends on how threads interleave. The classic case is count++. It looks like one step, but it is three: read, add one, write back.

Why it matters

Two threads can both read 5, both add one, and both write 6. One increment is lost. The bug shows up only under load, which makes it hard to find later.

Thread A Thread B count in memory read 5 5 read 5 5 write 5 + 1 = 6 6 write 5 + 1 = 6 6, not 7 two increments ran, but the count rose by one
Figure 10.2 — count++ is read, add, write, so two threads can overwrite each other.

Reading the figure. Time runs down. Blue boxes are reads. Amber boxes are writes. Both threads read 5 before either writes. The red cell shows the lost update.

public static class RaceDemo
{
    /// <summary>Increments a shared int with plain ++ from many workers.</summary>
    /// <param name="workers">Number of parallel loop bodies.</param>
    /// <param name="perWorker">Increments done by each body.</param>
    /// <returns>The final count. It is often LESS than workers * perWorker.</returns>
    /// <example><c>Unsafe(4, 100_000)</c> may return 263_118.</example>
    public static int Unsafe(int workers, int perWorker)
    {
        int count = 0;                             // 0: nothing counted yet
        Parallel.For(0, workers, _ =>
        {
            // i counts this worker's increments. Invariant: i increments tried so far.
            for (int i = 0; i < perWorker; i++)
            {
                count++;                           // BUG: read, add, write can interleave
            }
        });
        return count;
    }

    /// <summary>Same loop, but each increment is one atomic step.</summary>
    /// <param name="workers">Number of parallel loop bodies.</param>
    /// <param name="perWorker">Increments done by each body.</param>
    /// <returns>Exactly workers * perWorker.</returns>
    /// <example><c>Safe(4, 100_000)</c> returns 400_000.</example>
    public static int Safe(int workers, int perWorker)
    {
        int count = 0;
        Parallel.For(0, workers, _ =>
        {
            for (int i = 0; i < perWorker; i++)
            {
                Interlocked.Increment(ref count);  // one CPU instruction, cannot be split
            }
        });
        return count;
    }
}

Pitfalls

Interview questions

Q. What is the difference between a race condition and a data race?
A. A data race is two unsynchronized accesses to one memory spot, with at least one write. A race condition is any timing-dependent bug. You can have a race condition with no data race, for example check-then-act on a thread-safe dictionary.

3. lock and System.Threading.Lock

What

lock (x) { ... } lets one thread at a time into the block. Before .NET 9, x was any object and the compiler called Monitor.Enter and Monitor.Exit. C# 13 with .NET 9 adds a real lock type, System.Threading.Lock. When x is a Lock, the compiler calls x.EnterScope() instead. It is a little faster and states the intent in the type.

Why

A lock turns several steps into one indivisible step for every other thread that uses the same lock. It also makes your writes visible to the next thread that takes the lock.

public sealed class BankAccount
{
    // C# 13 / .NET 9: a dedicated lock object. lock (_gate) compiles to EnterScope.
    private readonly Lock _gate = new();
    private decimal _balance;

    /// <summary>Reads the balance under the lock, so a decimal is never torn.</summary>
    /// <returns>The current balance.</returns>
    /// <example><c>new BankAccount().Balance</c> returns 0.</example>
    public decimal Balance
    {
        get { lock (_gate) { return _balance; } }
    }

    /// <summary>Adds money. The read-add-write runs as one step.</summary>
    /// <param name="amount">Amount to add, must be positive.</param>
    /// <example><c>Deposit(5m)</c> raises Balance by 5.</example>
    public void Deposit(decimal amount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        lock (_gate) { _balance += amount; }
    }

    /// <summary>Withdraws only if the money is there. Check and act share one lock.</summary>
    /// <param name="amount">Amount to take.</param>
    /// <returns>true if the money was taken.</returns>
    /// <example>Balance 10, <c>TryWithdraw(20m)</c> returns false.</example>
    public bool TryWithdraw(decimal amount)
    {
        lock (_gate)
        {
            // The check and the change sit in ONE critical section.
            if (_balance < amount) return false;
            _balance -= amount;
            return true;
        }
    }
}

public static class LockDemo
{
    /// <summary>Puts 100 in, then races many withdrawals of 1.</summary>
    /// <param name="attempts">Number of parallel withdraw attempts.</param>
    /// <returns>How many withdrawals succeeded. Never more than 100.</returns>
    /// <example><c>Overdraw(150)</c> returns 100.</example>
    public static int Overdraw(int attempts)
    {
        var account = new BankAccount();
        account.Deposit(100m);                     // 100: the cap on successful withdrawals
        int ok = 0;
        Parallel.For(0, attempts, _ =>
        {
            if (account.TryWithdraw(1m)) Interlocked.Increment(ref ok);   // 1m: one unit
        });
        return ok;
    }

    /// <summary>Shows EnterScope, the method lock (Lock) uses under the hood.</summary>
    /// <returns>"held free": held inside the scope, free after it.</returns>
    /// <example><c>LockDemo.Scope()</c> returns "held free".</example>
    public static string Scope()
    {
        var gate = new Lock();
        string inside;
        // EnterScope returns a ref struct. Dispose at the end of using releases the lock.
        using (gate.EnterScope())
        {
            inside = gate.IsHeldByCurrentThread ? "held" : "free";
        }
        string after = gate.IsHeldByCurrentThread ? "held" : "free";
        return $"{inside} {after}";
    }
}

Pitfalls

Interview questions

Q. Is lock re-entrant?
A. Yes. Both Monitor and Lock let the owning thread enter again. It must exit the same number of times.
Q. What does lock compile to for an object?
A. Monitor.Enter(obj, ref lockTaken) inside a try, and Monitor.Exit in the finally if lockTaken is true. So an exception never leaves the lock held.

4. Monitor: TryEnter, Wait, Pulse

What

Monitor is the engine under lock (object). It adds two things lock cannot express. TryEnter gives up after a timeout. Wait and Pulse let a thread sleep inside a lock until another thread says the state changed.

Why

Timeouts let you detect trouble instead of hanging. Wait and Pulse build classic condition variables, such as “wait until the queue is not empty”.

public sealed class Mailbox
{
    private readonly object _gate = new();         // Wait/Pulse need an object, not Lock
    private string? _message;                      // null means the box is empty

    /// <summary>Puts a message in and wakes any waiting reader.</summary>
    /// <param name="text">The message.</param>
    /// <example><c>Put("hi")</c> then <c>Take()</c> returns "hi".</example>
    public void Put(string text)
    {
        lock (_gate)
        {
            _message = text;
            Monitor.PulseAll(_gate);               // wake readers so they re-check
        }
    }

    /// <summary>Blocks until a message is there, then takes it.</summary>
    /// <returns>The message.</returns>
    /// <example>After <c>Put("hi")</c>, <c>Take()</c> returns "hi".</example>
    public string Take()
    {
        lock (_gate)
        {
            // while, not if: a wake-up does not promise the state you want.
            while (_message is null)
            {
                Monitor.Wait(_gate);               // releases the lock while asleep
            }
            string text = _message;
            _message = null;
            return text;
        }
    }
}

public static class MonitorDemo
{
    /// <summary>Another thread tries a lock we hold, with a short timeout.</summary>
    /// <returns>"busy free": fails while held, succeeds after release.</returns>
    /// <example><c>MonitorDemo.TryEnter()</c> returns "busy free".</example>
    public static string TryEnter()
    {
        var gate = new object();
        bool whileHeld, afterRelease;
        lock (gate)
        {
            bool got = false;
            // 50 ms: long enough to show a timeout, short enough for a test.
            var t = new Thread(() =>
            {
                got = Monitor.TryEnter(gate, TimeSpan.FromMilliseconds(50));
                if (got) Monitor.Exit(gate);
            });
            t.Start();
            t.Join();                              // we still hold gate, so it must fail
            whileHeld = got;
        }
        afterRelease = Monitor.TryEnter(gate, TimeSpan.FromMilliseconds(50));
        if (afterRelease) Monitor.Exit(gate);
        return $"{(whileHeld ? "got" : "busy")} {(afterRelease ? "free" : "busy")}";
    }

    /// <summary>A reader blocks on an empty Mailbox until a writer puts a message.</summary>
    /// <returns>The message the reader saw.</returns>
    /// <example><c>MonitorDemo.Handoff()</c> returns "ping".</example>
    public static string Handoff()
    {
        var box = new Mailbox();
        string seen = "";
        var reader = new Thread(() => seen = box.Take());
        reader.Start();
        box.Put("ping");
        reader.Join();
        return seen;
    }
}

Pitfalls

Interview questions

Q. Why does Monitor.Wait release the lock?
A. So another thread can enter and change the state you are waiting for. Wait takes the lock back before it returns.

5. Interlocked and CAS loops

What

Interlocked gives single atomic CPU operations: Increment, Decrement, Add, Exchange and CompareExchange. Compare-and-swap (CAS, a conditional atomic write) is the building block. CompareExchange(ref x, new, expected) writes new only if x still equals expected. It always returns the old value.

Why

No lock means no blocking and no deadlock. For one shared number it is the fastest correct choice. For anything that is not a built-in operation, wrap it in a CAS loop.

seen = x read once next = f(seen) pure, no side effects old = CompareExchange (ref x, next, seen) done old == seen old != seen: another thread won, so seen = old and retry Each retry means some other thread made progress. That is lock-free.
Figure 10.3 — A CAS loop retries until its write lands on the value it read.

Reading the figure. Blue steps are private work. The amber step is the one atomic write. Green is success. The red path is the retry, taken only when another thread changed x in between.

public static class Cas
{
    /// <summary>Atomically raises target to value if value is larger.</summary>
    /// <param name="target">The shared maximum.</param>
    /// <param name="value">A candidate.</param>
    /// <returns>The maximum after this call.</returns>
    /// <example>target 3, <c>InterlockedMax(ref target, 9)</c> returns 9.</example>
    public static int InterlockedMax(ref int target, int value)
    {
        int seen = Volatile.Read(ref target);
        // Invariant at the top of each pass: seen is a value target held recently.
        while (true)
        {
            if (value <= seen) return seen;        // nothing to do, already big enough
            int old = Interlocked.CompareExchange(ref target, value, seen);
            if (old == seen) return value;         // our write landed
            seen = old;                            // lost the race, retry with fresh value
        }
    }

    /// <summary>Finds the max of values from many threads at once.</summary>
    /// <param name="values">Input numbers.</param>
    /// <returns>The largest value.</returns>
    /// <example><c>MaxDemo([4, 17, 2])</c> returns 17.</example>
    public static int MaxDemo(int[] values)
    {
        int max = int.MinValue;                    // int.MinValue: smaller than any input
        Parallel.ForEach(values, v => InterlockedMax(ref max, v));
        return max;
    }

    /// <summary>Uses Exchange as a one-time switch. Only the first caller wins.</summary>
    /// <param name="callers">Number of threads racing for the switch.</param>
    /// <returns>How many callers won. Always 1.</returns>
    /// <example><c>OneWinner(8)</c> returns 1.</example>
    public static int OneWinner(int callers)
    {
        int flag = 0;                              // 0 = not taken, 1 = taken
        int winners = 0;
        Parallel.For(0, callers, _ =>
        {
            // Exchange returns the OLD value. Only one caller ever sees 0.
            if (Interlocked.Exchange(ref flag, 1) == 0) Interlocked.Increment(ref winners);
        });
        return winners;
    }
}

Pitfalls

Interview questions

Q. Lock-free or lock: which is faster?
A. Under low contention for one variable, Interlocked wins. Under heavy contention, a CAS loop can spin and waste CPU. For several related fields a lock is simpler and often just as fast.

6. volatile and the memory model

What

The compiler, the JIT and the CPU may reorder or cache memory reads and writes. That is fine for one thread. Other threads can then see stale or out-of-order values. volatile on a field, or Volatile.Read and Volatile.Write, stop that for one field. A volatile read has acquire semantics: later reads cannot move above it. A volatile write has release semantics: earlier writes cannot move below it.

Why

Without it, the JIT may read a bool stop flag once and keep it in a register. The loop then never sees the change and spins forever.

public sealed class StopFlagWorker
{
    // volatile: every read goes to memory, so the loop sees the change.
    private volatile bool _stop;

    /// <summary>Starts a spinning thread, then stops it with the flag.</summary>
    /// <returns>"stopped" once the worker has seen the flag and exited.</returns>
    /// <example><c>new StopFlagWorker().Run()</c> returns "stopped".</example>
    public string Run()
    {
        long spins = 0;                            // long: a busy loop can pass int.MaxValue
        var worker = new Thread(() =>
        {
            // Without volatile the JIT could hoist this read out of the loop.
            while (!_stop) spins++;
        });
        worker.Start();
        Thread.Sleep(10);                          // 10 ms: let the worker spin a bit
        _stop = true;                              // volatile write, release semantics
        worker.Join();
        return "stopped";
    }
}

public sealed class Config(string name)
{
    public string Name { get; } = name;
}

public static class SafePublish
{
    private static Config? _instance;
    private static readonly Lock Gate = new();

    /// <summary>Double-checked lazy creation, done right.</summary>
    /// <returns>The single shared Config.</returns>
    /// <example><c>Get().Name</c> returns "main".</example>
    public static Config Get()
    {
        // Fast path: acquire read, so a non-null reference has its fields ready.
        var current = Volatile.Read(ref _instance);
        if (current is not null) return current;
        lock (Gate)
        {
            // Check again: another thread may have created it while we waited.
            current = _instance;
            if (current is null)
            {
                current = new Config("main");
                Volatile.Write(ref _instance, current);   // release: publish after init
            }
            return current;
        }
    }
}

Pitfalls

Interview questions

Q. Why is double-checked locking broken in some languages?
A. A thread can see the reference before the object's fields are written. In .NET, a volatile write to publish, or Lazy<T>, prevents that. Most code should just use Lazy<T>.

7. SemaphoreSlim

What

A semaphore holds a count of permits. Wait takes one and blocks if none are left. Release gives one back. SemaphoreSlim is the light in-process version, and it has WaitAsync.

Why

It does two jobs. With a count of N it caps how many tasks run at once, such as 4 HTTP calls. With a count of 1 it is an async-friendly mutex, the fix for “no await inside lock”. With a count of 0 it is a signal another thread can fire.

public sealed class PeakCounter
{
    private int _inFlight;
    private int _peak;

    /// <summary>Marks one job as started and updates the peak.</summary>
    /// <example>First call raises Peak to 1.</example>
    public void Enter() => Cas.InterlockedMax(ref _peak, Interlocked.Increment(ref _inFlight));

    /// <summary>Marks one job as finished.</summary>
    /// <example>After Enter then Exit, nothing is in flight.</example>
    public void Exit() => Interlocked.Decrement(ref _inFlight);

    /// <summary>Most jobs ever running at once.</summary>
    /// <example>With a limit of 2, Peak is at most 2.</example>
    public int Peak => Volatile.Read(ref _peak);
}

public static class Throttle
{
    /// <summary>Runs many async jobs but lets only limit run at once.</summary>
    /// <param name="jobs">Number of jobs.</param>
    /// <param name="limit">Most jobs allowed in flight.</param>
    /// <returns>The highest number seen in flight. Never above limit.</returns>
    /// <example><c>MaxInFlight(10, 2)</c> returns 2.</example>
    public static int MaxInFlight(int jobs, int limit) =>
        MaxInFlightAsync(jobs, limit).GetAwaiter().GetResult();

    private static async Task<int> MaxInFlightAsync(int jobs, int limit)
    {
        // limit, limit: start with limit permits, never more than limit.
        using var gate = new SemaphoreSlim(limit, limit);
        var counter = new PeakCounter();
        var tasks = Enumerable.Range(0, jobs).Select(async _ =>
        {
            await gate.WaitAsync();                // take a permit or wait for one
            try
            {
                counter.Enter();
                await Task.Delay(5);               // 5 ms of fake I/O
                counter.Exit();
            }
            finally
            {
                gate.Release();                    // always give the permit back
            }
        });
        await Task.WhenAll(tasks);
        return counter.Peak;
    }
}

public sealed class AsyncCache
{
    // Count 1, max 1: an async mutex. Safe to await while holding it.
    private readonly SemaphoreSlim _mutex = new(1, 1);
    private readonly Dictionary<string, int> _data = [];
    public int Loads;

    /// <summary>Gets a value, loading it at most once even under concurrency.</summary>
    /// <param name="key">The key.</param>
    /// <returns>The value, the key length in this demo.</returns>
    /// <example><c>await GetAsync("abc")</c> returns 3.</example>
    public async Task<int> GetAsync(string key)
    {
        await _mutex.WaitAsync();
        try
        {
            if (_data.TryGetValue(key, out int hit)) return hit;
            await Task.Delay(1);                   // 1 ms: stands in for a slow load
            Loads++;                               // safe: only one holder at a time
            return _data[key] = key.Length;
        }
        finally
        {
            _mutex.Release();
        }
    }

    /// <summary>Asks for the same key from 20 tasks at once.</summary>
    /// <returns>How many real loads happened. Always 1.</returns>
    /// <example><c>new AsyncCache().Demo()</c> returns 1.</example>
    public int Demo()
    {
        // 20 tasks: plenty of overlap for the mutex to matter.
        var all = Enumerable.Range(0, 20).Select(_ => GetAsync("key"));
        Task.WhenAll(all).GetAwaiter().GetResult();
        return Loads;
    }
}

Pitfalls

Interview questions

Q. SemaphoreSlim or Semaphore?
A. SemaphoreSlim for anything inside one process. It is cheaper and has WaitAsync. Semaphore wraps an OS object and can be named and shared across processes.

8. ReaderWriterLockSlim

What

Many readers may hold the lock together. A writer gets it alone. An upgradeable read lets one thread read, then turn into a writer without letting anyone in between.

Why

For data that is read far more than written, plain lock makes readers queue behind each other for no reason. This lock lets them run in parallel.

public sealed class RwCache : IDisposable
{
    private readonly ReaderWriterLockSlim _rw = new();
    private readonly Dictionary<string, int> _map = [];
    public int Builds;

    /// <summary>Gets a value, building it once if missing.</summary>
    /// <param name="key">The key.</param>
    /// <param name="build">Makes the value. Runs at most once per key.</param>
    /// <returns>The cached or new value.</returns>
    /// <example><c>GetOrAdd("ab", k => k.Length)</c> returns 2.</example>
    public int GetOrAdd(string key, Func<string, int> build)
    {
        // Fast path: a shared read lock. Many readers run in parallel.
        _rw.EnterReadLock();
        try
        {
            if (_map.TryGetValue(key, out int hit)) return hit;
        }
        finally { _rw.ExitReadLock(); }

        // Slow path: only one upgradeable reader at a time, so no lost builds.
        _rw.EnterUpgradeableReadLock();
        try
        {
            if (_map.TryGetValue(key, out int hit)) return hit;   // someone beat us
            _rw.EnterWriteLock();
            try
            {
                Builds++;
                return _map[key] = build(key);
            }
            finally { _rw.ExitWriteLock(); }
        }
        finally { _rw.ExitUpgradeableReadLock(); }
    }

    /// <summary>Releases the OS resources the lock may hold.</summary>
    /// <example><c>using var c = new RwCache();</c></example>
    public void Dispose() => _rw.Dispose();

    /// <summary>Hammers 3 keys from many threads.</summary>
    /// <returns>Number of builds. Always 3, one per key.</returns>
    /// <example><c>RwCache.Demo()</c> returns 3.</example>
    public static int Demo()
    {
        using var cache = new RwCache();
        string[] keys = ["a", "bb", "ccc"];
        // 300 lookups over 3 keys: each key is asked for 100 times.
        Parallel.For(0, 300, i => cache.GetOrAdd(keys[i % keys.Length], k => k.Length));
        return cache.Builds;
    }
}

Pitfalls

Interview questions

Q. Why allow only one upgradeable reader?
A. Two readers that both want to upgrade would each wait for the other to leave. That is a deadlock. One upgradeable slot removes the cycle.

9. Deadlock and lock ordering

What

A deadlock is a cycle of waiting. Thread 1 holds A and wants B. Thread 2 holds B and wants A. Neither can move. Four conditions must all hold: mutual exclusion, hold and wait, no preemption, and circular wait.

Why it matters

Break any one condition and deadlock is impossible. The easiest to break is circular wait. Give every lock a global order and always take locks in that order.

opposite order: cycle Thread 1 Thread 2 A B holds holds red: waits for same order: no cycle Thread 1 Thread 2 1. lock A 2. lock B 1. lock A 2. lock B Whoever gets A first finishes. The other waits on A, holding nothing.
Figure 10.4 — Opposite lock order makes a wait cycle, and one global order removes it.

Reading the figure. Green arrows mean “holds”. Red arrows mean “waits for”. On the left the red and green arrows close a loop. On the right every thread asks for A first, so no thread can hold B while waiting for A.

public sealed class Account(int id, decimal balance)
{
    public int Id { get; } = id;                  // the global lock order key
    public decimal Balance { get; set; } = balance;
    public Lock Gate { get; } = new();
}

public static class Transfers
{
    /// <summary>Moves money between two accounts without deadlock.</summary>
    /// <param name="from">Source account.</param>
    /// <param name="to">Target account.</param>
    /// <param name="amount">Amount to move.</param>
    /// <example><c>Move(a, b, 5m)</c> takes 5 from a and gives it to b.</example>
    public static void Move(Account from, Account to, decimal amount)
    {
        // Lock ordering: always lock the lower Id first, whatever the direction.
        var (first, second) = from.Id < to.Id ? (from, to) : (to, from);
        lock (first.Gate)
        {
            lock (second.Gate)
            {
                from.Balance -= amount;
                to.Balance += amount;
            }
        }
    }

    /// <summary>Two threads move money in opposite directions many times.</summary>
    /// <returns>The total money left. Always 200, and the call always returns.</returns>
    /// <example><c>Transfers.Demo()</c> returns 200.</example>
    public static decimal Demo()
    {
        var a = new Account(1, 100m);              // ids 1 and 2 fix the order
        var b = new Account(2, 100m);              // 100 + 100 = 200 total
        // 10_000 rounds: enough overlap that a naive version would hang.
        var t1 = new Thread(() => { for (int i = 0; i < 10_000; i++) Move(a, b, 1m); });
        var t2 = new Thread(() => { for (int i = 0; i < 10_000; i++) Move(b, a, 1m); });
        t1.Start(); t2.Start();
        t1.Join(); t2.Join();
        return a.Balance + b.Balance;
    }
}

public static class DeadlockDetector
{
    /// <summary>Forces the A-then-B and B-then-A cycle, and detects it with timeouts.</summary>
    /// <returns>"deadlock": both threads timed out waiting for the other lock.</returns>
    /// <example><c>DeadlockDetector.Opposite()</c> returns "deadlock".</example>
    public static string Opposite()
    {
        var a = new object();
        var b = new object();
        // 2 parties: the two threads. Barrier 1 makes both hold their first lock.
        // Barrier 2 keeps both locks held until both tries have failed.
        using var bothHoldFirst = new Barrier(2);
        using var bothTried = new Barrier(2);
        bool got1 = false, got2 = false;

        void Run(object mine, object theirs, Action<bool> report)
        {
            lock (mine)
            {
                bothHoldFirst.SignalAndWait();
                // 100 ms: plenty of time if the lock were free. It never is.
                bool got = Monitor.TryEnter(theirs, TimeSpan.FromMilliseconds(100));
                if (got) Monitor.Exit(theirs);
                report(got);
                bothTried.SignalAndWait();
            }
        }

        var t1 = new Thread(() => Run(a, b, g => got1 = g));
        var t2 = new Thread(() => Run(b, a, g => got2 = g));
        t1.Start(); t2.Start();
        t1.Join(); t2.Join();
        return !got1 && !got2 ? "deadlock" : "no deadlock";
    }
}

Pitfalls

Say this out loud: “Deadlock needs a cycle. I prevent the cycle by giving every lock a rank and always locking in rank order. For accounts, the rank is the account id.”

Interview questions

Q. How would you find a deadlock in production?
A. Take a dump with dotnet-dump collect. Then run clrstack -all and syncblk to see which thread holds which lock and what it waits for.
Q. Besides ordering, how else can you avoid deadlock?
A. Hold one lock at a time. Use timeouts with TryEnter and back off. Or avoid shared state with messages, immutable data, or a single owner thread.

10. Concurrent collections

What

System.Collections.Concurrent holds thread-safe collections. ConcurrentDictionary uses fine-grained locks and lock-free reads. ConcurrentQueue, ConcurrentStack and ConcurrentBag are lock-free. BlockingCollection wraps one of them and adds blocking and a bounded size.

Why

Each single call is atomic, so you skip writing locks by hand. But a sequence of calls is still not atomic. The API gives combined calls such as GetOrAdd, AddOrUpdate and TryRemove for that reason.

The GetOrAdd pitfall

GetOrAdd(key, factory) stores only one value per key. But the factory runs outside the lock. Under contention it can run several times, and the extra results are thrown away. If the factory is costly or has side effects, store a Lazy<T> instead.

using System.Collections.Concurrent;

public static class GetOrAddDemo
{
    /// <summary>Many threads ask for one missing key at the same moment.</summary>
    /// <param name="threads">How many threads race.</param>
    /// <returns>How many times the factory ran. Can be more than 1.</returns>
    /// <example><c>NaiveFactoryRuns(8)</c> may return 8.</example>
    public static int NaiveFactoryRuns(int threads)
    {
        var map = new ConcurrentDictionary<string, int>();
        int runs = 0;
        RaceAll(threads, () => map.GetOrAdd("k", _ =>
        {
            Interlocked.Increment(ref runs);
            Thread.Sleep(20);                      // 20 ms: a slow factory widens the race
            return 42;                             // 42: any value, all copies are equal
        }));
        return runs;
    }

    /// <summary>Same race, but the dictionary stores Lazy values.</summary>
    /// <param name="threads">How many threads race.</param>
    /// <returns>How many times the real factory ran. Always 1.</returns>
    /// <example><c>LazyFactoryRuns(8)</c> returns 1.</example>
    public static int LazyFactoryRuns(int threads)
    {
        var map = new ConcurrentDictionary<string, Lazy<int>>();
        int runs = 0;
        RaceAll(threads, () =>
        {
            // Many Lazy shells may be built, but only the stored one is ever .Value-d.
            var lazy = map.GetOrAdd("k", _ => new Lazy<int>(() =>
            {
                Interlocked.Increment(ref runs);
                Thread.Sleep(20);
                return 42;
            }));
            _ = lazy.Value;
        });
        return runs;
    }

    // Starts n raw threads that all run body right after a shared barrier.
    // Raw threads, not Parallel.For: a barrier needs every party running at once.
    private static void RaceAll(int n, Action body)
    {
        using var start = new Barrier(n);
        var all = Enumerable.Range(0, n)
            .Select(_ => new Thread(() => { start.SignalAndWait(); body(); }))
            .ToList();
        all.ForEach(t => t.Start());
        all.ForEach(t => t.Join());
    }
}

public static class WordCounter
{
    /// <summary>Counts words from many threads with AddOrUpdate.</summary>
    /// <param name="lines">Input lines.</param>
    /// <returns>"word=count" pairs sorted by word.</returns>
    /// <example><c>Count(["a b", "a"])</c> returns ["a=2", "b=1"].</example>
    public static List<string> Count(string[] lines)
    {
        var counts = new ConcurrentDictionary<string, int>();
        Parallel.ForEach(lines, line =>
        {
            foreach (var word in line.Split(' ', StringSplitOptions.RemoveEmptyEntries))
            {
                // 1 if new. Otherwise old + 1. The update lambda may retry, so keep it pure.
                counts.AddOrUpdate(word, 1, (_, old) => old + 1);
            }
        });
        // Dictionary order is not defined, so sort for a stable answer.
        return [.. counts.OrderBy(p => p.Key, StringComparer.Ordinal)
                        .Select(p => $"{p.Key}={p.Value}")];
    }
}

public static class QueueDemo
{
    /// <summary>4 producers enqueue 1..n each. One drain sums everything.</summary>
    /// <param name="n">Items per producer.</param>
    /// <returns>The total. Equals 4 * n * (n + 1) / 2.</returns>
    /// <example><c>Sum(10)</c> returns 220.</example>
    public static long Sum(int n)
    {
        var queue = new ConcurrentQueue<int>();
        // 4 producers: enough to show contention. 1..n each: values start at 1.
        Parallel.For(0, 4, _ =>
        {
            for (int v = 1; v <= n; v++) queue.Enqueue(v);
        });
        long total = 0;
        // TryDequeue never blocks. It returns false when the queue is empty.
        while (queue.TryDequeue(out int v)) total += v;
        return total;
    }
}

public static class Pipeline
{
    /// <summary>Producer and consumer joined by a bounded BlockingCollection.</summary>
    /// <param name="n">Number of items to send, 1..n.</param>
    /// <returns>Sum of the squares the consumer saw, in order.</returns>
    /// <example><c>SumOfSquares(3)</c> returns 14.</example>
    public static long SumOfSquares(int n)
    {
        // boundedCapacity 2: the producer blocks when 2 items wait. That is back-pressure.
        using var buffer = new BlockingCollection<int>(boundedCapacity: 2);
        var producer = Task.Run(() =>
        {
            for (int i = 1; i <= n; i++) buffer.Add(i);   // blocks while full
            buffer.CompleteAdding();               // tells the consumer no more items
        });
        long total = 0;
        // GetConsumingEnumerable blocks while empty and ends after CompleteAdding.
        foreach (int item in buffer.GetConsumingEnumerable()) total += (long)item * item;
        producer.Wait();
        return total;
    }
}

Pitfalls

Interview questions

Q. Is dict.GetOrAdd(k, f) guaranteed to call f once?
A. No. Only one result is stored and every caller gets that one. But f can run on several threads. Use Lazy<T> values for once-only creation.
Q. Why not just wrap a Dictionary in a lock?
A. That is fine at low contention and often simpler. ConcurrentDictionary wins when many threads read and write at once, because reads take no lock.

11. Immutable and frozen collections

What

System.Collections.Immutable gives lists, dictionaries and sets that never change. Add returns a new collection that shares most of its structure with the old one. System.Collections.Frozen (.NET 8) gives read-only sets and dictionaries tuned for fast lookups after a one-time build.

Why

Data that never changes needs no lock to read. A reader holds a snapshot that stays valid forever. Writers build a new version and swap one reference.

using System.Collections.Frozen;
using System.Collections.Immutable;

public static class ImmutableDemo
{
    /// <summary>Add returns a new list. The old one is untouched.</summary>
    /// <returns>"3 4": old count, new count.</returns>
    /// <example><c>ImmutableDemo.Snapshot()</c> returns "3 4".</example>
    public static string Snapshot()
    {
        ImmutableList<int> before = [1, 2, 3];
        var after = before.Add(4);                 // 4: one new item
        return $"{before.Count} {after.Count}";
    }

    /// <summary>Many threads add to one shared immutable list without a lock.</summary>
    /// <param name="n">Number of adds.</param>
    /// <returns>Final count. Always n.</returns>
    /// <example><c>ConcurrentAdds(1000)</c> returns 1000.</example>
    public static int ConcurrentAdds(int n)
    {
        var list = ImmutableList<int>.Empty;
        // ImmutableInterlocked.Update is a CAS loop on the reference.
        Parallel.For(0, n, i => ImmutableInterlocked.Update(ref list, l => l.Add(i)));
        return list.Count;
    }

    /// <summary>Builds a frozen lookup once, then reads it from anywhere.</summary>
    /// <param name="key">A key to look up.</param>
    /// <returns>The code, or -1 if the key is unknown.</returns>
    /// <example><c>Lookup("GBP")</c> returns 826.</example>
    public static int Lookup(string key)
    {
        // ISO 4217 numeric codes, used here as sample values.
        var raw = new Dictionary<string, int> { ["USD"] = 840, ["EUR"] = 978, ["GBP"] = 826 };
        var codes = raw.ToFrozenDictionary();
        return codes.TryGetValue(key, out int code) ? code : -1;   // -1: not found
    }
}

Pitfalls

Interview questions

Q. How do you update shared immutable state from many threads?
A. Read the reference, build the new version, and swap with Interlocked.CompareExchange. Retry if it changed. ImmutableInterlocked.Update does exactly that.

12. Lazy<T>

What

Lazy<T> runs a factory the first time someone reads .Value, then caches the result. The default mode, ExecutionAndPublication, runs the factory exactly once even when many threads ask at once.

Why

It is the correct, ready-made form of double-checked locking. It defers costly work until needed.

public static class LazyDemo
{
    /// <summary>Many threads read one Lazy at the same moment.</summary>
    /// <param name="threads">Number of readers.</param>
    /// <returns>Factory runs. Always 1 in the default mode.</returns>
    /// <example><c>FactoryRuns(8)</c> returns 1.</example>
    public static int FactoryRuns(int threads)
    {
        int runs = 0;
        var lazy = new Lazy<int>(() =>
        {
            Interlocked.Increment(ref runs);
            Thread.Sleep(20);                      // slow factory, to invite a race
            return 7;                              // 7: any value
        }, LazyThreadSafetyMode.ExecutionAndPublication);
        Parallel.For(0, threads, _ => _ = lazy.Value);
        return runs;
    }

    /// <summary>Shows that the default mode caches an exception.</summary>
    /// <returns>"1 1": the factory ran once and both reads threw.</returns>
    /// <example><c>LazyDemo.CachedFailure()</c> returns "1 1".</example>
    public static string CachedFailure()
    {
        int runs = 0, throws = 0;
        var lazy = new Lazy<int>(() =>
        {
            runs++;
            throw new InvalidOperationException("boom");
        });
        // 2 reads: proves the second read does not retry the factory.
        for (int i = 0; i < 2; i++)
        {
            try { _ = lazy.Value; } catch (InvalidOperationException) { throws++; }
        }
        // throws is 2, runs is 1. Report runs and throws / 2 to keep it short.
        return $"{runs} {throws / 2}";
    }
}

Pitfalls

Interview questions

Q. Write a thread-safe singleton in C#.
A. private static readonly Lazy<Foo> Inst = new(() => new Foo()); and expose Inst.Value. A static readonly field also works, because the runtime runs a static constructor once.

13. ThreadLocal vs AsyncLocal

What

Why

ThreadLocal avoids sharing, so it suits per-thread caches and scratch buffers. AsyncLocal carries ambient context such as a request id or a trace span through async calls.

public static class LocalsDemo
{
    private static readonly AsyncLocal<string?> RequestId = new();

    /// <summary>Shows that AsyncLocal flows down into awaits but not back up.</summary>
    /// <returns>"req-1 req-1": the value survives an await and a child's change.</returns>
    /// <example><c>LocalsDemo.Flow()</c> returns "req-1 req-1".</example>
    public static string Flow() => FlowAsync().GetAwaiter().GetResult();

    private static async Task<string> FlowAsync()
    {
        RequestId.Value = "req-1";
        await Task.Yield();                        // may resume on another thread
        string afterAwait = RequestId.Value ?? "none";
        await ChildAsync();                        // child sets its own value
        // The child's change ended with the child. We still see ours.
        return $"{afterAwait} {RequestId.Value ?? "none"}";
    }

    private static async Task ChildAsync()
    {
        RequestId.Value = "child";
        await Task.Yield();
    }

    /// <summary>Each thread counts in its own slot. Slots are summed at the end.</summary>
    /// <param name="n">Number of loop iterations.</param>
    /// <returns>The total count. Always n.</returns>
    /// <example><c>ThreadLocalSum(1000)</c> returns 1000.</example>
    public static int ThreadLocalSum(int n)
    {
        // () => 0: each thread starts its slot at 0. trackAllValues lets us read all slots.
        using var slot = new ThreadLocal<int>(() => 0, trackAllValues: true);
        Parallel.For(0, n, _ => slot.Value++);    // no sharing, so no lock
        return slot.Values.Sum();
    }
}

Pitfalls

Interview questions

Q. How does AsyncLocal flow?
A. It lives in the ExecutionContext. await, Task.Run and thread pool queues capture and restore that context. Changes are copy-on-write, so a child cannot change the parent's value.

14. Parallel.For, ForEach and PLINQ

What

Parallel.For and Parallel.ForEach split a loop across pool threads. Parallel.ForEachAsync (.NET 6) does the same for async bodies with a parallelism cap. PLINQ, started with .AsParallel(), runs a LINQ query in parallel.

Why

They are for CPU-bound data work: many independent items and enough work per item to beat the scheduling cost.

public static class ParallelDemo
{
    /// <summary>Sum of i*i for i in 1..n, with thread-local subtotals.</summary>
    /// <param name="n">Upper bound, inclusive.</param>
    /// <returns>The sum.</returns>
    /// <example><c>SumOfSquares(100)</c> returns 338350.</example>
    public static long SumOfSquares(int n)
    {
        long total = 0;
        // 1, n + 1: For is [from, to), so + 1 makes n included.
        Parallel.For(1, n + 1,
            () => 0L,                                         // each worker starts at 0
            (i, _, local) => local + (long)i * i,              // no sharing in the hot path
            local => Interlocked.Add(ref total, local));       // one atomic add per worker
        return total;
    }

    /// <summary>Primes up to limit, with PLINQ, kept in input order.</summary>
    /// <param name="limit">Largest number to test.</param>
    /// <returns>The primes, ascending.</returns>
    /// <example><c>Primes(20)</c> returns [2, 3, 5, 7, 11, 13, 17, 19].</example>
    public static int[] Primes(int limit) =>
        // Start at 2, the first prime. limit - 1 values cover 2..limit.
        Enumerable.Range(2, limit - 1)
            .AsParallel()
            .AsOrdered()                           // without this, order is not defined
            .Where(IsPrime)
            .ToArray();

    private static bool IsPrime(int n)
    {
        // d * d <= n: a factor above sqrt(n) pairs with one below it.
        for (int d = 2; d * d <= n; d++)
        {
            if (n % d == 0) return false;          // % d == 0: d divides n
        }
        return true;
    }

    /// <summary>Async bodies with a cap on how many run at once.</summary>
    /// <param name="items">Inputs.</param>
    /// <returns>Sum of the doubled inputs.</returns>
    /// <example><c>DoubleAll([1, 2, 3])</c> returns 12.</example>
    public static int DoubleAll(int[] items)
    {
        int sum = 0;
        // 2: at most two bodies in flight, like a small HTTP connection limit.
        var options = new ParallelOptions { MaxDegreeOfParallelism = 2 };
        Parallel.ForEachAsync(items, options, async (x, ct) =>
        {
            await Task.Delay(1, ct);               // 1 ms of fake I/O
            Interlocked.Add(ref sum, x * 2);       // * 2: the "work" is doubling
        }).GetAwaiter().GetResult();
        return sum;
    }

    /// <summary>Exceptions from parallel bodies arrive wrapped together.</summary>
    /// <returns>The exception type name.</returns>
    /// <example><c>ParallelDemo.Failure()</c> returns "AggregateException".</example>
    public static string Failure()
    {
        try
        {
            // 0..3: four bodies, and body 2 fails.
            Parallel.For(0, 4, i => { if (i == 2) throw new InvalidOperationException(); });
            return "none";
        }
        catch (AggregateException ex)
        {
            return ex.GetType().Name;
        }
    }
}

Pitfalls

Interview questions

Q. Parallel.ForEach or Task.WhenAll?
A. Parallel for CPU work on many items. Task.WhenAll for I/O work that waits without threads. For async work that needs a cap, use Parallel.ForEachAsync.

15. Interview task: bounded blocking queue

Problem

Build a thread-safe FIFO queue with a fixed capacity. Enqueue blocks while the queue is full. Dequeue blocks while it is empty. Count returns the current size. This is LeetCode 1188.

Idea

Use two semaphores. _free counts empty slots and starts at the capacity. _items counts filled slots and starts at 0. A producer takes a free slot, adds, then releases an item. A consumer does the mirror. A small lock guards the ring buffer itself.

producers consumers ring buffer, capacity 4 7 8 head tail wait _free wait _items _free = 2 empty slots _items = 2 filled slots
Figure 10.5 — Two semaphores count free and filled slots, so each side blocks only when it must.

Reading the figure. Green cells hold items. Grey cells are empty. The two amber counters always add up to the capacity, 4. A producer blocks when _free is 0. A consumer blocks when _items is 0.

Solution

public sealed class BoundedBlockingQueue<T> : IDisposable
{
    private readonly T[] _ring;
    private int _head;                             // index of the oldest item
    private int _count;                            // items stored now
    private readonly Lock _gate = new();           // guards _ring, _head, _count
    private readonly SemaphoreSlim _free;          // empty slots
    private readonly SemaphoreSlim _items;         // filled slots

    /// <summary>Creates a queue that holds at most capacity items.</summary>
    /// <param name="capacity">Maximum size, at least 1.</param>
    /// <example><c>new BoundedBlockingQueue<int>(2)</c></example>
    public BoundedBlockingQueue(int capacity)
    {
        ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);   // 1: smallest useful
        _ring = new T[capacity];
        _free = new SemaphoreSlim(capacity, capacity);   // all slots start empty
        _items = new SemaphoreSlim(0, capacity);         // 0: nothing to take yet
    }

    /// <summary>Adds an item, blocking while the queue is full.</summary>
    /// <param name="item">The item.</param>
    /// <example><c>q.Enqueue(5)</c></example>
    public void Enqueue(T item)
    {
        _free.Wait();                              // 1. reserve an empty slot
        lock (_gate)
        {
            // Tail = head + count, wrapped by % length to stay inside the ring.
            _ring[(_head + _count) % _ring.Length] = item;
            _count++;
        }
        _items.Release();                          // 2. announce a filled slot
    }

    /// <summary>Tries to add within a timeout.</summary>
    /// <param name="item">The item.</param>
    /// <param name="timeoutMs">Milliseconds to wait for room.</param>
    /// <returns>false if the queue stayed full the whole time.</returns>
    /// <example>On a full queue, <c>TryEnqueue(1, 10)</c> returns false.</example>
    public bool TryEnqueue(T item, int timeoutMs)
    {
        if (!_free.Wait(timeoutMs)) return false;  // no slot freed in time
        lock (_gate)
        {
            _ring[(_head + _count) % _ring.Length] = item;
            _count++;
        }
        _items.Release();
        return true;
    }

    /// <summary>Removes the oldest item, blocking while the queue is empty.</summary>
    /// <returns>The oldest item.</returns>
    /// <example>After <c>Enqueue(5)</c>, <c>Dequeue()</c> returns 5.</example>
    public T Dequeue()
    {
        _items.Wait();                             // 1. reserve a filled slot
        T item;
        lock (_gate)
        {
            item = _ring[_head];
            _ring[_head] = default!;               // drop the reference for the GC
            _head = (_head + 1) % _ring.Length;    // + 1 then wrap: next oldest
            _count--;
        }
        _free.Release();                           // 2. announce an empty slot
        return item;
    }

    /// <summary>Current number of items.</summary>
    /// <example>New queue: <c>Count</c> is 0.</example>
    public int Count { get { lock (_gate) { return _count; } } }

    /// <summary>Frees the two semaphores.</summary>
    /// <example><c>using var q = new BoundedBlockingQueue<int>(4);</c></example>
    public void Dispose() { _free.Dispose(); _items.Dispose(); }
}

public static class BbqDemo
{
    /// <summary>3 producers and 3 consumers share a queue of capacity 2.</summary>
    /// <param name="perProducer">Items each producer sends, values 1..perProducer.</param>
    /// <returns>The sum consumed. Equals 3 * p * (p + 1) / 2.</returns>
    /// <example><c>Sum(100)</c> returns 15150.</example>
    public static long Sum(int perProducer)
    {
        // 2: a tiny capacity forces lots of blocking on both sides.
        using var q = new BoundedBlockingQueue<int>(2);
        const int Workers = 3;                     // 3 producers and 3 consumers
        long total = 0;
        var producers = Enumerable.Range(0, Workers).Select(_ => new Thread(() =>
        {
            for (int v = 1; v <= perProducer; v++) q.Enqueue(v);
        })).ToList();
        // Each consumer takes an equal share, so all of them finish.
        var consumers = Enumerable.Range(0, Workers).Select(_ => new Thread(() =>
        {
            for (int k = 0; k < perProducer; k++) Interlocked.Add(ref total, q.Dequeue());
        })).ToList();
        producers.Concat(consumers).ToList().ForEach(t => t.Start());
        producers.Concat(consumers).ToList().ForEach(t => t.Join());
        return total;
    }

    /// <summary>One producer, one consumer: FIFO order must hold.</summary>
    /// <returns>The items in the order they came out.</returns>
    /// <example><c>BbqDemo.Order()</c> returns [1, 2, 3, 4, 5].</example>
    public static List<int> Order()
    {
        using var q = new BoundedBlockingQueue<int>(2);
        var seen = new List<int>();
        // 5 items through capacity 2: the producer must block at least once.
        var consumer = new Thread(() =>
        {
            for (int k = 0; k < 5; k++) seen.Add(q.Dequeue());
        });
        consumer.Start();
        for (int v = 1; v <= 5; v++) q.Enqueue(v);
        consumer.Join();
        return seen;
    }

    /// <summary>A full queue refuses TryEnqueue after the timeout.</summary>
    /// <returns>"True False 1": first add works, second times out, size is 1.</returns>
    /// <example><c>BbqDemo.Full()</c> returns "True False 1".</example>
    public static string Full()
    {
        using var q = new BoundedBlockingQueue<string>(1);   // 1: full after one add
        bool first = q.TryEnqueue("a", 10);        // 10 ms timeout, plenty for a free slot
        bool second = q.TryEnqueue("b", 10);       // no consumer, so this must time out
        return $"{first} {second} {q.Count}";
    }
}

The classic alternative uses one lock with Monitor.Wait and Monitor.PulseAll. The producer waits while count == capacity. The consumer waits while count == 0. Both pulse after each change. Know both. The semaphore version is easier to get right, and it ports to async with WaitAsync.

EnqueueO(1)DequeueO(1)SpaceO(capacity)
Say this out loud: “One semaphore counts free slots and one counts items. A producer takes a free slot and gives an item. A consumer does the reverse. The lock only guards the ring indexes, so nobody blocks while holding it.”

Edge cases

16. Interview task: print in order

Problem

Three threads call First, Second and Third on one object, in any order. The output must always be “firstsecondthird”. This is LeetCode 1114.

Idea

Two semaphores that start at 0 act as one-shot gates. First prints and opens gate 1. Second waits on gate 1, prints and opens gate 2. Third waits on gate 2.

Solution

public sealed class PrintInOrder : IDisposable
{
    // 0, 1: start closed, open at most once. A Release is the "go" signal.
    private readonly SemaphoreSlim _firstDone = new(0, 1);
    private readonly SemaphoreSlim _secondDone = new(0, 1);

    /// <summary>Runs printFirst with no waiting, then opens gate 1.</summary>
    /// <param name="printFirst">Prints "first".</param>
    /// <example>Called in any order, output is "firstsecondthird".</example>
    public void First(Action printFirst)
    {
        printFirst();
        _firstDone.Release();
    }

    /// <summary>Waits for gate 1, runs printSecond, opens gate 2.</summary>
    /// <param name="printSecond">Prints "second".</param>
    /// <example>Never prints before First has printed.</example>
    public void Second(Action printSecond)
    {
        _firstDone.Wait();
        printSecond();
        _secondDone.Release();
    }

    /// <summary>Waits for gate 2, then runs printThird.</summary>
    /// <param name="printThird">Prints "third".</param>
    /// <example>Always prints last.</example>
    public void Third(Action printThird)
    {
        _secondDone.Wait();
        printThird();
    }

    /// <summary>Frees the gates.</summary>
    /// <example><c>using var p = new PrintInOrder();</c></example>
    public void Dispose() { _firstDone.Dispose(); _secondDone.Dispose(); }

    /// <summary>Starts the three calls in a given thread start order.</summary>
    /// <param name="startOrder">Which call each thread makes, e.g. [3, 2, 1].</param>
    /// <returns>The printed text. Always "firstsecondthird".</returns>
    /// <example><c>Run([3, 1, 2])</c> returns "firstsecondthird".</example>
    public static string Run(int[] startOrder)
    {
        using var p = new PrintInOrder();
        var output = new System.Text.StringBuilder();
        var outGate = new Lock();                  // StringBuilder is not thread-safe
        void Print(string s) { lock (outGate) { output.Append(s); } }
        var threads = startOrder.Select(n => new Thread(() =>
        {
            // 1, 2, 3 name the three methods of the problem.
            switch (n)
            {
                case 1: p.First(() => Print("first")); break;
                case 2: p.Second(() => Print("second")); break;
                default: p.Third(() => Print("third")); break;
            }
        })).ToList();
        threads.ForEach(t => t.Start());
        threads.ForEach(t => t.Join());
        return output.ToString();
    }
}
Say this out loud: “Each method waits for the gate of the one before it, prints, and opens its own gate. Semaphores start at zero, so a Release before the Wait is not lost.”

Variations you should expect

Recap

The things to carry forward

Where this goes next

The last page is a tour of the language itself. A 11 walks every C# version from 7.0 to 14 and links each feature back to the page that covers it.


← A 09 — Metaprogramming A 11 — C# 7 to C# 14 →