Practical Multithreading Best Practices: C++ Edition — Designing Failures Out with RAII and jthread

· Updated: · · Windows, Multithreading, C++, Visual Studio, Business Applications, Bug Investigation, Design

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

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: C++ Edition — Designing Failures Out with RAII and jthread. KomuraSoft LLC. https://comcomponent.com/en/blog/multithreading-best-practices-cpp/

DOI (registered archive)
10.5281/zenodo.22170847
DOI (last registered version)
10.5281/zenodo.22170848

“An exception was thrown, and the cleanup of a std::thread took the whole app down.” “I set the stop flag, but the worker never comes back.” “A design that worked in C# was ported to C++, and now it crashes once in a while.” In C++ multithreading, before you line up the work, you have to decide how shared data is protected and how threads end.

In C++ in particular, a data race is not merely a miscalculation; it is undefined behavior. The fact that a correct result happened to come out cannot serve as evidence of safety.1

This article is the C++ edition of the practical multithreading series, aimed at developers who write business applications, device control, and DLLs in modern C++ (C++17/20). It proceeds in this order: choose the execution method, reduce sharing, implement synchronization and stopping, then check Windows-specific constraints and verification. Examples that require C++20 are marked as such where they appear.

The same principles are worked out for other languages in the “.NET Edition”, “C Edition”, and “Java Edition”, but this article can be read on its own.

1. The Conclusion First — Decide Sharing and Lifetime Before You “Add Synchronization”

First, reduce shared mutable state, and protect what remains shared with the standard library and RAII. Then make everything from the stop request to the completion of join a single design.1

Order of decisions What to decide Where in this article
1. Execution method A one-off task, parallel processing over a collection, or a long-running worker? Are you trying to solve I/O waits by adding threads? Section 3
2. Owner of the data Can partitioning, passing by value, immutability, or a queue reduce writes to shared state? Section 4
3. Protecting what remains shared Which data is guarded by which mutex? Have you distinguished single updates handled by atomic from consistency across multiple variables? Section 5
4. Shutdown procedure Does the stop request reach both waiting and busy threads? Can you record exceptions and join at the end? Section 6
5. Placement and verification Are the DLL, UI, and COM constraints respected, and can you investigate with logs, dumps, and load tests? Sections 7 to 8

The default tools are std::jthread for thread lifetime, RAII for locks, predicate wait for waiting, and std::atomic for a single shared flag or counter. volatile is not used for synchronization. Before C++20, stop design is built from an atomic flag and a condition variable.2345

