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

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

Revision history (first version, published Aug 2, 2026)
First published
Cite this article(DOI (registered archive): 10.5281/zenodo.22170845)

The DOIs below refer to previously archived versions and may not match the current text. Use this page’s URL to reference the current text.

Go Komura (2026). Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads. KomuraSoft LLC. https://comcomponent.com/en/blog/multithreading-best-practices-dotnet/

DOI (registered archive)
10.5281/zenodo.22170845
DOI (last registered version)
10.5281/zenodo.22170846

“Processing was slow, so we spun up threads to run it in parallel, and now the totals are occasionally off.” “We added background processing, and now the app freezes once a month.” “They tell us it does not 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 show their face only 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 several devices or files concurrently”. What matters is deciding on design principles before you add more threads. Multithreading bugs are not something you stamp out by debugging; they are something you design so there is no room for them to creep in.

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 “C++ Edition” and the “C Edition”, which map the same principles onto each language’s tools; if you are writing Java, see the “Java Edition”.

1. The Bottom Line First

  • The first best practice is not to create threads yourself. Use higher-level APIs such as Task, the thread pool, and Parallel instead of new Thread, and leave the management of thread counts to the runtime.12
  • The first thing to cut when parallelizing is “shared mutable state”. Places where several threads write to the same variable are where races originate, so before protecting them with locks, reduce the sharing itself through partitioning, immutability, and hand-off.3
  • Give locking discipline. Decide, one to one, which lock protects which data, and make the lock target a dedicated object that is 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 everywhere, 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, and 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 ends up slower because of parallelization overhead. Always measure before adopting it.3

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (28 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

2. Why Multithreading Is Hard — Race Conditions and Deadlocks

Boiled down, multithreading introduces two kinds of problem.4

A race condition is a bug in which the result changes depending on the order in which several 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, and write back. When two threads run those three steps at the same time, one thread’s addition is overwritten by the other’s write-back and is lost. The result changes on every run, and which result you get cannot be predicted.4

Thread BShared variable countThread AThread BShared variable countThread Acount = 10count = 11 even though it was incremented twiceThread A's addition was lostRead (10)Read (10)Add locally (11)Add locally (11)Write back (11)Write back (11)

Figure 1: The typical race condition in which an increment to a shared counter is lost. When another thread cuts in between the three steps of count++, whichever writes back later overwrites the other

A deadlock is a state in which two threads each wait for the lock the other holds, and neither can move forward. Thread A holds lock 1 and waits for lock 2, thread B holds lock 2 and waits for lock 1 — that alone stops both of them forever.4

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

Figure 2: The circular wait of a deadlock. The moment the wait arrows form a loop, every thread inside the loop stops forever

The awkward part is that both are timing-dependent. An interleaving (a combination of execution orders) that comes up only once in tens of thousands of runs on the development machine can perfectly well happen every day on a customer machine with a different core count and different timing. “It does not reproduce when I attach the debugger” and “it disappeared once I added logging” are also typical behavior for a race bug, because observing it changes the timing.

That is exactly why every principle that follows points in one direction. Before “synchronizing correctly”, reduce the places that need synchronization — this is the basic principle of multithreaded design.

3. Principle 1: Do Not Create Threads Yourself

3.1. Ride on Task and the Thread Pool

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

The thread pool is infrastructure that .NET itself uses broadly — running tasks, completing asynchronous I/O, invoking timer callbacks — and as long as you queue short pieces of work, you do not need to manage thread lifetimes yourself.2

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

// Run several independent operations concurrently and wait for all of them (when there are few)
// * This shape is for when ProcessAsync is an I/O-bound asynchronous method.
//   WhenAll only "waits for Tasks that are already running", so if you want CPU
//   computation to run concurrently, wrap each one 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 number running at once as you push them through
await Parallel.ForEachAsync(items,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
    async (x, ct) => await ProcessAsync(x, ct));
// Two points: connect the caller's token to ParallelOptions
// (forget this and the ct in the body is always None), and pass that ct into the body too (do not discard it)

There is one caveat. Task.WhenAll(items.Select(...)) starts processing every element at once the moment the sequence is enumerated. That is no problem for a fixed set of a few or a few dozen jobs, but used against a large collection it exhausts sockets, database connections, and memory all at once. For work whose item count you cannot predict, cap the number running at once as in the Parallel.ForEachAsync above, or control the flow rate with the bounded channel described later.

Threads of your own are justified in roughly only those cases where a property of the thread itself is the requirement: it has its own message loop, it needs a specific thread apartment (STA), or it keeps running for the whole lifetime of the application.

3.2. Data Parallelism Goes to Parallel.For / ForEach

For data parallelism — “apply the same operation to every element of a collection and make the whole thing faster” — use Parallel.For / Parallel.ForEach rather than handing pieces of the loop to threads yourself. The TPL handles partitioning the data source and rebalancing the load, and a basic loop needs no locks at all.11

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

  • Do not assume that parallel is always faster. A loop with few iterations, or one whose per-iteration work is light, becomes slower because the parallelization overhead exceeds the work itself. Performance depends on many factors, so always measure before deciding.
  • Do not make iterations wait on each other. There is no guarantee that each iteration of Parallel.For actually runs in parallel. Code in which one iteration waits for another to set an event will deadlock, depending on scheduling.

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

Work that is mostly waiting on I/O — files, the network, a database — is not a candidate for more threads. Occupying a thread while it waits is pure waste, and asynchronous I/O with async/await consumes no thread while it waits. Drawing this distinction (parallelize CPU-bound work, make I/O-bound work asynchronous) is the first line to draw at the entrance to multithreaded design.

Mostly waiting on I/Ofiles, network, databaseCPU-bound computationApply the same operation toevery element of a collectionOne independent chunk ofbackground processingA property of the thread itself is requiredsuch as a message loop or STAThere is work you want to run concurrentlyWhat does the work mostly do?Asynchronous I/O with async/awaitdo not add threadsWhat shape is the work?Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(exceptional last resort)

Figure 3: The branch to take before you “spin up a thread”. Almost all business processing lands in one of the top three exits, and only exceptional cases reach new Thread

Practical judgment calls around async/await are covered in “A Practical Decision Table for C# async/await”, and how the thread pool and asynchronous I/O connect underneath is covered in detail in “IOCP and the .NET Thread Pool”.

4. Principle 2: Minimize Shared Mutable State

A race happens only when “several threads” and “shared mutable data” come together. The number of threads is dictated by requirements, so what design can cut is the sharing. There are three ways to do it.

4.1. Partition — Each Thread Touches Only Its Own Data

The simplest and most powerful move is to split the data up per thread. For an aggregation in a parallel loop, instead of writing to a shared total on every pass, use the Parallel.For overload that hands you thread-local state, so that each thread builds a subtotal locally and merges it just once at the end. Writes to the shared value drop from “once per iteration” to “once per thread”, and both the cost of synchronization and the window for a race 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));  // the merge happens once per thread
Data array (the work to process)Thread 1processes its share andadds only to its local subtotalThread 2processes its share andadds only to its local subtotalThread 3processes its share andadds only to its local subtotalMerge - Interlocked.Add folds intothe total just once per thread

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

