Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads

· · Windows, Multithreading, C#, .NET, Business Applications, Bug Investigation, Design

“Processing was slow, so we spun up threads to parallelise it, and now the aggregated totals are occasionally off.” “We added a background task, and now the app freezes once a month.” “They tell us it doesn’t reproduce under the debugger, but it definitely happens at the customer site.” — What makes multithreaded programming frightening is that it looks correct the moment you finish writing it. Race-condition bugs are timing-dependent: they slip past testing and only show their face in production.

At the same time, now that multicore hardware is the norm, there are certainly situations even in business applications where multithreading cannot be avoided — requirements such as “run heavy processing without freezing the UI” or “process multiple devices or files concurrently”. What matters is deciding on design principles before you add more threads. Multithreading bugs are not something you stamp out through debugging; they are something you design so there is no room for them to creep in in the first place.

This article is the .NET edition of the Practical Multithreading series. Aimed at developers building business applications on Windows who find they need to add multithreading, it lays out design principles that hold regardless of language or OS, together with the concrete tooling available in C#/.NET, based on primary sources as of August 2026. The principles themselves do not change on Linux or in C++. If you are writing native code, see the companion articles that map the same principles onto each language’s tools — the “C++ Edition” and the “C Edition” — and if you are writing Java, see the “Java Edition”.

1. The Bottom Line First

  • The first best practice is not to create threads yourself. Ride on higher-level APIs — Task, the thread pool, the Parallel class — instead of new Thread, and leave the management of thread counts to the runtime.12
  • The first thing to cut when parallelising is “shared mutable state”. Places where multiple threads write to the same variable are where races originate; before reaching for locks to protect them, reduce the sharing itself through data partitioning, immutability, and hand-off.3
  • Give locking discipline. Decide, one to one, “which lock protects which data”, and make the lock object a dedicated instance not visible from outside. lock(this) and lock(typeof(X)) are forbidden. From .NET 9 onward, use the dedicated System.Threading.Lock type.4
  • Route data hand-off between threads through a queue. A producer/consumer arrangement built on System.Threading.Channels or a concurrent collection is simpler to design than scattering locks around, and it gives you a clear boundary as well.56
  • Design how it stops, first. Cooperative cancellation via CancellationToken is the only correct answer for stopping; Thread.Abort throws a runtime exception on .NET (the Core-based line).78
  • The UI belongs exclusively to the UI thread. Neither WinForms controls nor WPF elements may be touched from any thread other than the one that created them. From another thread, make the request via Control.Invoke / Dispatcher.910
  • “Parallel means faster” does not always hold. A loop whose per-iteration work is small can end up slower because of parallelisation overhead. Always measure before adopting it.3

2. Why Multithreading Is Hard — Race Conditions and Deadlocks

Boiled down, multithreading introduces two kinds of problem.4

A race condition is a bug where the result changes depending on the order in which multiple threads reach a particular piece of code. The classic example is incrementing a shared counter: the single line count++ actually breaks down into three steps — “read → add → write back”. If two threads execute these three steps at the same time, one thread’s write-back overwrites the other’s addition, and the increment is lost. The result changes on every run, and which result you get is unpredictable.4

Thread BShared variable countThread AThread BShared variable countThread Acount = 10count = 11 despite two incrementsThread A's increment was lostRead (10)Read (10)Add locally (11)Add locally (11)Write back (11)Write back (11)

Figure 1: A textbook race condition in which an increment on a shared counter is lost. If another thread interleaves during the three steps of count++, whichever thread writes back last overwrites the other

A deadlock is a state in which two threads each wait on a lock the other is holding, and neither can proceed. Thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1 — that alone is enough for both to stop forever.4

waiting for lock 2 to be releasedwaiting for lock 1 to be releasedThread Aholding lock 1Thread Bholding lock 2

Figure 2: A circular wait forming a deadlock. The moment the waiting arrows form a ring, every thread inside that ring stops forever

What makes both awkward is that they are timing-dependent. It is entirely normal for an interleaving (a particular combination of execution order) that only comes up once in tens of thousands of runs on a development machine to happen every day on a customer’s machine, where the core count and the timing are both different. “It doesn’t reproduce with the debugger attached” and “it disappeared when I added logging” happen because observation itself changes the timing — this is textbook behaviour for a race bug.

That is exactly why every principle from here on points in a single direction: before “synchronising correctly”, reduce the places that need synchronisation — this is the backbone of multithreaded design.

3. Principle 1: Don’t Create Threads Yourself

3.1. Ride on Task and the Thread Pool

Creating a thread directly with new Thread(...) is, in today’s .NET, an exceptional last resort. Since .NET Framework 4, the recommended means for multithreaded and parallel code has been the TPL (Task Parallel Library) — that is, the family of APIs centred on Task. The TPL dynamically adjusts the degree of parallelism to match the available processors, and takes on all the low-level chores of dividing up the work, scheduling it onto the thread pool, handling cancellation, and managing state.1