Choosing the tools alone does not finish the job, though. Even if jthread joins automatically, it keeps waiting if the work cannot finish. Even if atomic swaps a pointer, it does not protect the lifetime of the old object. Check both against the code examples later on.

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 (27 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. Three Premises to Understand First — Races, Waiting on Each Other, and RAII

2.1. A Race Condition Depends on Execution Order, and a C++ Data Race Is Undefined Behavior

A race condition is a bug in which the result changes depending on the order in which threads reach a piece of work. Break the shared counter’s ++count into “read, add, write back,” and you can see how two threads read the same value and one update disappears.

Example of a lost update to a shared counterSchematically shows how an update is lost when two threads read the same value, each add to it, and write it back.count is 10Both A and B read 10Each adds to 11 locallyA writes back 11B also writes back 11In C++ the result is not limited to this

Figure 1: A schematic of a lost increment; it does not bound the outcome of undefined behavior in C++.

This is a schematic for understanding races. In C++, when multiple threads touch the same non-atomic memory location, at least one of them writes, and the required synchronization is absent, the standard classes it as undefined behavior caused by a data race. The actual outcome is not limited to “one increment is lost.”1

The compiler optimizes on the assumption that there are no data races. As a result, the behavior you expect from the source code can no longer be guaranteed, including reordering and merging of condition checks, reads, and writes. Do not assume that “either the old value or the new value is read.” Making the stop flag a volatile bool does not solve this either, because it is not inter-thread synchronization in C++.15

2.2. Deadlock Happens When the “Parties Being Waited On” Form a Cycle

A deadlock is a state in which each side waits for the other to release a lock it holds, and neither can move on. It takes only thread A holding lock 1 while waiting for lock 2, and thread B holding lock 2 while waiting for lock 1.

Circular wait on two locksWhen each side waits for the lock the other holds, neither can move on.waits for lock 2waits for lock 1A holds lock 1B holds lock 2

Figure 2: When the wait arrows form a cycle, each side stops, waiting for the other to move.

With both races and deadlocks, the problematic execution order may never show up on the development machine and then appear at a customer site with a different core count or load. Attaching a debugger or adding logging can change the timing and make it stop reproducing. That is why, before synchronizing correctly, it matters to reduce the places that need synchronization.

2.3. Use RAII to Make Cleanup, Including Exception Paths, Part of the Structure

C++ has no finally; instead it has RAII (Resource Acquisition Is Initialization), which ties resource management to object lifetime. The object that acquired a lock releases it when it leaves scope, and the object that owns a thread joins it when it is destroyed.1

Not only normal completion but also the paths that leave the scope through an early return or an exception ride on the same cleanup. If you read the jthread and lock wrappers that follow as tools that implement this idea, the choice between them becomes easier to see.

3. Choosing the Execution Method — Tasks First, Threads When Needed

3.1. Separate One-Off Work, Processing over a Collection, and I/O Waits

The principle “don’t add threads yourself” holds in C++ as well. First look at the shape of the work, then pick the tool that expresses it.

Shape of the work Main options What to check first
A one-off asynchronous operation and receiving its result std::async and std::future The launch policy and the lifetime of the future
Parallel processing over a collection PPL, C++17 parallel algorithms The amount of work per iteration and the handling of exceptions
A worker with a lifetime C++20 std::jthread Where the stop request is observed, and join
Waiting on I/O On Windows, OVERLAPPED I/O or IOCP Use asynchronous I/O rather than adding threads
Choose the execution method, then decide the lifetimeChoose an execution method that fits the shape of the work, and decide how results and stopping are managed for that method.One-off or collection processingLong-running processingWaiting on I/OCheck the shape of the workWhat is being executedTasks or parallel algorithmsManage the worker's lifetimeConsider asynchronous I/ODesign results, exceptions, and termination

Figure 3: Before adding threads, decide the shape of the work and who owns its termination.

Avoid adding threads just to wait on I/O. OVERLAPPED I/O and IOCP, the mechanisms that take on this role in native Windows code, are covered in “The Depths of Windows I/O (Part 2)”.

3.2. With async, Decide the “Start Condition” and “How Long the Result Is Held” as a Pair

std::async is convenient, but you must not discard the returned future. There are two points to watch.6

The first is blocking on destruction. If work launched with std::launch::async is still incomplete when the future or shared_future that last holds its shared state is destroyed, a wait for completion occurs. If you discard the return value on the spot, you wait at the end of that expression even though you meant it to be asynchronous, which amounts to serial execution.

The second is deferred execution. If you do not specify a launch policy, the implementation may choose deferred. In that case the work never runs unless someone calls get() or wait(). Where you need execution on another thread for certain, specify std::launch::async explicitly, and decide who holds the future and for how long.

async launch policy and the lifetime of the futureDistinguishes blocking on destruction when launched with async from the case where nothing runs under deferred unless someone waits.asyncdeferredCall std::asyncLaunch policy chosenRuns on another threadLast future is destroyedWaits for completion if unfinishedExecution deferred until get or waitNever runs if neither is called

Figure 4: Behavior depends not only on the launch policy but on how long the future is held.

3.3. Check Granularity and Exception Boundaries in Parallel Loops

concurrency::parallel_for and parallel_for_each from the PPL (Parallel Patterns Library) apply an operation to the elements of a collection in parallel. If the work per iteration is too small, however, the fork-join and scheduling overhead outweighs the gain. The principle is to express parallelism at the outermost loop possible.7

With the C++17 parallel algorithms you can use std::execution::par. MSVC parallelizes the major algorithms, but that does not mean every algorithm always runs in parallel.8

Also, if an exception escapes from the element operation of an algorithm that uses a standard execution policy, it leads to std::terminate. Put the necessary try/catch not only in the caller but inside the per-element callback. This is the same thinking as the exception boundary of a thread function covered in Section 6.

3.4. If You Own a Thread, Guarantee join on Exception Paths Too

A std::thread that is destroyed while still joinable, neither joined nor detached, calls std::terminate. Regardless of whether the thread function’s work has finished, the object side must complete the join.9

The following example shows that pitfall.

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← if an exception is thrown here...
    worker.join();      // ← join is never reached, and worker's destructor calls terminate
}

Writing join() at the end alone does not protect the exception path in the middle. If you use std::thread, guarantee the join with try/catch or RAII. If C++20 is available, make std::jthread the default; when the thread is joinable, its destructor issues a stop request and then joins. In MSVC, <stop_token> and jthread are available from Visual Studio 2019 16.9 onward.210

Destroying a thread object and joiningDestroying a joinable thread leads to terminate, while destroying a jthread issues a stop request and joins.NoYesthreadjthreadDestroy the thread objectIs it joinableNothing to joinWhich type is itstd::terminateIssue a stop request and joinThe work itself must terminate