4.2. Make It Immutable — What Is Never Rewritten Is Safe to Share

Data that is only ever read is safe to read from any number of threads at once. Configuration values, master data, and the inputs to a computation can be shared freely without synchronization by never rewriting them after construction, that is, by making them immutable. In C#, record types and init properties support this design. Simply deciding that “when a change is needed, do not rewrite it — build a new instance and swap it in” removes one more piece of mutable state you have to protect.

But “looks read-only” and “is immutable” are different things. A read-only interface such as IReadOnlyList<T> only means “it cannot be rewritten through that interface”; it cannot prevent the List<T> behind it from being rewritten through another reference. The guarantees of record / init are shallow as well and do not protect the objects a property points at. For data you genuinely want to share safely between threads, use an immutable collection from System.Collections.Immutable such as ImmutableArray<T>, or hand over a copy at the point of sharing, cutting the write path itself. This holds on the condition that the element type T is itself immutable. An immutable collection protects only “the ordering”; references to mutable element objects are still shared as they were, so if the contents of an element can be rewritten through another path, the race remains. Make the object graph immutable all the way to its leaves, or hand over a deep copy.

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

Even so, data does have to move between threads. When it does, rather than “touching a shared variable from both sides”, use a producer/consumer arrangement that puts a queue in between that one side writes and the other side reads.

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

var channel = Channel.CreateBounded<WorkItem>(100); // capacity 100, which applies backpressure