The thread pool is infrastructure that .NET itself uses extensively — for running Tasks, completing asynchronous I/O, timer callbacks, and more — and as long as you throw it short pieces of work, developers do not need to manage the lifecycle of threads themselves.2

// Run a CPU-heavy calculation in the background
var result = await Task.Run(() => HeavyCalculation(input));

// Run several independent operations concurrently and wait for all of them (when the count is small)
// * This shape assumes ProcessAsync is an I/O-bound asynchronous method.
//   WhenAll only "waits on Tasks that are already running", so if you want to run
//   CPU-bound work concurrently, wrap each piece in Task.Run(() => Calc(x)) to put it on the thread pool
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));

// If there are many items, cap the degree of concurrency
await Parallel.ForEachAsync(items,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
    async (x, ct) => await ProcessAsync(x, ct));
// Two points matter here: wire the caller's token into ParallelOptions
// (forget this and the ct inside the body is always None), and pass that same ct
// into the body as well (don't discard it)

There is one caveat. Task.WhenAll(items.Select(...)) starts processing every element at once, the moment it is enumerated. That is fine for a fixed handful to a few dozen items, but use it on a large collection and you will exhaust sockets, DB connections, and memory all at once. For work whose volume you cannot predict, either cap the degree of concurrency as with Parallel.ForEachAsync above, or control the flow with a bounded channel, described later.

Creating your own thread is justified almost only when a property of the thread itself is the requirement — things like “it needs its own dedicated message loop”, “it needs to specify a threading apartment (STA)”, or “it needs to keep running for the entire lifetime of the app”.

3.2. For Data Parallelism, Use Parallel.For / ForEach

For data parallelism — “apply the same processing to every element of a collection to speed the whole thing up” — use Parallel.For / Parallel.ForEach rather than dividing the loop across threads yourself. The TPL handles splitting the data source (partitioning) and rebalancing the load, and for a basic loop you don’t even need locks.11

There are, however, two pitfalls the official documentation spells out explicitly.3

  • Don’t assume parallel is always faster. A loop with few iterations, or one whose per-iteration work is light, can end up slower because the overhead of parallelising outweighs the body of the work. Performance depends on many factors, so always measure and decide from that.
  • Don’t have iterations wait on each other. There is no guarantee that each iteration of Parallel.For actually runs in parallel. Code where one iteration waits on an event set by another iteration can deadlock, depending on scheduling.

3.3. Route “Waiting” Work to Asynchronous I/O, Not Threads

Work that is mostly waiting on I/O — files, the network, a database — is not a candidate for adding threads. Tying up an entire thread while it waits is simply wasteful; asynchronous I/O via async/await consumes no thread while it waits. This distinction — parallelise CPU-bound work, make I/O-bound work asynchronous — is the first line you should draw at the entrance to multithreaded design.

Mostly waiting on I/Ofiles, network, DBCPU-bound computationApply the same processingto every element of a collectionAn independent chunk ofbackground processingA message loop, STA requirement, etc.a property of the thread itself is the requirementThere is work you want to run concurrentlyWhat dominates the work?Asynchronous I/O with async/awaitdon't add threadsWhat shape is the work?Parallel.For / ForEachTask.Run / Task.WhenAllnew Threadan exceptional last resort

Figure 3: The branches to work through before “spinning up a thread”. Most business processing falls into one of the top three exits, and reaching new Thread is the exceptional case

Practical decision-making for async/await is covered in “A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait”, and how the thread pool and asynchronous I/O connect underneath it all is covered in detail in “The Depths of Windows I/O (Part 3) — I/O Completion Ports (IOCP) and the .NET Thread Pool”.

4. Principle 2: Minimise Shared Mutable State

A race only happens when “multiple threads” and “shared mutable data” are both present. The number of threads is dictated by requirements, so what design can cut is the sharing. There are three means.

4.1. Partition — Have Each Thread Touch Only Its Own Data

The simplest and most powerful approach is to split the data up per thread. For aggregation in a parallel loop, rather than writing to a shared total variable on every iteration, use the overload of Parallel.For that takes thread-local state so each thread builds its own subtotal locally, and merge them just once at the end. Writes to shared state drop from “every iteration” to “once per thread”, and both the synchronisation cost and the window for races shrink by orders of magnitude.3

long total = 0;
Parallel.For(0, items.Length,
    () => 0L,                                     // Thread-local initial value
    (i, state, local) => local + Weigh(items[i]),  // Each iteration adds only to its own local
    local => Interlocked.Add(ref total, local));   // Merge happens once per thread