Figure 5: jthread automates joining but does not forcibly terminate the work.

What is automated here is the owner’s stop request and join. It is not a feature that catches exceptions escaping from the thread function or forcibly terminates work that will not stop. Section 6 designs those two separately.

As a rule, do not use detach(). Because it forfeits any means of joining, the destruction of static variables or the heap races with the thread’s execution and leads to a crash at exit. Make “being able to wait for the end” a basic requirement of the design.

4. Reducing Sharing — Partitioning, Passing by Value, Immutability, and Queues

4.1. Keep Per-Thread Subtotals Instead of a Shared Total

Shared mutable state, the cause of races, can be reduced before you add locks. For parallel aggregation, instead of every thread updating a shared total, each thread builds a local subtotal and merges it exactly once at the end.

Writes to shared state drop from “every iteration” to “once per thread,” so both the synchronization cost and the places where contention can occur shrink. For the update at merge time, either std::mutex or std::atomic’s fetch_add works.

Merge per-thread subtotals at the endInstead of updating the shared total every time, each thread's subtotal is applied to the total exactly once at the end.Partition the inputThread A's subtotalThread B's subtotalSynchronize and merge at the endShared total

Figure 6: Update private data during iteration, and gather the writes to shared state at merge time.

4.2. Pass by Value, but Check What the Copy Points To

If the data needed at startup is passed by copy or move so that it belongs to that piece of work alone, later synchronization can be reduced. Rather than relying on [&], lambdas use explicit captures, by copy or move as a rule. If you do use a reference capture, the referenced object must outlive the thread.

However, copying a pointer does not make the object it points to private. A struct that contains raw pointers or shared_ptr still shares through aliases. You can conclude “it’s safe because I copied the value” only when the whole graph of values, including what is referenced, is a deep value with no shared mutable state.

Copying a pointer still shares the referenced objectWhen a value containing a pointer is copied, the two variables are distinct but the referenced object stays the same.Copy the pointer valuePointer in the original valueThe same referenced objectPointer in the copySynchronization needed to modify it

Figure 7: Check not only the copied value but the objects it references.

4.3. If You Share Through const, Create a State That “Nobody Modifies”

Things that are not modified after construction, such as configuration, master data, and computation inputs, can be shared read-only. For example, std::shared_ptr<const Config> forbids modification through that handle.

But if a non-const reference remains elsewhere, or a mutable member is modified, the race remains. Include in the design the point where once construction is done, non-const references are released and nobody writes to it afterward.

When a change is needed, the policy is to build a new object and swap it in rather than modify the existing one. Synchronizing the pointer being swapped and managing the old object’s lifetime are, however, separately required. Section 5.4 checks these.

4.4. Give Queues a Capacity Limit and a Wait That Can Be Stopped

Route handoffs between threads through a producer-consumer queue rather than having threads touch shared variables directly. The C++17/20 standard library dealt with here has no channel, so a small queue that combines a mutex and a condition variable is the basic form.

The next example assumes C++20. Because it uses a wait that takes a std::stop_token, the condition variable is std::condition_variable_any. The return values of Push and Pop signal that a stop request was observed and the operation abandoned.

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // capacity 0 is a trap where every Push waits forever
            throw std::invalid_argument("capacity must be positive");
    }

    // If full, wait until there is room (or a stop request). false means a stop request.
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // woken by a stop request
            if (st.stop_requested())                // if room and stop coincide, stop wins,
                return false;                       // and no item is accepted after stop begins
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // notify outside the lock
        return true;
    }

    // Wait for a stop request (stop_token) or for an item to arrive. nullopt on stop.
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // woken by a stop request
            if (st.stop_requested())                // if an item and stop coincide, stop wins,
                return std::nullopt;                // and no new work is started after stop begins
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // _any is chosen for the stop_token-aware wait
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

There are three things to look at in this code.

What to check Where the code handles it Problem prevented
What happens when the queue is full Set a capacity limit and wait for room in Push. Reject capacity 0 Production outpacing consumption and memory growing without bound
What to check after returning from a wait Pass wait a predicate that inspects the queue’s state Returning without a notification, or the condition having changed by the time of wake-up
Whether a wait can be left when stopping Use the stop_token-aware wait, and check for stop before the operation too Being unable to exit while waiting on an empty or full queue

Making the producer wait when the queue is full provides backpressure, which propagates overload upstream. Also, because condition variables have spurious wakeups, in which a thread wakes without a notification, waits are written with a predicate. The predicate form of wait takes over the loop that rechecks the condition.4