// Producer side
await channel.Writer.WriteAsync(item, ct); // if it is full, wait until space frees up
// ...once every producer has finished writing:
channel.Writer.Complete();   // declare that "no more is 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, make the writer wait(backpressure)if empty, make the reader waitProducer 1WriteAsyncBounded channel (capacity 100)FIFO queuethe channel manages synchronizationProducer 2WriteAsyncConsumer 1ReadAllAsyncConsumer 2ReadAllAsync

Figure 5: A producer/consumer arrangement with a channel in between. Neither side touches a shared variable directly, and 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 behavior when the limit is reached is “the writer waits for space”, and that is natural backpressure. Using an unbounded queue in an arrangement where production outruns consumption gives you a time bomb: it keeps running while memory keeps growing.5

The counterpart to a bounded channel in the synchronous world is BlockingCollection<T> with a capacity specified. It offers both blocking and capacity control: the capacity limit keeps the producer from running too far ahead of the consumer, and when the collection is empty the consumer is blocked and made to wait.12 ConcurrentQueue<T> / ConcurrentStack<T>, by contrast, are fast collections that achieve thread safety with Interlocked operations alone rather than locks,6 but they are plain thread-safe queues with neither a capacity limit nor a “wait until something arrives” mechanism. Think of them as parts rather than as what drives the hand-off of work. Note also that BlockingCollection<T> is not designed with asynchronous access in mind, so choose Channel<T> if you are combining it with async/await.12

One more thing: be wary of the assumption that “we swapped the dictionary for a ConcurrentDictionary, so it is thread-safe now”. Even when the individual operations are thread-safe, a compound operation such as “check whether it exists, then add” still races (use a method meant for compound operations, such as GetOrAdd). And GetOrAdd has a caveat of its own: while the stored value settles on exactly one, the factory function that creates the value can be called more than once under contention. Putting side effects in the factory — opening a connection, creating a file — leaks through the duplicate execution, so either make it a function without side effects, or store a Lazy<T> as the value for initialization that must happen exactly once. Changing the type of a collection 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. The sharing that remains is handled with mutual exclusion (locks), but a lock is not a tool for “wrapping anything that looks suspicious in a lock”. There are four rules of discipline.

5.1. Decide “What It Protects” and Lock on a Dedicated Object

Think of the unit of a lock as “data”, not “a section of code”. Associate one lock object with each set of mutable data you want to protect, and take that same lock everywhere that data is touched — a race bug is, in practice, this correspondence having broken down.

Make the object you lock on a dedicated instance that is not exposed externally. lock(this) ends up sharing the lock with any external code that can reference your instance, and lock(typeof(X)) with the entire application domain; both are breeding grounds for deadlocks. From .NET 9 / C# 13 onward, the recommendation is to use an instance of the dedicated System.Threading.Lock type as the lock object.4

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

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

C#’s lock statement guarantees that the lock is released even if an exception is thrown. 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 EnterScope() and the disposal of what it returns.413 In other words, a field of the Lock type is a different mechanism from Monitor, and if only some of the code hand-writes Monitor.Enter(_gate), mutual exclusion against lock (_gate) does not hold. With either type, the safe course is to stop hand-writing Monitor.Enter / Exit and always use the lock syntax.4

5.2. Do Not Do “Slow Things” or “Outside Things” While Holding a Lock

The shorter a lock is held the better, and the only thing you should do while holding one is read and write the data it protects. Performing I/O while holding a lock, or calling outside code through an event or a callback, not only stretches the hold time but also creates a path to a deadlock when the code you called tries to take another lock. The basic shape is to prepare outside the lock and only swap the result in inside it.

Note that you cannot await inside a lock (it is a compile error). This is protection rather than a restriction: Monitor has thread affinity, in that the thread that took the lock must be the one to release it, which is incompatible with asynchronous code where the thread can change across an await. For mutual exclusion in asynchronous code, use a 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 there are two or more locks, the classic deadlock pattern is the acquisition order differing from thread to thread. The countermeasure is simple: make it a rule that every thread takes the locks in the same order. Where the order cannot be guaranteed, use the Monitor.TryEnter overload with a timeout, and if the lock cannot be taken, let go and retry (or record the anomaly); that turns a permanent hang into a failure you can detect.4

5.4. Interlocked for Simple Updates, ReaderWriterLockSlim When Reads Dominate