Data array - the input to processThread 1processes its share andadds only to its own subtotalThread 2processes its share andadds only to its own subtotalThread 3processes its share andadds only to its own subtotalMerge: Interlocked.Add reflectseach thread's subtotal into the total, once per thread

Figure 4: Thread-local aggregation. Because each thread touches only its own data while processing, there is no room for a race, and writes to shared state happen only once per thread, at merge time

4.2. Make It Immutable — What You Don’t Rewrite, You May Freely Share

Data that is only ever read is safe to read simultaneously from any number of threads. Configuration values, master data, calculation inputs, and the like can be shared freely without synchronisation if you make them immutable — never rewritten after construction. In C#, record types and init properties support this design. Simply deciding that “when a change is needed, build a new instance and swap it in rather than rewriting the existing one” removes one more piece of mutable state you need to protect.

But “looks read-only” and “is immutable” are different things. A read-only interface such as IReadOnlyList<T> only means “you cannot rewrite it through that interface” — it does nothing to stop the underlying List<T> being rewritten through a different reference. The guarantee record / init gives is shallow too: it does not protect the objects a property points to. For data you genuinely want to share safely between threads, either use an immutable collection from System.Collections.Immutable, such as ImmutableArray<T>, or pass a copy at the point you share it, cutting off the rewrite path entirely. In that case, the condition is that the element type T itself must also be immutable. An immutable collection only protects the “ordering” — references to mutable element objects are still shared as-is, so if the contents of an element can be rewritten through some other path, the race remains. Either make the object graph immutable all the way to its leaves, or pass a deep copy.

4.3. Hand It Off — Send Through a Queue Instead of Sharing

Even so, you still need to move data between threads. When you do, rather than “both sides touching a shared variable”, use a producer/consumer arrangement where one side writes and the other reads, with a queue in between.

The first choice on .NET is System.Threading.Channels. It is a FIFO into which the producer writes data asynchronously and the consumer reads it out asynchronously, and the channel itself manages all the synchronisation chores.5

var channel = Channel.CreateBounded<WorkItem>(100); // Capacity 100 - applies backpressure

// Producer side
await channel.Writer.WriteAsync(item, ct); // If full, waits until space opens up
// …once every producer has finished writing:
channel.Writer.Complete();   // Declares "no more coming". Without this the reader's loop can never end

// Consumer side
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
    Process(item);
}
If full, makes writers wait - backpressureIf empty, makes readers waitProducer 1WriteAsyncBounded channel - capacity 100FIFO queuesynchronisation managed by the channelProducer 2WriteAsyncConsumer 1ReadAllAsyncConsumer 2ReadAllAsync

Figure 5: A producer/consumer arrangement built around a channel. Neither side touches a shared variable directly; both the waiting and the capacity control are left to the channel

What matters in practice is choosing a channel with a bounded capacity. The default behaviour on hitting the limit is “the writer waits for space”, which becomes natural backpressure. Use an unbounded queue in a setup where production outruns consumption, and you get a time bomb that keeps running while memory keeps growing.5

In the synchronous world, the role a bounded channel plays is filled by BlockingCollection<T> with a capacity specified. It combines blocking with capacity control: the capacity limit stops the producer from getting too far ahead of the consumer, and it blocks and makes the consumer wait when it is empty.12 ConcurrentQueue<T> / ConcurrentStack<T>, on the other hand, are fast collections that achieve thread safety using only Interlocked operations rather than locks6, but they are plain thread-safe queues with neither a capacity limit nor a “wait when empty” mechanism. Think of them as a component, not the star of your hand-off design. Note too that BlockingCollection<T> was not designed with asynchronous access in mind, so if you are pairing it with async/await, choose Channel<T> instead.12

One caveat: be wary of the assumption that “switching a dictionary to ConcurrentDictionary makes it thread-safe”. Even when individual operations are thread-safe, compound operations such as “check whether it exists, then add” still race (use a method built for compound operations, such as GetOrAdd). And GetOrAdd itself comes with a caveat of its own: while the value that ends up stored is guaranteed to be a single one, the factory function that builds the value can be called more than once under contention. Put a side effect into the factory — opening a connection, creating a file, and so on — and the duplicate execution leaks it, so either make the factory free of side effects, or, for initialisation you need to happen exactly once, store a Lazy<T> as the value instead. Swapping the collection type is no substitute for reducing shared mutable state.

5. Principle 3: Give Locking Discipline

Even after reducing shared mutable state, you often cannot get it to zero. Use exclusive control (locking) for what sharing remains, but a lock is not a tool for “wrapping lock around wherever looks suspicious, just in case”. There are four points of discipline.

5.1. Decide “What You’re Protecting”, and Lock With a Dedicated Object

Think of the unit of locking as “data”, not “a stretch of code”. Assign one lock object to each set of mutable data you want to protect, and take that same lock at every place that touches that data — a race bug, in practice, is what you get when this correspondence table has broken down.