Queue with a capacity limit and wait releaseThe producer waits for room when full, the consumer waits for an item when empty, and the stop request reaches both waits.ProducerIf full, wait for roomQueue with a capacity limitIf empty, wait for an itemConsumerStop request

Figure 8: The capacity limit provides backpressure, and the stop request reaches the producer’s and consumer’s waits as well.

This example’s policy is that once a stop is observed, it does not process everything that remains; it stops the next push or pop. However, the stop request and an operation already in progress are not combined into one indivisible operation. There is no guarantee that an operation which passed the stop check just before the request arrived will be rolled back, and work in progress is handled by the cooperative stopping in Section 6.

The example is the skeleton of synchronization and stopping. Provide the required headers and business-specific types where you integrate it, and handle failures of element moves or queue operations at the exception boundary in Section 6 as well.

5. Protecting What Remains Shared — Lock Discipline and the Scope of atomic

5.1. Map Locks to the Data They Guard, Not to Code

When shared mutable state cannot be reduced to zero, map a mutex to each set of data it guards, and take the same mutex on every access. The mutex is not exposed externally; the basic form is to hold it as a private member together with the data.1

While holding the lock, do only the short reads and writes of the guarded data. Calling external code such as file I/O, network calls, or callbacks while holding the lock not only lengthens the hold time but creates paths that wait on locks inside the callee. Aim for the form prepare outside the lock, and only swap inside.

Prepare outside the lock and swap insideTime-consuming preparation moves outside the lock, and only the change to the shared data happens in a short locked region.Prepare outside the lockAcquire the lock with RAIIModify the shared dataLeave the scope and releasePerform external notifications and the like

Figure 9: Map the guarded data to its mutex, and keep external processing out of the hold period.

5.2. Leave Acquire and Release to RAII, and Take Multiple Locks Together

If you pair lock() and unlock() by hand, you forget the release on an early return or an exception. Leave acquire and release to the following wrappers.

Wrapper Use
std::lock_guard The most basic form: hold one mutex for the duration of a scope
std::scoped_lock (C++17) Acquire multiple mutexes at once. A deadlock-avoidance algorithm resolves the ordering problem3
std::unique_lock When you want to release and reacquire midway, or pass it to condition_variable::wait

If you take multiple locks separately, unify the acquisition order across all threads. If the locks are needed at the same time, pass them together to std::scoped_lock and leave deadlock avoidance at acquisition to the library. This mechanism covers acquiring the mutexes you pass; it does not prevent other circular waits, such as external calls made while the lock is held.3

Here is an example that guards two accounts at once. Focus on how the locks are acquired, not on business concerns such as balance checks.

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // do nothing for the same account (see note below)
    std::scoped_lock lock(from.mtx, to.mtx);   // both at once, and the library resolves the order
    from.balance -= amount;
    to.balance   += amount;
}

The identity check at the top is mandatory. If the same account is passed for both arguments, the same non-recursive mutex is passed twice, which causes a hang or undefined behavior. A function that “locks both” must exclude the same object.

5.3. Choose shared_mutex and recursive_mutex After Confirming the Use Case

For data that is read often and written rarely, C++17’s std::shared_mutex gives you a reader-writer lock.11

recursive_mutex is a type that permits reacquisition by the same thread. But needing recursive acquisition is often a sign that the responsibility for the lock has become blurred, so review the structure before settling the matter by switching types.

5.4. Atomic Updates and Object Lifetime Are Separate Problems

std::atomic provides indivisible operations on a single variable and ordering based on memory_order. Its main use is updating counters and flags. When several variables must stay consistent as a set, making each of them atomic is not enough; guard them together with a mutex.5

std::atomic<T*> in particular only makes the pointer swap indivisible; it does not extend the lifetime of what it points to. If a reader obtains the old pointer and, immediately after, the writer swaps it and deletes the old object, the reader touches freed memory.

An atomic pointer swap alone does not protect lifetimeThe old pointer a reader obtained loses its referenced object when the writer deletes it after the swap.Reader obtains the old pointerWriter swaps the pointerWriter deletes the old objectReader accesses the old objectAccess to freed memoryIndivisibility of the swap alone is not enough

Figure 10: The pointer swap and the lifetime of the referenced object must be protected separately.

If you share immutable objects by swapping them in, choose a means that includes lifetime management, such as replacing a std::shared_ptr<const T> under a lock, or C++20’s std::atomic<std::shared_ptr<T>>. Even with shared_ptr, you are not free to modify the referenced data, as Section 4.3 explained.

volatile is no substitute here either. In business applications, use atomic’s default seq_cst or write it with a mutex. Lock-free designs that relax memory_order are a specialist option, limited to cases where you can explain both the necessity and the means of verification.