For an atomic update of a single variable, such as incrementing a counter or flipping a flag, the Interlocked class (Increment / Add / CompareExchange) is faster than lock. With no contention, a single CPU instruction prefix is all it takes.4 Conversely, that is as far as Interlocked goes; it cannot be used to keep several variables consistent as a group. A hand-rolled lock-free structure combined with volatile is a tool for experts with a deep understanding of the memory model, and not something to write in a business application.

For shared data that is “read often but written rarely”, there is also the option of ReaderWriterLockSlim, which makes writes exclusive 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, but code that stops safely does not come into being unless it is designed.

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

.NET’s model for stopping is unified around cooperative cancellation. The side that stops the work creates a CancellationTokenSource and passes its Token to each operation. When it wants to stop, it calls Cancel(). The operation watches the token and, at a convenient point of its own, cleans up and ends — because this is cooperation rather than force, the operation can finish while keeping its state consistent.7

private CancellationTokenSource? _cts;
private Task? _worker;

public void Start()
{
    if (_worker is { IsCompleted: false })    // refuse a second Start while one is running
        throw new InvalidOperationException("The worker is already running.");
    if (_worker is { IsFaulted: true })       // do not rebuild while swallowing the previous failure
        throw new InvalidOperationException("The previous worker failed.", _worker.Exception);
    _cts = new CancellationTokenSource();
    var token = _cts.Token;   // capture it into a local first, so a restart after a stop cannot race
    _worker = Task.Run(() => WorkLoop(token), token);
}

private void WorkLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)   // watch it by polling
    {
        ProcessNextItem(ct);              // pass ct into blocking calls so they break off immediately
    }
}

public async Task StopAsync()
{
    var cts = _cts;          // pin what is being stopped into locals so that, even if the fields
    var worker = _worker;    // are swapped out while we wait, we do not stop the wrong thing
    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 it and report it after the join is done

    try
    {
        try { await worker; }    // always complete the join whether Cancel succeeded or not, and observe any failure along the way
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        { }                      // treat only the stop we asked for as "normal"
        catch (Exception ex) when (cancelFailure is not null)
        {
            throw new AggregateException(cancelFailure, ex);  // lose neither of the two failures
        }
    }
    finally
    {
        cts.Dispose();           // dispose the source once the join is done (releases OS resources such as WaitHandle).
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // do not let a later StopAsync use a disposed source
            _worker = null;
        }
    }
    if (cancelFailure is not null)
        throw new AggregateException(cancelFailure);
}

Note that this Start / StopAsync pair is a minimal arrangement that assumes it is called in sequence from a single thread, such as the UI thread. If several threads can operate the lifecycle at the same time, serialize Start / StopAsync themselves with something like a SemaphoreSlim — letting the management operations for the worker race each other before you have even protected the worker defeats the purpose.

This small sample also has touches in it that pay off in practice. To begin with, Start refuses a duplicate call while the worker is running. Overwriting _cts and _worker unconditionally loses the reference to the previous worker, and a stray thread that can be neither stopped nor joined runs alongside. The basic rule for lifecycle APIs (Start/Stop) is to enforce “one at a time” yourself. Three more points. First, the stop API waits for completion. Cancel() only “requests” cancellation; the moment it returns, the worker may still be in the middle of ProcessNextItem. Making it a Stop() that only requests and returns creates a new race, in which the worker is still running when the caller starts cleaning up. Second, do not throw the Task away — hold on to it. Discard it with _ = Task.Run(...) and nobody notices when the worker dies of an exception. Third, capture the token into a local variable before passing it, rather than referencing _cts.Token inside the lambda. A reference inside the lambda is evaluated at run time, so if a restart happens right after a stop, the old worker can grab the new token by mistake. Passing that same token as the second argument of Task.Run as well means that when the operation ends through ThrowIfCancellationRequested or an OperationCanceledException from a cancellation-aware API, the Task is classified as “Canceled” rather than “Faulted” (in this example, where the loop simply exits on its condition, it counts as normal completion). One more thing: the catch in StopAsync uses a when filter to hold only cancellations that originate from its own token. Swallowing OperationCanceledException unconditionally would make a genuine failure thrown by a different token inside the operation, such as a per-item timeout, look like “we stopped, so this is normal”. Note that this identification by token equality breaks down if WorkLoop uses a linked token internally (the linked composition from 6.1), because the exception that arrives carries the linked token. In that arrangement, either call ct.ThrowIfCancellationRequested() at the exit of WorkLoop to “translate” it back to the outer token before leaving, or relax the filter to when (cts.IsCancellationRequested) and accept that “a cancellation while a stop is being requested is normal” — choose one or the other explicitly, as a design decision.