Make the lock object a dedicated instance that is not exposed outside. lock(this) shares the lock with any external code that can reference your instance, and lock(typeof(X)) shares it with the entire application domain — either way, that is fertile ground for deadlocks. From .NET 9 / C# 13 onward, using an instance of the dedicated System.Threading.Lock type as the lock object is recommended.4

public class OrderBook
{
    private readonly Lock _gate = new();          // .NET 9+ (readonly object before that)
    private readonly List<Order> _orders = [];    // The data that _gate protects

    public void Add(Order order)
    {
        lock (_gate) { _orders.Add(order); }
    }
}

The C# lock statement guarantees the lock is released even when an exception occurs. How it expands depends on the type of the lock object: for an ordinary object it becomes a call to Monitor.Exit in a finally block, and for the Lock type it becomes a call to EnterScope() and its disposal.413 In other words, a Lock-typed field is a different mechanism from Monitor, and if only part of your code hand-writes Monitor.Enter(_gate), mutual exclusion with lock (_gate) does not hold. With either type, it is safer to stop hand-writing Monitor.Enter / Exit and standardise entirely on the lock syntax.4

5.2. Don’t Do Anything Slow or External While Holding a Lock

The shorter you hold a lock, the better; the only thing you should do while holding it is read and write the data it protects. Writing code that performs I/O while holding a lock, or that calls out to external code via an event or callback, does not just extend how long you hold it — it opens a path where the code you called tries to take a different lock and deadlocks. Prepare outside the lock, and do only the swap-in inside it — that is the basic shape.

Note that you cannot await inside a lock (it is a compile error). This is a protection, not merely a restriction: Monitor has thread affinity — the thread that took the lock must be the one to release it — which is incompatible with asynchronous code where the thread executing can change across an await. For exclusion in asynchronous code, use SemaphoreSlim with an initial count of 1.14

private readonly SemaphoreSlim _asyncGate = new(1, 1);

public async Task SaveAsync(Data data, CancellationToken ct)
{
    await _asyncGate.WaitAsync(ct);
    try   { await WriteToFileAsync(data, ct); }
    finally { _asyncGate.Release(); }
}

5.3. Always Take Multiple Locks in the Same Order

When you have two or more locks, the classic deadlock pattern is that the acquisition order flips from one thread to another. The fix is simple: make it a rule that every thread takes the locks in the same order. Where you cannot guarantee the order, use the timeout overload of Monitor.TryEnter, and if you can’t get the lock, back off and retry (or log the anomaly) — that turns a hang that would last forever into a detectable failure.4

5.4. Use Interlocked for Simple Updates, ReaderWriterLockSlim When Reads Dominate

For atomic updates of a single variable — incrementing or decrementing a counter, swapping a flag — the Interlocked class (Increment / Add / CompareExchange) is faster than lock. With no contention, it can cost as little as a single CPU instruction prefix.4 Conversely, that is as far as Interlocked goes; it cannot keep multiple variables consistent together. A hand-rolled lock-free structure combined with volatile is a tool for experts requiring a deep understanding of the memory model, and it is not something you should be writing in a business application.

For shared data where “reads are frequent but writes are rare”, there is also the option of ReaderWriterLockSlim, which excludes only writes while letting reads through concurrently.13

6. Principle 4: Design How It Stops, First

The first question to ask in a multithreading design review is “how does this stop”. You can write code that starts running without thinking about it, but code that stops safely does not come into being unless you design it.

6.1. Cooperative Cancellation (CancellationToken) Is the Only Correct Answer

.NET’s stopping model is unified around cooperative cancellation. The side that wants to stop something creates a CancellationTokenSource and passes its Token to each piece of processing. When it wants to stop, it calls Cancel(). The processing side watches the token and, at a convenient point of its own choosing, cleans up and finishes — because it is cooperation rather than force, the processing side can end while keeping its state consistent throughout.7

private CancellationTokenSource? _cts;
private Task? _worker;

public void Start()
{
    if (_worker is { IsCompleted: false })    // Reject a double Start while it's still running
        throw new InvalidOperationException("The worker is already running.");
    if (_worker is { IsFaulted: true })       // Don't rebuild on top of a swallowed previous failure
        throw new InvalidOperationException("The previous worker has failed.", _worker.Exception);
    _cts = new CancellationTokenSource();
    var token = _cts.Token;   // Capture into a local first, so it can't race with a re-Start after stopping
    _worker = Task.Run(() => WorkLoop(token), token);
}

private void WorkLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)   // Watch it by polling
    {
        ProcessNextItem(ct);              // Pass ct to any blocking call for immediate interruption
    }
}

