Skip to content

Threading and concurrency in .NET in a real industrial desktop system

This topic becomes much easier once you stop thinking about it as “computer science theory” and start thinking about it as multiple things happening at the same time that all want to touch the same application state.

In a production WPF machine-control system, concurrency is not optional. It is already there whether you design for it or not.

A wafer inspection app is a good example because many things are happening at once:

  • the UI is responding to operators
  • the machine is sending status and results
  • images or defect data are streaming in
  • background workflows are running
  • alarms, stop requests, and recipe changes can happen anytime

If you handle that with naive single-threaded thinking, the app becomes unstable very quickly.


PART 1 — BIG PICTURE

What problems threading and concurrency solve in real systems

In real systems, threading and concurrency exist because the application has to do more than one thing without freezing, blocking, or losing responsiveness.

In a WPF industrial app, the system often needs to:

  • keep the UI responsive
  • listen to machine signals
  • process incoming results
  • save data
  • update charts or images
  • react to emergency stop or pause immediately

These things do not happen nicely one after another.

For example, imagine this timeline:

  • operator clicks Start Inspection
  • machine begins scanning
  • status events arrive every 100 ms
  • image tiles stream in
  • defect results are aggregated
  • operator opens another screen
  • operator clicks Stop
  • machine sends one more “scan complete” event after stop was requested

That is concurrency. Not because you wanted fancy multithreading, but because the real world is messy.

Why single-threaded thinking fails

Single-threaded thinking assumes:

  • one action finishes before another starts
  • state changes in a clean order
  • events do not overlap
  • nothing important arrives while you are busy

That is almost never true in machine software.

A common beginner mental model is:

“When the user clicks Start, we start. When they click Stop, we stop.”

In reality it is more like:

“Start was requested, machine start is in progress, result streaming already began, stop was requested during transition, one hardware callback arrived late, the UI still thinks it is running, and the save pipeline is still flushing old data.”

That is why industrial systems are full of bugs like:

  • stop button does nothing for 2 seconds
  • inspection starts twice
  • defect count jumps backward
  • old data appears in a new run
  • machine says idle but UI says running
  • random deadlocks happen once a week

Why concurrency becomes unavoidable in WPF + machine systems

WPF has a UI thread model. That means the UI must stay responsive and most UI-bound objects must be touched from the UI thread.

At the same time, machine systems naturally generate background activity:

  • device SDK callbacks
  • socket messages
  • PLC polling
  • result streaming
  • image processing
  • file/database writes
  • timeout monitoring
  • workflow coordination

So you immediately have at least two worlds:

  • the UI thread, which must stay responsive
  • background threads or thread-pool work, where machine and processing work happens

The challenge is not “how do I use multiple threads.” The challenge is:

“How do I let many things happen concurrently without corrupting state, freezing the UI, or making the machine workflow inconsistent?”


PART 2 — HOW IT ACTUALLY WORKS

Thread vs ThreadPool vs Task

Thread

A Thread is a real operating system thread.

It is the low-level execution unit. It has its own stack and can run independently.

You use explicit Thread much less in modern .NET application code because it is expensive and usually too low-level for business/workflow code.

Typical old-school code:

csharp
var thread = new Thread(() =>
{
    PollMachine();
});
thread.IsBackground = true;
thread.Start();

This works, but now you own the thread lifecycle directly. That is often more control than you actually want.

ThreadPool

The ThreadPool is a shared pool of worker threads managed by .NET.

Instead of creating a brand-new thread for every small piece of work, .NET reuses threads from this pool. This is much cheaper and much more scalable for typical background work.

If ten short tasks need to run, you usually do not want ten dedicated threads. You want the runtime to schedule them on reusable worker threads.

Task

Task is the abstraction you usually work with in modern .NET.

A Task is not the same thing as a thread.

A task represents a unit of work or an asynchronous operation. It may run on a thread-pool thread, or it may represent I/O that is not actively using a thread while waiting.

That distinction matters a lot.

For example:

csharp
await Task.Delay(1000);

This is asynchronous, but it is not “burning” a thread for 1 second. The runtime schedules continuation later.

Another example:

csharp
await File.WriteAllTextAsync(path, content);

This is async I/O. Again, not “one dedicated thread doing nothing while waiting.”

For CPU work:

csharp
await Task.Run(() => ProcessLargeImage(image));

Now you are asking .NET to run CPU work on a thread-pool thread.

So the mental model is:

  • Thread = actual thread, low-level
  • ThreadPool = shared reusable worker threads
  • Task = higher-level representation of work or async operation

Background work vs UI thread

In WPF, the UI runs on a special thread often called the UI thread or dispatcher thread.

That thread handles:

  • button clicks
  • rendering coordination
  • bindings
  • UI events
  • most access to UI objects

If you block that thread, the app feels frozen.

Example of what not to do:

csharp
private void StartButton_Click(object sender, RoutedEventArgs e)
{
    // Bad: blocks UI thread
    MachineController.StartInspection();
    var results = LoadBigDataset();
    RenderResults(results);
}

If StartInspection() blocks or LoadBigDataset() takes 3 seconds, your UI stops responding.

In real machine systems, the UI thread should mostly coordinate and display state, not do heavy work.

Shared memory and why it is dangerous

The hardest part of concurrency is not “running multiple things.” The hardest part is multiple things touching the same data.

Suppose you have:

csharp
private InspectionState _state;
private List<Defect> _defects = new();

Now imagine:

  • machine callback thread adds new defects
  • UI thread reads _defects.Count
  • stop command clears _defects
  • background save task enumerates _defects

That is shared memory. Several execution paths can read or write the same objects at the same time.

This leads to classic problems:

  • one thread sees partially updated state
  • collection modified during enumeration
  • count is wrong
  • old run data leaks into new run
  • state transitions happen out of order

Shared memory is dangerous because code that looks innocent when read line-by-line becomes unsafe when two threads run it interleaved.

Example:

csharp
if (!_isRunning)
{
    _isRunning = true;
    StartMachine();
}

Looks fine. But if two threads execute it at nearly the same time, both may read _isRunning == false before either writes true. Now the machine starts twice.

That is a race condition.


PART 3 — REAL PROBLEMS IN THIS SYSTEM

Let’s use this concrete system:

A WPF desktop app controlling a wafer inspection machine.

This kind of app typically has:

  • operator commands: Start, Stop, Pause, Resume
  • machine status events
  • streaming inspection results
  • defect aggregation
  • recipe loading
  • UI screens showing real-time data

Now let’s look at realistic concurrency bugs.

Race condition: Start and Stop at nearly the same time

Imagine:

  • operator clicks Start
  • app sends start command to machine
  • before machine confirms, operator clicks Stop
  • machine sends “Started” callback slightly later

Without proper coordination, you can end up with:

  • UI says stopped
  • machine says running
  • result pipeline still active
  • buttons in wrong state
  • later callbacks processed under wrong run id

This is a classic transition race.

The bug is not just thread-related. It is really a state machine problem under concurrency.

If your code only uses booleans like _isRunning, _isStopping, _isPaused, it gets messy fast.

You need controlled state transitions.

Concurrent updates to defect data

Suppose machine events stream defect results while the UI is showing:

  • defect count
  • defect grid
  • wafer map
  • histogram
  • image thumbnails

At the same time:

  • one thread adds defects
  • another thread groups defects by type
  • UI reads the latest totals
  • another background task saves them to disk

If all of them touch the same mutable List<Defect>, eventually something breaks:

  • exceptions during enumeration
  • inconsistent totals
  • UI flicker
  • random missing or duplicated results

A subtle version is when no exception happens, but the data is logically wrong.

Those are worse because they survive testing and appear only under load.

Machine events arriving during UI actions

The operator opens a dialog to change recipe settings. While they are editing, the machine sends:

  • stage position update
  • inspection-complete event
  • alarm event
  • new image stream

Now the application has to decide:

  • should those events still be applied?
  • are they for the current run or previous run?
  • can the UI model accept them while user is editing?
  • should recipe changes be blocked during active inspection?

If this is not carefully designed, state becomes inconsistent across screens.

Inconsistent state between threads

This is one of the most common production issues.

Example:

  • background thread updates machine state to Running
  • UI thread still displays Starting
  • another task sees old state and allows recipe edit
  • stop logic checks stale data and sends wrong command

Even if each line of code is “correct,” the overall system is wrong because different threads are observing and mutating state at different times.

In industrial software, consistency matters more than clever concurrency.