calls Cancel() oncehands over the Tokenhands over the Tokenhands over the Tokenchecks IsCancellationRequestedcleans up and ends on its ownThrowIfCancellationRequestedbreaks off immediately even while waitingThe side that stops the workCancellationTokenSourceWorker operation 1Worker operation 2A cancellation-awarelibrary APINormal completionOperationCanceledException= treated as cancellation completedCancellation completed

Figure 6: The structure of cooperative cancellation. The side that stops only calls Cancel(), and each operation decides for itself when and how it ends. That is why it can stop while keeping its state consistent

There are established conventions on the library side too. A cancellable operation should offer public methods that take a CancellationToken, and a computation loop should check IsCancellationRequested periodically or call ThrowIfCancellationRequested(). The latter raises an OperationCanceledException, which Task treats as “cancellation completed” rather than “failure”. When you want to stop on both an externally supplied token and an internal condition such as a timeout, compose them with a linked token.7

6.2. Treat Thread.Abort as Something That Does Not Exist

Thread.Abort, the way to “kill a thread from outside when it will not listen”, only throws a PlatformNotSupportedException on .NET Core / .NET 5 and later, and can no longer be used. Throwing an exception into a thread without knowing where it is executing invites interrupted resource release and corrupted state. If you have to force-terminate third-party code that does not respond to cooperative cancellation (and cannot be written to respond), the official guidance is to run it in a separate process and stop it with Process.Kill.8

6.3. When You Wait, Use a Wait Handle Rather Than Polling

Writing a “loop on Sleep(100) until the flag goes up” wastes both CPU and responsiveness. For signaling between threads there are synchronization primitives such as ManualResetEventSlim and SemaphoreSlim, which put a thread properly to sleep until it is signaled.13 Timer precision on Windows and when to use an event wait instead are covered in detail in “Why You Should Prefer Event Waits over Sleep(1) on Windows”.

7. The Special Circumstances of the UI Thread — The Rules for Windows Desktop Apps

On top of the general principles, Windows desktop applications have one more strong constraint: the rule that the UI can be touched only by the thread that created it, the UI thread.

WinForms controls are not thread-safe, and operating them from several threads drives a control into an inconsistent state and becomes a cause of races, deadlocks, and freezes. Windows requires an application to provide one dedicated thread that receives system messages, and creating and operating the UI has to be concentrated on that thread.9 WPF has exactly the same structure: only the UI thread can modify UI elements.10

When you want to update the UI from another thread, convert it into “a request to the UI thread” rather than touching the UI directly.

request it with Control.Invoke /Dispatcher.InvokeAsynctouching a control directlyWindowsmouse, keyboard, repaintThe UI thread'smessage queueBackground thread(heavy processing, communication)UI threadthe only thread that may touch controlsForbiddena cause of races, deadlocks, freezes

Figure 7: UI updates are converted into “requests”. The background thread’s job ends at getting the work onto the message queue, and the one that touches controls is always the UI thread itself

Framework How to make the request
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 worker calls Invoke while the UI thread is synchronously waiting for that worker to finish, you get a deadlock in which each waits for the other (exactly the circular wait from section 2). Make the asynchronous forms (BeginInvoke / InvokeAsync) the default for notifications and progress reports from the background, and limit the synchronous forms to situations where you can state with certainty that the UI thread is not waiting on you.

In practice there is a better answer one step further on. If you write work started on the UI thread with async/await, await captures the UI thread’s SynchronizationContext and resumes the continuation on the UI thread automatically, which greatly reduces the occasions for hand-writing Invoke. This is not an unconditional property, though. Code entered from a background callback, and continuations after a ConfigureAwait(false), do not return to the UI thread, so explicit dispatch is still required if you touch the UI on those paths. Settling on the shape “heavy work goes to Task.Run or to asynchronous I/O, and reflecting the result on screen happens in the continuation after await” is the basic form for a modern Windows application. The relationship between the UI thread and async/await is laid out on a single sheet in “WPF/WinForms async and the UI Thread on One Sheet”.