public async Task StopAsync()
{
    var cts = _cts;          // Pin these to locals so that even if the fields get
    var worker = _worker;    // replaced while we're waiting, we don't stop the wrong target
    if (cts is null || worker is null) return;

    Exception? cancelFailure = null;
    try { cts.Cancel(); }    // A callback registered on the token can throw
    catch (Exception ex) { cancelFailure = ex; }   // Hold onto it and report it after joining

    try
    {
        try { await worker; }    // Always join regardless of whether Cancel succeeded, and observe any mid-flight failure
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        { }                      // Only treat cancellation we ourselves requested as "normal"
        catch (Exception ex) when (cancelFailure is not null)
        {
            throw new AggregateException(cancelFailure, ex);  // Lose neither failure
        }
    }
    finally
    {
        cts.Dispose();           // Dispose of the source once joined (releases OS resources such as its WaitHandle).
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // Don't let a subsequent StopAsync use an already-disposed source
            _worker = null;
        }
    }
    if (cancelFailure is not null)
        throw new AggregateException(cancelFailure);
}

Note that this Start / StopAsync is a minimal construction that assumes it is called sequentially from a single thread (the UI thread, for example). If multiple threads might operate the lifecycle simultaneously, serialise Start / StopAsync themselves with something like SemaphoreSlim — it would defeat the purpose if the lifecycle management operations themselves raced, before you even get to protecting the worker.

This small sample also has several tweaks built in that pay off in practice. First, Start rejects a double call while it is running. Unconditionally overwriting _cts and _worker would lose the reference to the previous worker, leaving a “stray thread” running alongside — one you can neither stop nor join. It is standard for a lifecycle API (Start/Stop) to enforce “only one at a time” on itself. Beyond that, three more points. First, the stop API waits for completion. Cancel() only “requests” cancellation; the moment it returns, the worker may still be partway through ProcessNextItem. Make it a Stop() that only requests and then returns, and you create a new race where the caller starts its own cleanup while the worker is still running. Second, hold onto the Task rather than discarding it. Throw it away with _ = Task.Run(...) and no one will notice if the worker dies from an exception. Third, capture the token into a local variable before passing it, rather than referencing _cts.Token inside the lambda. Referencing it inside the lambda means it is evaluated at execution time, and if a re-Start happens right after stopping, you get a mix-up where the old worker grabs the new token. Passing that same token as the second argument to Task.Run as well means that when the processing side ends via ThrowIfCancellationRequested or the OperationCanceledException from a cancellation-aware API, the Task is classified as “Cancelled” rather than “Faulted” (in this example, exiting normally through the loop condition, as shown, still counts as completing successfully). One more thing: the catch in StopAsync uses a when filter to trap only cancellation originating from its own token. Unconditionally swallowing OperationCanceledException would make even a genuine failure thrown by a different token inside the processing — a per-element timeout, say — look like “it stopped, so it’s fine”. Note that this identification by token match breaks down if WorkLoop internally uses a linked token (the link composition from Section 6.1), because the exception that comes flying out carries the linked-side token. In that configuration, choose explicitly, as a design decision, either to call ct.ThrowIfCancellationRequested() at the exit of WorkLoop to “translate” it back to the outer token before leaving, or to loosen the filter to when (cts.IsCancellationRequested) and accept “cancellation while a stop was requested counts as normal”.

Calls Cancel() onceHands over the TokenHands over the TokenHands over the TokenChecks IsCancellationRequestedcleans up and ends on its ownThrowIfCancellationRequestedInterrupts immediately even mid-waitThe side stopping itCancellationTokenSourceWorker process 1Worker process 2A library'scancellation-aware APICompletes normallyOperationCanceledExceptiontreated as cancellation completingCancellation completes

Figure 6: The shape of cooperative cancellation. The side stopping it only calls Cancel(); each piece of processing decides for itself “when and how” it ends. That’s why it can stop while keeping its state consistent

There is a set convention on the library side too. A cancellable operation should provide a public method that accepts a CancellationToken, and inside a computation loop, either check IsCancellationRequested periodically or call ThrowIfCancellationRequested(). The latter throws OperationCanceledException, which Task treats as “cancellation completing” rather than “failure”. When you want to stop on both an externally supplied token and an internal concern (a timeout, say), compose them with a linked token.7

6.2. Treat Thread.Abort as Nonexistent

Thread.Abort — “kill from the outside a thread that won’t listen” — simply throws PlatformNotSupportedException on .NET Core / .NET 5 and later; it can no longer be used at all. Throwing an exception into a thread without knowing where it is currently executing risks interrupting resource cleanup and corrupting state. If you need to forcibly terminate third-party code that does not respond to cooperative cancellation (or that you cannot rewrite to respond), the official guidance is to run it in a separate process and stop it with Process.Kill.8

6.3. When Waiting, Use a Wait Handle, Not Polling