PART 4 — HOW WE USE IT IN .NET (PRACTICAL)

The goal is not “use more threads.” The goal is:

  • keep UI responsive
  • serialize critical operations where needed
  • isolate background pipelines
  • minimize shared mutable state
  • marshal UI updates safely

1. Task-based concurrency

In modern .NET, application-level concurrency should usually be task-based.

For example, starting an inspection without freezing the UI:

csharp
private readonly SemaphoreSlim _commandGate = new(1, 1);

public async Task StartInspectionAsync(CancellationToken cancellationToken)
{
    await _commandGate.WaitAsync(cancellationToken);
    try
    {
        if (_state != MachineRunState.Idle)
            return;

        _state = MachineRunState.Starting;
        await _machineApi.StartAsync(cancellationToken);
        _state = MachineRunState.Running;
    }
    catch
    {
        _state = MachineRunState.Faulted;
        throw;
    }
    finally
    {
        _commandGate.Release();
    }
}

Why this is useful:

  • UI thread is not blocked while waiting
  • start/stop commands do not overlap
  • state transitions are controlled
  • only one command path enters critical section at a time

This is much better than random booleans and unsynchronized checks.

2. Producer-consumer pattern

This pattern is extremely useful in machine systems.

One part of the app produces events quickly. Another part consumes and processes them at a controlled pace.

Typical producers:

  • machine callbacks
  • image stream arrivals
  • defect events
  • status messages

Typical consumers:

  • aggregation pipeline
  • database writer
  • UI updater
  • analysis engine

A great modern tool for this is Channel<T>.

Example:

csharp
using System.Threading.Channels;

private readonly Channel<DefectEvent> _defectChannel =
    Channel.CreateUnbounded<DefectEvent>();

public void OnMachineDefectReceived(DefectEvent defectEvent)
{
    // Called from machine callback thread
    _defectChannel.Writer.TryWrite(defectEvent);
}

public async Task RunDefectProcessingLoopAsync(CancellationToken cancellationToken)
{
    await foreach (var defectEvent in _defectChannel.Reader.ReadAllAsync(cancellationToken))
    {
        ProcessDefect(defectEvent);
    }
}

private void ProcessDefect(DefectEvent defectEvent)
{
    // Safe place to centralize mutation
    _defectStore.Add(defectEvent);
}

This is powerful because it turns “many threads mutating shared state” into:

  • many producers
  • one controlled consumer mutating state

That is a huge simplification.

In senior systems design, this is a very common move: do not synchronize chaos; reduce chaos by shaping the flow.

3. Safe UI updates with Dispatcher

In WPF, UI-bound changes should happen on the UI thread.

If background work needs to update the UI, use the dispatcher.

csharp
Application.Current.Dispatcher.Invoke(() =>
{
    StatusText = "Inspection running";
    DefectCount = _defectStore.Count;
});

Usually async is better than synchronous invoke:

csharp
await Application.Current.Dispatcher.InvokeAsync(() =>
{
    StatusText = "Inspection running";
    DefectCount = _defectStore.Count;
});

Why?

  • Invoke blocks until UI executes it
  • InvokeAsync is less disruptive in many cases

In MVVM, you often update view-model properties from UI-thread context.

A practical pattern is to batch UI updates instead of pushing one dispatcher call per event.

Bad:

  • every defect arrival triggers one UI dispatcher update

Better:

  • aggregate results in background
  • update UI every 100 ms or 250 ms

That reduces UI pressure and improves responsiveness.

4. Controlling access to shared state

There are several practical tools.

lock

Use lock for very short synchronous critical sections.

csharp
private readonly object _stateLock = new();
private MachineSnapshot _snapshot = new();

public void UpdateMachinePosition(double x, double y)
{
    lock (_stateLock)
    {
        _snapshot.X = x;
        _snapshot.Y = y;
        _snapshot.LastUpdatedUtc = DateTime.UtcNow;
    }
}

public MachineSnapshot GetSnapshot()
{
    lock (_stateLock)
    {
        return _snapshot.Clone();
    }
}

This is fine when:

  • critical section is short
  • no async inside
  • low complexity

SemaphoreSlim

Use SemaphoreSlim when the protected operation is asynchronous.

csharp
private readonly SemaphoreSlim _inspectionGate = new(1, 1);

public async Task StopInspectionAsync(CancellationToken cancellationToken)
{
    await _inspectionGate.WaitAsync(cancellationToken);
    try
    {
        if (_state != MachineRunState.Running)
            return;

        _state = MachineRunState.Stopping;
        await _machineApi.StopAsync(cancellationToken);
        _state = MachineRunState.Idle;
    }
    finally
    {
        _inspectionGate.Release();
    }
}

Important: do not use lock with await.

Immutable snapshots

Instead of sharing one mutable object, create snapshots.

csharp
public record InspectionSummary(
    int DefectCount,
    int ImageCount,
    string Status,
    DateTime TimestampUtc);

Background pipeline computes a new snapshot and atomically replaces a reference. UI reads snapshot without coordinating every internal field change.

This is often simpler than locking every property.

Concurrent collections

When the problem is mostly thread-safe collection access, .NET has collections like:

  • ConcurrentDictionary<TKey, TValue>
  • ConcurrentQueue<T>
  • ConcurrentBag<T>

Example:

csharp
private readonly ConcurrentDictionary<string, Defect> _defectsById = new();

public void UpsertDefect(Defect defect)
{
    _defectsById[defect.Id] = defect;
}

Useful, but do not overestimate them. They make collection operations thread-safe, but they do not magically make your whole business operation atomic.

This is a common mistake.

For example, this is still not atomic at workflow level:

csharp
if (!_defectsById.ContainsKey(defect.Id))
{
    _defectsById[defect.Id] = defect;
}

Another thread may add the same item in between. You still need correct operation design.


PART 5 — COMMON MISTAKES (VERY REALISTIC)

1. No synchronization at all

This is the classic “it worked in dev” problem.

Example:

csharp
private bool _isRunning;

public void Start()
{
    if (_isRunning) return;
    _isRunning = true;
    _machine.Start();
}

public void Stop()
{
    if (!_isRunning) return;
    _isRunning = false;
    _machine.Stop();
}

Looks simple. Fails under concurrency.

Production consequences:

  • duplicate commands
  • invalid state transitions
  • impossible-to-reproduce bugs
  • machine safety risks
  • corrupted run data

2. Overusing lock

Some teams discover race conditions and then lock everything.

csharp
lock (_bigGlobalLock)
{
    // lots of machine logic
    // logging
    // database write
    // maybe network call
}

Now the app becomes serialized in the worst possible way.

Production consequences:

  • throughput collapses
  • UI lag
  • hidden deadlock risk
  • threads pile up waiting
  • one slow dependency blocks unrelated work

Locks should be narrow and short.

3. Locking the UI thread

A very common WPF mistake is doing heavy synchronized work from UI events.

csharp
private readonly object _lock = new();

private void RefreshButton_Click(object sender, RoutedEventArgs e)
{
    lock (_lock)
    {
        Thread.Sleep(2000);
        ReloadEverything();
    }
}

Now the UI freezes for 2 seconds.

Production consequences:

  • operator thinks app crashed
  • button clicks queue up
  • machine alarms may not display fast enough
  • bad human trust in system

4. Mixing async and lock incorrectly

This is a big one.

You cannot safely do this:

csharp
lock (_lock)
{
    await _machineApi.StartAsync();
}

await inside lock is illegal for a reason. The method may suspend and resume later, and lock is strictly synchronous.

The bad workaround some teams do is:

csharp
lock (_lock)
{
    _machineApi.StartAsync().GetAwaiter().GetResult();
}

Now you are back to blocking, and in UI or synchronization-context environments this can cause deadlocks or responsiveness issues.

Use SemaphoreSlim or redesign flow.

5. Ignoring race conditions because “they are rare”

This is how production incidents are born.

Concurrency bugs are often:

  • low frequency
  • load dependent
  • timing dependent
  • hard to reproduce locally

Teams sometimes dismiss them because they happen “only once in a while.”

In industrial systems, once in a while is enough to cause:

  • lost inspection results
  • incorrect defect count
  • bad stop behavior
  • operator mistrust
  • expensive debugging effort

Rare is not harmless.


PART 6 — PERFORMANCE & TRADE-OFFS

Contention vs throughput

Concurrency is not automatically faster.

If many threads are constantly fighting over the same lock, you have contention.

High contention means:

  • lots of waiting
  • context switching
  • lower throughput
  • unpredictable latency