Also, when COM is involved — Office integration, legacy components — another layer is added in the form of COM’s own threading model (STA/MTA). The breakage of “we created a COM object on the UI thread but called it from another thread and it froze” belongs to that layer, and is explained in “COM STA/MTA Fundamentals”.

8. If You Are Writing Native Code (C++/C)

The principles so far — do not create threads directly, reduce shared mutable state, keep locking disciplined, design how it stops — carry over to native code unchanged. 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 APIs _beginthreadex, SRW locks, condition variables, and the stop-event pattern. Each is covered in this series’ “C++ Edition” and “C Edition”, down to the language-specific pitfalls such as std::thread’s destructor, the danger of TerminateThread, and DllMain and the loader lock.

9. Verification and Debugging — Prepare on the Assumption That It Will Not Reproduce

You cannot expect multithreading bugs to be found by testing, because an ordinary unit test counts a run that “happened not to race” as a pass. Think of your preparation in three layers.

The first line of defense is the design principles themselves. An application with 5 pieces of shared mutable state and one with 50 differ by a factor of ten in the number of places you have to suspect. In review, use a table to confirm “which mutable data is shared”, “which lock protects each piece”, “is the lock acquisition order unique”, and “where is the stop path”. A design for which that table cannot be written is not finished yet, even if it runs.

Second, make anomalies observable instead of hiding them. Detect abnormal lock waits with a Monitor.TryEnter timeout and log them,4 record rather than swallow unobserved exceptions from work queued to the thread pool, and make sure you can take a full dump on a hang and inspect the stacks of every thread — the fight against a bug that “happens only occasionally” is decided by how much information you can get out of the one time it happens. Setting up dumps and logging is covered in “Designing Windows Apps to Leave Logs and Dumps When They Crash”.

Third, shake it under load. Running for a long time at a degree of parallelism higher than the core count, randomizing the processing order, and injecting artificial delays are realistic ways to make an unlucky interleaving more likely on the development machine and to flush races out before shipping. A bug that disappears under a debug run often reproduces in a release build under heavy load.

10. Summary — A Checklist for Before You Add Threads

Boiled down, the best practices of multithreaded programming are not “the skill of writing synchronization correctly” but “a design that avoids having to write synchronization”. If you can answer the following eight questions before you start, you can prevent almost all of the serious breakage.

  1. Is this work CPU-bound or I/O-bound (if the latter, the answer is async/await rather than threads)
  2. Are you about to write new Thread (can it not be expressed with Task, Parallel, or the thread pool)
  3. Which mutable data is shared between threads, and can you enumerate it
  4. Can that sharing be eliminated by “partitioning”, “making it immutable”, or “handing it off through a queue”
  5. Does each remaining piece of shared data have exactly one lock assigned to it
  6. Is the lock acquisition order the same on every thread, and are you free of outside calls while holding a lock
  7. Is a CancellationToken passed to every long-running operation, and can you explain the stop path
  8. Is the code that touches the UI concentrated on the UI thread

Multithreading bugs do not show up on the day you write them; they bare their teeth at the customer site once you have forgotten about them. Put the other way round, running this checklist at the design stage means you can pull out the most expensive class of failure — “it crashes occasionally”, “it freezes once a month” — before you write the code.

KomuraSoft LLC handles design reviews of business applications that include making them multithreaded, root-cause investigation of hard-to-reproduce defects such as “it crashes or freezes occasionally” (dump analysis, pinpointing the racing code), and technical consulting on parallelizing and making existing applications asynchronous. It is perfectly fine to come to us at the stage of “I would like someone to look at whether this design can race”.