Writing “wait in a Sleep(100) loop until a flag is set” wastes both CPU and responsiveness. There are synchronisation primitives such as ManualResetEventSlim and SemaphoreSlim for signalling between threads, which correctly put a thread to sleep until it is signalled.13 The choice between timer precision and event waits on Windows is covered in detail in “Why You Should Prefer Event Waits over Sleep(1) on Windows”.

7. The Special Case of the UI Thread — the Law of Windows Desktop Apps

Windows desktop apps have one more strong constraint on top of the general principles. The law that only the thread that created the UI (the UI thread) may touch it.

WinForms controls are not thread-safe; operating on them from multiple threads drives a control into an inconsistent state and causes races, deadlocks, and freezes. Windows requires an app to have one dedicated thread that receives system messages, and creation and manipulation of the UI must be concentrated on that thread.9 WPF has exactly the same structure: only the UI thread can change UI elements.10

When you want to update the UI from a different thread, don’t touch it directly — turn it into “a request to the UI thread”.

Requests via Control.Invoke /Dispatcher.InvokeAsyncTouching a control directlyWindowsmouse, keyboard, repaintThe UI thread'smessage queueBackground threadheavy processing, communicationUI threadthe only thread that may touch controlsForbiddencauses races, deadlocks, freezes

Figure 7: Convert a UI update into a “request”. The background thread’s job stops at getting its work placed on the message queue — it is always the UI thread itself that touches the control

Framework Means of Requesting
WinForms Control.Invoke (synchronous) / Control.BeginInvoke (asynchronous) / from .NET 9 onward, Control.InvokeAsync9
WPF Dispatcher.Invoke (synchronous) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (asynchronous)10

Of these, the synchronous forms (Control.Invoke / Dispatcher.Invoke) need care. If the UI thread is synchronously waiting on that worker to finish, and the worker calls Invoke, you get a deadlock where each waits on the other (exactly the circular wait from Section 2). Make the asynchronous forms (BeginInvoke / InvokeAsync) your default for notifications and progress reports from a background thread, and confine the synchronous forms to situations where you can be certain the UI thread is not waiting on you.

In practice there is a still better answer. Write processing that started on the UI thread using async/await, and await captures the UI thread’s SynchronizationContext and automatically resumes the continuation on the UI thread, which greatly reduces the number of places you need to hand-write Invoke at all. This is not, however, an unconditional property. Code entered from a background callback, or a continuation after a ConfigureAwait(false), does not return to the UI thread, so explicit dispatch is still needed if you touch the UI along that path. Settling into the shape of “heavy work goes to Task.Run or asynchronous I/O, and reflecting the result on screen happens in the continuation after await” is the basic form of a modern Windows app. The relationship between the UI thread and async/await is summarised in one diagram in “WPF/WinForms async and the UI Thread on One Sheet”.

Also, when COM is involved — Office integration, legacy components, and so on — another layer is added: COM’s own threading model (STA/MTA). Incidents like “we created the COM object on the UI thread but called it from a different thread and it froze” belong to this layer, and are explained in “COM STA/MTA Fundamentals - Threading Models and How to Avoid Hangs”.

8. When Writing in Native Code (C++/C)

The principles up to this point — don’t create threads directly, reduce shared mutable state, locking discipline, designing how it stops — apply directly to native code too. What changes is the tooling. In C++, the counterparts are RAII together with std::jthread / std::mutex / std::atomic; in C, they are the Win32 API’s _beginthreadex, SRW locks, condition variables, and the stop-event pattern. Each is covered, including language-specific pitfalls (the std::thread destructor, the dangers of TerminateThread, DllMain and the loader lock, and more), in this series’ “C++ Edition” and “C Edition”.

9. Verification and Debugging — Preparing on the Assumption That It Won’t Reproduce

You cannot expect multithreading bugs to be found by testing. An ordinary unit test counts a run where the race simply happened not to occur as a success. Think of your preparation in three layers.

The first line of defence is the design principles covered so far, themselves. Between an app with five pieces of shared mutable state and one with fifty, the number of places you have to suspect differs by a factor of ten. In review, check with a table: “which mutable data is shared”, “which lock protects each one”, “is the lock acquisition order unique”, and “where are the stop paths”. A design for which you cannot write this table is not finished yet, even if it is running.

Second, make anomalies observable rather than hiding them. Detect lock-wait anomalies with a Monitor.TryEnter timeout and log them,4 record rather than swallow unobserved exceptions from work thrown at the thread pool, and be set up to capture a full dump on a hang so you can check every thread’s stack — the fight against a bug that “only happens occasionally” is decided by how much information you can extract from the one time it does happen. Setting up dumps and logging is covered in “Designing Windows Apps to Leave Logs and Dumps When They Crash”.