A good concurrency design reduces contention by:

  • minimizing shared mutable state
  • separating pipelines
  • using message passing
  • keeping critical sections short

Cost of locking

A lock is not “free,” but the bigger cost is often not the lock instruction itself. The real cost comes from:

  • threads waiting
  • blocked progress
  • poor scalability
  • long critical sections
  • nested locks causing complexity

So the question is not “are locks slow?” The real question is:

“How often do threads compete for this lock, and how much work happens while holding it?”

When to use concurrent collections

Use them when you have true concurrent access patterns and want thread-safe collection operations.

Good fit:

  • multiple producers enqueue work
  • shared cache by key
  • event buffering
  • background pipeline handoff

Bad fit:

  • trying to fix a broken workflow design
  • assuming collection safety means business safety
  • building complicated multi-step logic without atomicity

When to avoid parallelism

A lot of engineers over-apply parallelism because it sounds performant.

But in machine-control software, many workflows should remain intentionally serialized:

  • command sequencing to machine
  • state transitions
  • stop/start/pause logic
  • recipe activation
  • hardware resource access

If the machine itself expects strict order, parallelizing command logic often creates bugs, not speed.

Also, UI-heavy and I/O-heavy systems do not always benefit much from extra parallel CPU work.

Use parallelism when:

  • work is independent
  • CPU-bound
  • partitionable
  • merge cost is acceptable

Avoid it when:

  • order matters
  • shared state is central
  • coordination cost is high
  • correctness matters more than raw speed

Industrial systems usually care more about predictable behavior than theoretical maximum throughput.


PART 7 — SENIOR ENGINEER THINKING

How experienced engineers design concurrency safely

Senior engineers do not start with “where should I put locks?”

They start with:

  • what operations can happen concurrently
  • what state is shared
  • what operations must be serialized
  • what ordering guarantees are required
  • where backpressure is needed
  • what can be made immutable or snapshot-based

That is a much better mindset.

A strong design often has these characteristics:

  • UI thread only handles UI concerns
  • machine commands flow through a controlled command path
  • machine events enter through a queue/channel
  • processing pipelines are isolated
  • shared state is minimized
  • state transitions are explicit

How to reduce shared state

This is one of the biggest maturity steps.

Instead of letting many threads mutate the same domain objects, try to use:

  • immutable messages
  • channels/queues
  • single-writer ownership
  • snapshots for reads
  • explicit state machines

A beautiful pattern is:

  • many things can produce events
  • only one component owns mutation of a certain state

For example:

  • machine callback threads do not directly mutate view model and defect list and database state
  • they only publish MachineEvent
  • one coordinator processes events and updates authoritative state

That is much easier to reason about.

How to reason about race conditions

A good way is to ask:

  1. What shared state exists?
  2. Who can read it?
  3. Who can write it?
  4. Can two operations overlap in time?
  5. What happens if they interleave in the worst possible order?

That last question is the important one.

Do not test only happy ordering. Test hostile ordering.

For example:

  • Stop arrives before Start confirmation
  • results arrive after stop requested
  • UI closes while stream still active
  • run B starts before run A cleanup is fully finished
  • reconnect happens during save

Senior engineers think in these edge timelines.

How to debug concurrency issues

Concurrency bugs are hard because traditional debugging changes timing and may hide the bug.

Practical debugging methods include:

  • detailed structured logs with timestamps and run ids
  • correlation ids for each inspection run
  • explicit logging of state transitions
  • thread/task-aware logging when useful
  • event sequence tracing
  • reproducing under load, not just single-step local debugging

For example, log this kind of thing:

  • Run 42: Start requested
  • Run 42: Machine start command sent
  • Run 42: Stop requested
  • Run 42: Machine reported Started
  • Run 42: Transition rejected because state=Stopping

This kind of logging is much more useful than random debug messages.

Also, design for observability:

  • include current state in logs
  • include source of event
  • include run/session id
  • include timestamps with enough precision

In production concurrency bugs, the event timeline is often the truth.


Practical design advice for this kind of WPF machine system

If I were designing this system, I would usually aim for something like this:

1. Explicit workflow/state model

Do not manage machine lifecycle with scattered booleans.

Prefer a real state model:

  • Idle
  • Starting
  • Running
  • Pausing
  • Paused
  • Stopping
  • Faulted

And define legal transitions clearly.

2. Single command gate for machine control

Do not let Start, Stop, Pause, Resume overlap arbitrarily.

Serialize them through one control path.

3. Event ingestion pipeline

Machine callbacks should not directly touch everything. They should enter a channel or queue.

4. Separate authoritative state from UI state

UI state should be a projection of authoritative machine/application state, not the source of truth.

5. Batch UI updates

Real-time does not mean “every event instantly redraws the screen.” Often 5 to 10 UI updates per second is more than enough for operators.

6. Prefer ownership over locking

The best shared-state bug is the one you avoided by design.


A realistic combined example

Here is a small example combining several ideas:

csharp
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows;

public enum MachineRunState
{
    Idle,
    Starting,
    Running,
    Stopping,
    Faulted
}

public record DefectEvent(string Id, string Type, double X, double Y);

public sealed class InspectionCoordinator
{
    private readonly SemaphoreSlim _commandGate = new(1, 1);
    private readonly Channel<DefectEvent> _defectChannel = Channel.CreateUnbounded<DefectEvent>();

    private MachineRunState _state = MachineRunState.Idle;
    private int _defectCount;

    public MachineRunState State => _state;
    public int DefectCount => _defectCount;

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        await _commandGate.WaitAsync(cancellationToken);
        try
        {
            if (_state != MachineRunState.Idle)
                return;

            _state = MachineRunState.Starting;
            await UpdateUiAsync();

            await SimulateMachineStartAsync(cancellationToken);

            _state = MachineRunState.Running;
            await UpdateUiAsync();
        }
        catch
        {
            _state = MachineRunState.Faulted;
            await UpdateUiAsync();
            throw;
        }
        finally
        {
            _commandGate.Release();
        }
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        await _commandGate.WaitAsync(cancellationToken);
        try
        {
            if (_state != MachineRunState.Running)
                return;

            _state = MachineRunState.Stopping;
            await UpdateUiAsync();

            await SimulateMachineStopAsync(cancellationToken);

            _state = MachineRunState.Idle;
            await UpdateUiAsync();
        }
        finally
        {
            _commandGate.Release();
        }
    }

    public bool OnDefectFromMachine(DefectEvent defectEvent)
    {
        // Safe ingestion from machine callback thread
        return _defectChannel.Writer.TryWrite(defectEvent);
    }

    public async Task RunDefectLoopAsync(CancellationToken cancellationToken)
    {
        await foreach (var defectEvent in _defectChannel.Reader.ReadAllAsync(cancellationToken))
        {
            if (_state != MachineRunState.Running)
                continue;

            Interlocked.Increment(ref _defectCount);

            // In a real system you may batch this instead of per-event UI update
            await UpdateUiAsync();
        }
    }

    private static Task SimulateMachineStartAsync(CancellationToken ct) =>
        Task.Delay(500, ct);

    private static Task SimulateMachineStopAsync(CancellationToken ct) =>
        Task.Delay(500, ct);

    private Task UpdateUiAsync()
    {
        return Application.Current.Dispatcher.InvokeAsync(() =>
        {
            // In a real app:
            // - update ViewModel properties
            // - publish UI notifications
            // - refresh summary widgets
        }).Task;
    }
}

This example is small, but the ideas are production-relevant:

  • Start/Stop are serialized with SemaphoreSlim
  • machine events are ingested via Channel
  • defect processing is centralized
  • UI updates are marshaled through dispatcher
  • state is explicit

That is already much safer than letting everything update everything directly.


Final mental model

The best way to remember threading and concurrency in .NET is this:

Concurrency is not mainly about speed. In real desktop machine systems, it is mainly about coordination.

You are coordinating:

  • the UI
  • the machine
  • background pipelines
  • streaming data
  • state transitions
  • operator actions

The core mistakes come from letting too many threads directly mutate the same state.

The core improvements come from:

  • explicit state transitions
  • serialized critical operations
  • queue-based event handling
  • minimal shared mutable state
  • safe UI marshaling
  • good observability

That is how experienced engineers make these systems stable.

Next, I can do the same style deep dive for deadlocks, lock/Monitor, SemaphoreSlim, Interlocked, volatile, and concurrent collections, with a “when to use which” guide for interviews.

Docs-first project memory for AI-assisted implementation.