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:
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:
await Task.Delay(1000);This is asynchronous, but it is not “burning” a thread for 1 second. The runtime schedules continuation later.
Another example:
await File.WriteAllTextAsync(path, content);This is async I/O. Again, not “one dedicated thread doing nothing while waiting.”
For CPU work:
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-levelThreadPool= shared reusable worker threadsTask= 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:
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:
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:
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:
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:
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.
Application.Current.Dispatcher.Invoke(() =>
{
StatusText = "Inspection running";
DefectCount = _defectStore.Count;
});Usually async is better than synchronous invoke:
await Application.Current.Dispatcher.InvokeAsync(() =>
{
StatusText = "Inspection running";
DefectCount = _defectStore.Count;
});Why?
Invokeblocks until UI executes itInvokeAsyncis 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.
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.
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.
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:
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:
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:
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.
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.
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:
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:
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:
- What shared state exists?
- Who can read it?
- Who can write it?
- Can two operations overlap in time?
- 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 requestedRun 42: Machine start command sentRun 42: Stop requestedRun 42: Machine reported StartedRun 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:
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.