Third, shake it under load. Stress testing that makes it easier to hit an unlucky interleaving on a development machine — running with more parallelism than you have cores for an extended time, randomising processing order, injecting artificial delays, and so on — is a realistic way to flush out races before shipping. A bug that vanishes under the debugger will often reproduce under a release build plus heavy load.

10. Summary — A Checklist Before You Add More Threads

Boiled down, the best practice for multithreaded programming is not “the skill of writing synchronisation correctly” but “a design that lets you avoid writing synchronisation at all”. If you can answer the following eight questions before you start, you can prevent almost every major incident.

  1. Is this work CPU-bound or I/O-bound (if the latter, the answer is async/await, not a thread)?
  2. Are you about to write new Thread (can it be expressed with Task, Parallel, or the thread pool instead)?
  3. Which mutable data is shared between threads — can you list it?
  4. Can that sharing be eliminated through partitioning, immutability, or hand-off via a queue?
  5. For each piece of shared data that remains, is there exactly one corresponding lock decided on?
  6. Is the lock acquisition order unique across every thread, and are you avoiding external calls while holding a lock?
  7. Is CancellationToken passed to every long-running operation, and can you explain the stop path?
  8. Is code that touches the UI concentrated on the UI thread?

Multithreading bugs do not show up on the day you write the code — they bare their teeth at a customer site long after you’ve forgotten about them. Put the other way around, if you run through this checklist at the design stage, you can pluck out the most expensive kind of failure — “it crashes occasionally”, “it freezes once a month” — before you write a line of code.

KomuraSoft LLC handles design reviews of business applications involving multithreading, root-cause investigation of hard-to-reproduce faults such as “it crashes/freezes occasionally” — dump analysis and pinpointing races — and technical consulting on parallelising or making asynchronous existing applications. We are happy to be brought in from a stage as early as “please check whether this design can race”.

References

  1. Microsoft Learn, Task Parallel Library (TPL). On the TPL being the recommended means for multithreaded and parallel code since .NET Framework 4; on it dynamically adjusting the degree of parallelism to match available processors; on it taking on the division of work, scheduling onto the thread pool, cancellation handling, and state management; on a loop whose per-iteration work is small being able to slow down from parallelisation overhead; and on a basic understanding of locks, deadlocks, and race conditions still being recommended even when using the TPL.  2

  2. Microsoft Learn, The managed thread pool. On the ThreadPool class providing a pool of system-managed worker threads, letting developers focus on the app’s tasks rather than thread management; and on .NET using the thread pool extensively for TPL operations, asynchronous I/O completion, timer callbacks, registered waits, socket connections, and more.  2

  3. Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. On a parallel loop sometimes being slower than a sequential one and always needing to be measured; on avoiding writes to shared memory inside a parallel loop, with the overload that takes thread-local state being recommended; and on there being no guarantee that each iteration of For/ForEach actually runs in parallel, so that code waiting between iterations can deadlock.  2 3 4

  4. Microsoft Learn, Managed threading best practices. On the definitions of a race condition (an example where incrementing a counter breaks down into read, add, and write-back, and gets overwritten and lost) and a deadlock; on using cooperative cancellation rather than Thread.Abort; on the fact that a type or this must not be used as a lock object, and that .NET 9 / C# 13 onward should use a dedicated System.Threading.Lock instance; on the C# lock statement guaranteeing Monitor.Exit in a finally block; on detecting deadlocks with a Monitor.TryEnter timeout; on the Interlocked class being faster for simple state changes; and on the design guidance that static data should be thread-safe by default and instance data should not be thread-safe by default.  2 3 4 5 6 7 8 9 10

  5. Microsoft Learn, System.Threading.Channels library. On a channel being a FIFO for the producer/consumer model that manages synchronisation internally; on being able to create a channel with a capacity limit using CreateBounded; on the default behaviour on hitting the limit being for the writer to wait, with other FullModes such as DropOldest also selectable; and on backpressure being applied when writing outpaces reading.  2 3

  6. Microsoft Learn, Thread-safe collections. On the collections under System.Collections.Concurrent achieving thread safety through fine-grained locking or lock-free mechanisms; and on ConcurrentQueue and ConcurrentStack being implemented without locks, using Interlocked operations, so that they hold up under frequent addition and removal from multiple threads.  2

  7. Microsoft Learn, Cancellation in Managed Threads. On the procedure for cooperative cancellation using CancellationTokenSource and CancellationToken; on cancellation being cooperative rather than forced, with the listener deciding how to stop; on the three monitoring approaches of polling, callback registration, and wait handles; on ThrowIfCancellationRequested throwing OperationCanceledException, which Task treats as cancellation completing; on composing multiple tokens with a linked token; and on a library needing to provide public methods that accept a CancellationToken.  2 3

  8. Microsoft Learn, Using threads and threading. On CancellationToken being the correct way to stop a thread; on Thread.Abort throwing PlatformNotSupportedException on .NET Core and .NET 5 onward, with a compile-time deprecation warning (SYSLIB0006) from .NET 5 onward as well; and on forcibly terminating third-party code that does not respond to cooperative cancellation requiring it to be run in a separate process and stopped with Process.Kill.  2

  9. Microsoft Learn, How to handle cross-thread operations with controls. On access to WinForms controls not being thread-safe, with operations from multiple threads leading to an inconsistent state, races, deadlocks, and freezes; on every control needing to be created and accessed on the same thread, with Windows requiring a dedicated UI thread to deliver system messages; and on calling safely from another thread using Control.Invoke, Control.InvokeAsync from .NET 9 onward, or a BackgroundWorker.  2 3

  10. Microsoft Learn, Threading model (WPF). On UI changes in WPF being restricted to a single thread, with a background thread registering work items with the UI thread’s Dispatcher to request them; on Dispatcher.Invoke being synchronous while InvokeAsync and BeginInvoke are asynchronous; and on the Dispatcher processing work as a priority queue.  2 3

  11. Microsoft Learn, Data Parallelism (Task Parallel Library). On Parallel.For / Parallel.ForEach providing data parallelism with almost the same feel as writing a for loop; on there being no need to create threads or queue work items, and no need for locks in a basic loop; and on the TPL splitting the data source across multiple threads and rebalancing the load if it becomes uneven. 

  12. Microsoft Learn, BlockingCollection<T> Class. On BlockingCollection being a producer/consumer implementation with blocking and capacity limits; on the capacity limit preventing the producer from getting too far ahead of the consumer; and on it not being designed for asynchronous access, with Channel<T> recommended for an asynchronous producer/consumer.  2

  13. Microsoft Learn, Overview of synchronization primitives. On Monitor providing mutual exclusion through a lock object and having thread affinity; on C# code being expected to use the lock statement rather than Monitor directly; on ReaderWriterLockSlim making writes exclusive while allowing concurrent reads; and on SemaphoreSlim being a lightweight semaphore for use within a single process, while Semaphore is named and can be used for cross-process synchronisation.  2 3

  14. Microsoft Learn, Async semaphores, locks, and reader/writer coordination. On the C# lock statement and the Lock type having thread affinity and therefore not being usable across an await (because the thread executing the continuation can change before and after the await); on using a SemaphoreSlim with a count of 1, via WaitAsync and a Release in finally, for mutual exclusion in asynchronous code; and on a bounded Channel being an alternative for throttling purposes. 

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