References

  1. Microsoft Learn, Task Parallel Library (TPL). On the TPL being the recommended way to write multithreaded and parallel code from .NET Framework 4 onward; on it adjusting the degree of parallelism dynamically to the available processors; on it taking on the division of work, the scheduling onto the thread pool, cancellation support, and state management; on a loop whose per-iteration work is small potentially becoming slower because of parallelization overhead; and on a basic understanding of locks, deadlocks, and race conditions being recommended even when using the TPL.  2

  2. Microsoft Learn, The managed thread pool. On the ThreadPool class providing a system-managed pool of worker threads so that developers can concentrate on their application’s tasks rather than on thread management, and on .NET using the thread pool broadly, for TPL operations, asynchronous I/O completions, timer callbacks, registered waits, and socket connections.  2

  3. Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. On a parallel loop sometimes being slower than a sequential one, so that measurement is always required; on avoiding writes to shared memory inside a parallel loop, with the overloads that take thread-local state recommended instead; and on there being no guarantee that each iteration of For/ForEach runs in parallel, so that code which waits between iterations can deadlock.  2 3 4

  4. Microsoft Learn, Managed threading best practices. On the definitions of a race condition (with the example of a counter increment decomposing into read, add, and write back, so that one is lost to an overwrite) and of a deadlock; on using cooperative cancellation rather than Thread.Abort; on never locking on a type or on this, and using an instance of the dedicated System.Threading.Lock from .NET 9 / C# 13 onward; on C#’s lock statement guaranteeing Monitor.Exit in a finally block; on deadlock detection through a Monitor.TryEnter timeout; on the Interlocked class being faster for simple state changes; and on the design guidance of making static data thread-safe by default and instance data non-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 synchronization internally; on CreateBounded creating a channel with a capacity limit; on the default behavior when the limit is reached being for the writer to wait (Wait), with other FullMode values such as DropOldest also available; 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 with Interlocked operations rather than locks, so that they withstand frequent additions and removals from several threads.  2

  7. Microsoft Learn, Cancellation in Managed Threads. On the steps of the cooperative cancellation model built from CancellationTokenSource and CancellationToken; on cancellation being cooperative rather than forced, with the listener deciding how it stops; on the three ways of watching for it, polling, registering a callback, and a wait handle; on Task treating the OperationCanceledException raised by ThrowIfCancellationRequested as cancellation completed; on composing several tokens with a linked token; and on libraries being expected to offer public methods that take a CancellationToken.  2 3

  8. Microsoft Learn, Using threads and threading. On CancellationToken being what should be used to stop a thread; on Thread.Abort throwing PlatformNotSupportedException on .NET Core and .NET 5 and later, and also producing an obsolescence warning at compile time (SYSLIB0006) from .NET 5 onward; and on third-party code that does not respond to cooperative cancellation needing 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, so that operating them from several threads leads to an inconsistent state, races, deadlocks, and freezes; on every control having to be created and accessed on the same thread, and on Windows requiring a dedicated UI thread to which it dispatches system messages; and on making safe calls from another thread with Control.Invoke, with Control.InvokeAsync from .NET 9 onward, or with BackgroundWorker.  2 3

  10. Microsoft Learn, Threading model (WPF). On WPF limiting modification of the UI to a single thread; on a background thread making its request by registering work items with the UI thread’s Dispatcher; on Dispatcher.Invoke being synchronous while InvokeAsync and BeginInvoke are asynchronous; and on the Dispatcher processing work through a priority queue.  2 3

  11. Microsoft Learn, Data Parallelism (Task Parallel Library). On Parallel.For / Parallel.ForEach providing data parallelism with much 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 partitioning the data source, processing it on several threads, and redistributing the load if it becomes uneven. 

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

  13. Microsoft Learn, Overview of synchronization primitives. On Monitor providing mutual exclusion through the object being locked on and having thread affinity; on C# code using 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 can be named and used for cross-process synchronization.  2 3

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

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 lock(this) and lock(typeof(MyClass)) be avoided?
Because the object being locked on is visible to code other than your own. this is your own instance, so any external code that can reference that instance can lock on the same object, which becomes a source of unintended contention and 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. Use a dedicated object that is never exposed externally as the 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 is the optimal thread count?
"Do not decide the thread count yourself" is the modern answer. If you use Task and the Parallel class, the thread pool tunes the degree of parallelism automatically to the CPU core count and the current load. A design that repeatedly writes new Thread by hand tends to over- or under-provision on customer machines with a different core count. What you should be thinking about is not a number of threads but the kind of work: computation that saturates the CPU does not get faster by parallelizing beyond the core count, and processing that mostly waits on I/O should not 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 the memory operations around it (acquire/release semantics) — not the atomicity of a compound operation such as "read, compute, write back". For example, applying ++ to a volatile int counter from several threads still loses increments. Use the Interlocked class to increment or decrement a counter and to compare and exchange, and use lock when you need to protect several variables together. 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 can I tell whether a bug that happens only occasionally comes from 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 happens only under heavy load or right after startup". A timing-dependent bug is characterized by the result changing on every run, which is the very definition of a race condition. To narrow it down, first enumerate every piece of mutable data being shared 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 analyze 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