6. Completing the Stop — Request, Wait Release, Exception Handling, and join

6.1. request_stop Is a Request; Completion of join Confirms the Stop

In a review, you need to be able to explain “how it stops” before how it starts. C++ has no means of safely forcing a thread to terminate from outside. The dangers of Win32’s TerminateThread are covered in the C Edition.

The basis is cooperative stopping. The stopping side issues a request, the thread itself exits at a point where it can clean up, and the owner confirms that the join has completed. In C++20, jthread’s request_stop() conveys the request to the stop_token passed to the thread function.2

In a compute loop, check stop_requested(); while waiting, receive the stop request through condition_variable_any::wait(lock, st, pred). The queue in Section 4 is shaped so that even the empty and full waits return through this path. Being able to release a wait through a stop request is not the same as the stop completing within a bounded time, scheduler and lock reacquisition included.

Cooperative stopping makes everything up to join one pathThe owner requests a stop, computation and waits observe it and exit, and completion of join confirms the stop.Owner requests a stopConveyed to the stop_tokenCheck the request while computingRelease the corresponding waitClean up and returnOwner's join completes

Figure 11: Confirm the stop by the completion of join, not by the moment the request is issued.

6.2. Reading the Worker Example from the Standpoint of Lifetime and Double Start

Next is a C++20 worker that uses the BlockingQueue from Section 4. WorkItem, Process, and ReportError are types and functions the business side provides. In particular, implement ReportError so that it does not throw.

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // Reject a second Start while running.
            throw std::logic_error("already running"); // If you assign instead of rejecting, two
                                                       // workers run side by side while the old
                                                       // thread's stop is awaited after the new one starts
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // also wakes on a stop request
                        try {
                            Process(*item, st);          // pass st to work that may block internally too
                        } catch (...) {
                            ReportError(std::current_exception());  // one failure is recorded, then continue
                        }
                    }
                }
            } catch (...) {
                // Last line of defense at the thread boundary (failures of Pop or moves land here too).
                // An exception escaping from here takes the whole process down via std::terminate,
                // so implement ReportError so that it does not throw
                ReportError(std::current_exception());
            }
        });
    }
    // No explicit Stop is needed:
    // Worker's destructor → jthread's destructor → request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // with a capacity limit (Section 4)
    std::jthread thread_;
};

At the top of Start(), a second start on a joinable thread is rejected. If you assign a new jthread without checking, two threads can run side by side while the old thread’s stop is awaited after the new one has started. This check is not a lock that synchronizes concurrent Start() calls from multiple callers. The premise is that starting and destruction are managed serially on the owner’s side.

The declaration order of the members is part of the lifetime too. In the example, thread_ is declared after queue_, so thread_ is destroyed first. Only after its stop request and join complete is the queue the worker uses destroyed.

Worker member destruction order and the queue's lifetimeThe jthread declared later is destroyed first, and the queue the worker uses is destroyed after the join completes.Destroy WorkerDestroy thread_, declared laterStop request and joinConfirm the worker has exitedDestroy queue_

Figure 12: Do not destroy the queue the worker references before the join.

6.3. jthread Does Not Catch Exceptions That Escape from the Worker

Automatic join and exception handling in the thread function are separate. If an exception escapes from the function, it leads to std::terminate with jthread just as with std::thread.

The try/catch in the example has two roles.

Exception boundary Target Policy in the example
Inner Failure of a single work item in Process Record it and move on to the next item
Outer Failure of the loop as a whole, including Pop and element moves Record it as the last line of defense and let nothing escape from the function
Exception boundaries for one item and for the whole threadThe failure of one item and failures such as queue operations are caught at separate boundaries, and no exception escapes from the thread function.Exception from one itemException from taking, etc.Worker loopTake from the queueProcess one itemRecord in the inner boundary and continueRecord in the outer boundary and exitThe recording routine must not throw either

Figure 13: Rather than relying on automatic join, make the thread function’s exception boundaries explicit.

In real business work, decide whether to continue after one failure or to report it to the owner through an error channel and stop. Do not catch and silently discard; make it observable.

6.4. Run the Stop Path All the Way into Process

The token is passed to Process(*item, st) as well because the thread must respond to a stop while processing a single item, not only while waiting on the queue. If a long computation or a network wait does not observe the request, the destructor’s implicit join keeps waiting until that processing ends.

Cooperative stopping works only once the stop path reaches every place that waits. Give non-interruptible external calls a timeout, and put an upper bound on the execution time of a single item. Adding a stop token to the argument list does not by itself make that external API interruptible.

6.5. Before C++20, Pair a Stop Flag with a Notification

In environments where the C++20 stop mechanism is unavailable, build the same structure from a std::atomic<bool> stop flag and the condition variable’s notify_all. Include the stop flag in the waiter’s predicate too. If you only set the flag without notifying, the waiting thread does not wake.4

Furthermore, even if the flag is atomic, discipline is still needed so that a notification is not missed between the condition check and the start of the wait. Arbitrate changes to the stop state with the same mutex as the waiter, and notify after changing the state. Check separately that you prevent a data race on the value and that you do not miss the wake-up notification.

7. Integrating with Windows — The Boundaries of Sync APIs, DLLs, and UI

7.1. Ordinary C++ Uses the Standard Library; Choose Win32 Integration from the Requirements

In C++ code where portability matters, make std::mutex or std::shared_mutex with RAII the default. The reasons to choose Win32 synchronization objects are integration with the Win32 wait APIs and cross-process synchronization.12

Situation Choice
Ordinary in-process mutual exclusion std::mutex + RAII (the default)
Many readers, rare writers std::shared_mutex
Waiting on several objects at once with WaitForMultipleObjects Win32 kernel objects such as events and mutexes
Cross-process exclusion or notification Named mutexes, events, and semaphores
An in-process lock using the Win32 API directly SRW lock (CRITICAL_SECTION only when recursion is needed)12
Choosing between standard synchronization and Win32 integrationOrdinary C++ code defaults to standard synchronization, and Win32 objects are chosen when Win32 waits or cross-process synchronization are needed.NoYesCheck the synchronization requirementsWin32 wait or cross-processStandard mutex and RAII by defaultChoose Win32 objectsSRW lock and the like if using Win32 directly

Figure 14: Choose from the OS-integration requirements, and do not confuse them with ordinary in-process exclusion.

Avoid replacing ordinary in-process exclusion with a Win32 Mutex. It involves a kernel transition, which is an unnecessary cost for that purpose. The line is drawn as follows: for new code that uses Win32 directly, an SRW lock, and CRITICAL_SECTION only when the same thread needs recursive acquisition.12

For a concrete design that guards shared memory across processes, see “Shared Memory Pitfalls and Practical Best Practices”.

7.2. In DllMain, Do Not Start, Synchronize, or Wait for Threads

DllMain is called while the loader lock is held. Synchronizing with other threads there, waiting for them to finish, or calling LoadLibrary causes deadlocks and other problems.13

Move initialization and teardown that start or join threads into explicit functions outside DllMain. jthread is no exception; check where the automatic join takes place.

Separate DLL thread handling into explicit functionsDo not synchronize in DllMain while the loader lock is held, and separate thread startup and waiting for exit into outer functions.DllMainLoader lock heldNo starting, synchronizing, or joining hereExplicit function outside DllMainManage start, stop, and join

Figure 15: Check where the implicit join from destroying a thread object runs, too.

7.3. Delegate UI Updates to the Creating Thread, and Do Not Wait on Each Other Through Synchronous Notifications

Concentrate operations on windows and controls in the UI thread that created them. Rather than updating the screen directly from a worker, the basic form is to delegate with the asynchronous PostMessage and handle it in the window procedure on the UI side.

The synchronous SendMessage, when called while the UI thread is waiting for the worker to finish, creates a circular wait. Notifications from the worker are made asynchronous to avoid this path.

Circular wait from synchronous notification between UI and workerWhen the UI waits for the worker to finish while the worker waits for SendMessage to be handled, a circular wait results.Waits for the worker to finishWaits for SendMessage to completeUI threadWorker threadNotification from the workerDelegate with PostMessageHandled on the UI side

Figure 16: Make delegation to the UI asynchronous by default, and do not create mutual waits for completion.

For the STA/MTA constraints when COM is involved, see “COM STA/MTA Fundamentals”. C++/CLI has its own constraints as well: in code compiled with /clr, the standard threading headers such as <thread> and <mutex> are blocked.14

8. Verification — Prepare in Three Layers: Design Table, Observation, and Stress Testing

8.1. Before Looking at Results, Confirm You Can Explain Sharing and Stopping

Even if the ordinary tests pass, it may only mean that run did not race. The first line of defense is the design so far. In a review, confirm the following in table form.

Target What you must be able to explain
Shared mutable data Who reads and writes it, and which mutex guards it
Multiple locks Whether the acquisition order is unified, or they are taken together with scoped_lock
Data passed in Whether aliases remain after copying, and whether the lifetime suffices
Stopping Where the request is observed, where waits are released, and who joins

A design that cannot explain this mapping is not finished, even if it runs.

8.2. Make Anomalies Visible with Timeouts, Logs, and Dumps

For a lock that should never fail to be acquired or a wait that never ends, consider timeouts such as timed_mutex::try_lock_for and condition_variable::wait_for. If a timeout is logged, a silent hang becomes a detectable failure. Always record the exceptions caught at the thread boundary as well.

When a hang or crash occurs in the field, check the stacks of all threads from a dump and trace whether the lock waits form a cycle. How to prepare is covered in “Designing Windows Apps to Leave Logs and Dumps When They Crash”.

8.3. Shake Up Execution Order and Load, in Release Builds Too

Stress tests such as running for a long time at a parallelism higher than the core count, randomizing the processing order, and inserting artificial delays make problematic execution orders easier to provoke. Apply load not only to debug builds but to optimized release builds too.

Prepare the design and observation, then stress testConfirm sharing and stopping in the design, make them observable with logs and dumps, and then test with varied load and execution order.Check sharing, locks, lifetime, and stoppingPrepare logs and dumpsTest with varied load and execution orderFeed the problems found back into the design

Figure 17: Do not treat a passing test alone as proof of safety; combine design, observation, and testing.

Testing is not a substitute for design. The order is: protect sharing and lifetime through structure, then observe anomalies, then search for weak spots by testing.

9. Summary — The C++ Checklist

Finally, confirm in the C++ implementation that you do not add threads directly, that you reduce shared mutable state, that locks map to data, and that you stop cooperatively.

  1. Is a bare std::thread used anywhere (could it be a jthread, and is join guaranteed on exception paths too)?
  2. Is detach() used anywhere?
  3. Are lambda captures explicit, and do reference-captured variables outlive the thread?
  4. Can you state that there is not a single unsynchronized shared mutable access (= undefined behavior)?
  5. Is there no hand-written lock() / unlock(), and are multiple locks taken together with scoped_lock?
  6. Does every condition_variable::wait have a predicate?
  7. Is volatile used for a shared flag (has it been made a std::atomic)?
  8. Is the stop path designed with stop_token (or an atomic flag plus notification), and is completion of the join confirmed?
  9. Is a std::async future discarded anywhere?
  10. Does DllMain start, synchronize with, or join threads?

Because a data race is undefined behavior in C++, you cannot rely on “it usually works.” Even so, if you follow the conventions of RAII and the standard library, cleanup and synchronization failures can be reduced by structure.

Choose the execution method, reduce sharing, protect what remains shared, and complete the path from stop request to join. Using jthread, scoped_lock, predicate wait, and atomic within this order is the practical baseline.

KomuraSoft LLC handles multithreading design reviews for C++ applications and DLLs, investigation of race-induced defects such as “it crashes once in a while” or “it only misbehaves in release builds” (dump analysis), and consultations on migrating legacy threading code to modern C++.

References

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. On CP.1 (assume that your code will run as part of a multi-threaded program) and CP.2 (avoid data races) being set out as the opening rules of the concurrency and parallelism chapter, on no guarantee holding once a data race exists, and on the design rules for concurrent code, such as the scope of lock holding and the use of RAII, being systematized there.  2 3 4 5 6

  2. cppreference.com, std::jthread. On C++20’s jthread differing from std::thread by automatically calling request_stop() and then join in its destructor, on its ability to receive a std::stop_token as the first argument of the thread function, and on this guaranteeing the join and the stop request even when an exception is thrown.  2 3

  3. Microsoft Learn, scoped_lock Class. On C++17’s scoped_lock acquiring one or more mutexes on construction and releasing them in its destructor, on multiple mutexes being acquired with a deadlock-avoidance algorithm equivalent to std::lock, on release being guaranteed even when an exception is thrown, and on lock_guard/unique_lock also being options for a single mutex.  2 3

  4. Microsoft Learn, <condition_variable>. On waiting on a condition variable requiring a mutex that is released during the wait, on the existence of spurious wakeups without a notification meaning that the waiter should explicitly recheck the condition on return, on the predicate form wait(lock, pred) taking over that loop, and on condition_variable_any being combinable with any mutex type.  2 3

  5. Microsoft Learn, <atomic>. On atomic operations being indivisible so that other threads can observe only the state before or after the operation, on establishing ordering requirements for visibility relative to other atomic operations based on the memory_order argument and suppressing compiler optimizations that would violate them, on atomic_flag always being lock-free, and on this header being blocked under /clr:pure.  2 3

  6. Microsoft Learn, <future>. On the destructors of future and shared_future not blocking as a rule, with the single exception that the future (or the last shared_future) tied to a task launched by std::async blocks until the shared state becomes ready if its destructor runs while the task is incomplete, and on this behavior being stated explicitly in a note in the standard. 

  7. Microsoft Learn, Best Practices in the Parallel Patterns Library. On expressing parallelism at the highest level possible (the outer loop), on the fork/join scheduling overhead in parallel loops with small or unbalanced per-iteration work potentially outweighing the gain from parallel execution, and on that tendency growing stronger as the processor count increases. 

  8. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. On the C++17 parallel algorithms library being complete, while “complete” does not mean that every algorithm is parallelized in every case; on the implementation policy that the most important algorithms are parallelized and that execution-policy signatures are provided even for those that are not. 

  9. cppreference.com, std::thread::~thread. On std::thread’s destructor calling std::terminate when invoked while the thread is still joinable (neither joined nor detached), meaning that the decision to join or detach must be made before the thread object is destroyed. 

  10. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. On P0660R10 (<stop_token> and jthread) and P1135R6 (the C++20 synchronization library) being supported in Visual Studio 2019 16.9, and on the version-by-version support status of C++ standard library features. 

  11. Microsoft Learn, C++ standard library header files. On the multithreading-related standard headers being organized as <atomic> (C++11), <mutex> (C++11), <shared_mutex> (C++14), <condition_variable> (C++11), <future> (C++11), <stop_token>, <semaphore>, <latch>, <barrier> (C++20), and <thread> (C++11). 

  12. Microsoft Learn, About Synchronization. On the guidance for choosing Win32 synchronization primitives: std::mutex / std::shared_mutex with RAII being recommended for portability-focused C++ code, Win32 synchronization objects being used when Win32 wait APIs or cross-process synchronization are needed, the default for new in-process code being the SRW lock with CRITICAL_SECTION only when recursive acquisition is needed, and using a Mutex for in-process synchronization being a “common mistake” that always involves a kernel transition.  2 3

  13. Microsoft Learn, Dynamic-Link Library Best Practices. On DllMain being called while the loader lock is held and therefore facing serious restrictions on the APIs it can call, on synchronizing with other threads inside DllMain potentially deadlocking, on LoadLibrary calls and waiting for thread exit being typical prohibitions, on deferring initialization as much as possible and moving it out of DllMain, and on defining a lock hierarchy with the loader lock at the top. 

  14. Microsoft Learn, <thread>. On the <thread> header defining the thread class and helper functions such as sleep_for, on this header being blocked in code compiled with /clr, and on the STDCPP_THREADS macro indicating whether thread support is available. 

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.

How should I choose between std::mutex and Win32's CRITICAL_SECTION and SRW locks?
For ordinary C++ code that values portability, std::mutex / std::shared_mutex with the RAII wrappers (lock_guard / scoped_lock) are the first choice. Choose Win32 synchronization objects when you want to combine them with a Win32 wait API such as WaitForMultipleObjects, or when you need cross-process synchronization through named objects. If you use the Win32 API directly within a process, the default for new code is the SRW lock, and CRITICAL_SECTION is used only when the same thread needs recursive acquisition. Using a Win32 Mutex for in-process exclusion is a classic mistake: it always involves a kernel transition and is slow.
Is it acceptable to use std::thread's detach()?
As a rule, avoid it. A detached thread loses any means of being joined, and you can no longer control whether it is still running when the process exits. A detached thread that keeps running after static variables or the heap have been destroyed and causes a crash at exit is a classic failure. Being able to wait for the end is a basic requirement of thread design, so use jthread (automatic join) or, with thread, structure the code so that it always joins before the scope ends. detach is permissible only in the limited situation where the thread may share the process's fate and you can guarantee that it never touches shared state at all.
Can volatile be used for synchronization between threads in C++?
No. In C++, volatile is a qualifier for reads and writes you do not want the compiler to optimize away, such as memory-mapped I/O; it does not guarantee inter-thread visibility or ordering. If multiple threads access the same variable without synchronization, that is a data race and undefined behavior. Use std::atomic for flags and counters shared between threads, and std::mutex to guard several variables together. std::atomic provides both the indivisibility of operations and ordering based on memory_order.
std::async looks convenient. Are there pitfalls?
The biggest pitfall is the future's destructor. The future (or the last shared_future) tied to a task launched with std::async blocks until completion if its destructor runs while the task is still incomplete. If you discard the returned future without holding it, that amounts to synchronous execution on the spot, and you get the failure where it was meant to be asynchronous but runs serially. Also, whether the task really runs on another thread when no launch policy is specified is at the implementation's discretion. If you use it, manage the future's lifetime explicitly, and specify std::launch::async where you need concurrent execution for certain.

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