Why should I avoid lock(this) or lock(typeof(MyClass))?
Because the object you're locking on is visible to code outside your own. this is your instance itself, so any external code that can reference that instance can lock on the same object, causing unintended contention or deadlocks. typeof(MyClass) is even more dangerous: there is only one Type object per application domain, so you end up sharing the lock with code that is entirely unrelated to yours. Use a dedicated object that is never exposed externally as your lock target. From .NET 9 / C# 13 onward, the recommendation is to use an instance of the dedicated System.Threading.Lock type as the lock object.
How many threads am I allowed to create? What's the optimal thread count?
"Don't decide the thread count yourself" is the modern answer. Use Task and the Parallel class, and the thread pool automatically tunes the degree of parallelism to match the CPU core count and the current load. A design that repeatedly calls new Thread by hand tends to over- or under-provision on customer machines with a different core count. What you should pay attention to isn't a number but the kind of work: computation that saturates the CPU doesn't get faster by parallelising beyond the core count, and processing that is mostly waiting on I/O shouldn't get more threads at all — the correct move there is asynchronous I/O with async/await.
Does adding volatile make something thread-safe?
No. What volatile guarantees is ordering — that access to that field is not reordered with surrounding memory operations (acquire/release semantics) — not the atomicity of a compound operation such as "read, compute, write back". For example, even with multiple threads doing ++ on a volatile int counter, increments still get lost. Use the Interlocked class for incrementing/decrementing a counter or a compare-and-swap, and use lock when you need to protect several variables together as a group. volatile is worth considering in almost only the simple case of something like a stop flag, where one thread writes and the others only read — and even that flag is now standard to express as a CancellationToken.
How do I tell whether a bug that only happens occasionally is caused by multithreading?
The three signs to suspect are: "the same operation reproduces sometimes and not other times", "it stops reproducing once you attach a debugger or add logging", and "it only happens under heavy load or right after startup". A timing-dependent bug is characterised by the result changing on every run — that is the very definition of a race condition. To narrow it down, first enumerate every piece of mutable data you're sharing, and tabulate, for each one, which lock protects it. Even a single unprotected access is a suspect. For a hang, capture the stacks of every thread and check whether they form a cycle waiting on each other's locks. Pause in the Visual Studio debugger and look at Parallel Stacks, or, in production, capture a dump and analyse it.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog