Practical Multithreading Best Practices: C++ Edition — Designing Failures Out with RAII and jthread
· Updated: · Go Komura · 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.
flowchart TB
accTitle: Example of a lost update to a shared counter
accDescr: Schematically shows how an update is lost when two threads read the same value, each add to it, and write it back.
A["count is 10"] --> B["Both A and B read 10"]
B --> C["Each adds to 11 locally"]
C --> D["A writes back 11"]
D --> E["B also writes back 11"]
E -.-> F["In 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.
flowchart TB
accTitle: Circular wait on two locks
accDescr: When each side waits for the lock the other holds, neither can move on.
A["A holds lock 1"] -->|"waits for lock 2"| B["B holds lock 2"]
B -->|"waits for lock 1"| A
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 |
flowchart TB
accTitle: Choose the execution method, then decide the lifetime
accDescr: Choose an execution method that fits the shape of the work, and decide how results and stopping are managed for that method.
A["Check the shape of the work"] --> B{"What is being executed"}
B -->|"One-off or collection processing"| C["Tasks or parallel algorithms"]
B -->|"Long-running processing"| D["Manage the worker's lifetime"]
B -->|"Waiting on I/O"| E["Consider asynchronous I/O"]
C --> F["Design results, exceptions, and termination"]
D --> F
E --> F
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.
flowchart TB
accTitle: async launch policy and the lifetime of the future
accDescr: Distinguishes blocking on destruction when launched with async from the case where nothing runs under deferred unless someone waits.
A["Call std::async"] --> B{"Launch policy chosen"}
B -->|"async"| C["Runs on another thread"]
C --> D["Last future is destroyed"]
D -.-> E["Waits for completion if unfinished"]
B -->|"deferred"| F["Execution deferred until get or wait"]
F -.-> G["Never 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
flowchart TB
accTitle: Destroying a thread object and joining
accDescr: Destroying a joinable thread leads to terminate, while destroying a jthread issues a stop request and joins.
A["Destroy the thread object"] --> B{"Is it joinable"}
B -->|"No"| C["Nothing to join"]
B -->|"Yes"| D{"Which type is it"}
D -->|"thread"| E["std::terminate"]
D -->|"jthread"| F["Issue a stop request and join"]
F -.-> G["The 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.
flowchart TB
accTitle: Merge per-thread subtotals at the end
accDescr: Instead of updating the shared total every time, each thread's subtotal is applied to the total exactly once at the end.
A["Partition the input"] --> B["Thread A's subtotal"]
A --> C["Thread B's subtotal"]
B --> D["Synchronize and merge at the end"]
C --> D
D --> E["Shared 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.
flowchart TB
accTitle: Copying a pointer still shares the referenced object
accDescr: When a value containing a pointer is copied, the two variables are distinct but the referenced object stays the same.
A["Pointer in the original value"] --> C["The same referenced object"]
A -.->|"Copy the pointer value"| B["Pointer in the copy"]
B --> C
C -.-> D["Synchronization 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
flowchart TB
accTitle: Queue with a capacity limit and wait release
accDescr: The producer waits for room when full, the consumer waits for an item when empty, and the stop request reaches both waits.
A["Producer"] --> B["If full, wait for room"]
B --> C["Queue with a capacity limit"]
C --> D["If empty, wait for an item"]
D --> E["Consumer"]
S["Stop request"] -.-> B
S -.-> D
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.
flowchart TB
accTitle: Prepare outside the lock and swap inside
accDescr: Time-consuming preparation moves outside the lock, and only the change to the shared data happens in a short locked region.
A["Prepare outside the lock"] --> B["Acquire the lock with RAII"]
B --> C["Modify the shared data"]
C --> D["Leave the scope and release"]
D --> E["Perform 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.
flowchart TB
accTitle: An atomic pointer swap alone does not protect lifetime
accDescr: The old pointer a reader obtained loses its referenced object when the writer deletes it after the swap.
A["Reader obtains the old pointer"] --> B["Writer swaps the pointer"]
B --> C["Writer deletes the old object"]
C --> D["Reader accesses the old object"]
D --> E["Access to freed memory"]
B -.-> F["Indivisibility 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.
flowchart TB
accTitle: Cooperative stopping makes everything up to join one path
accDescr: The owner requests a stop, computation and waits observe it and exit, and completion of join confirms the stop.
A["Owner requests a stop"] --> B["Conveyed to the stop_token"]
B --> C["Check the request while computing"]
B --> D["Release the corresponding wait"]
C --> E["Clean up and return"]
D --> E
E --> F["Owner'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.
flowchart TB
accTitle: Worker member destruction order and the queue's lifetime
accDescr: The jthread declared later is destroyed first, and the queue the worker uses is destroyed after the join completes.
A["Destroy Worker"] --> B["Destroy thread_, declared later"]
B --> C["Stop request and join"]
C --> D["Confirm the worker has exited"]
D --> E["Destroy 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 |
flowchart TB
accTitle: Exception boundaries for one item and for the whole thread
accDescr: The failure of one item and failures such as queue operations are caught at separate boundaries, and no exception escapes from the thread function.
A["Worker loop"] --> B["Take from the queue"]
B --> C["Process one item"]
C -.->|"Exception from one item"| D["Record in the inner boundary and continue"]
D --> A
B -.->|"Exception from taking, etc."| E["Record in the outer boundary and exit"]
E -.-> F["The 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 |
flowchart TB
accTitle: Choosing between standard synchronization and Win32 integration
accDescr: Ordinary C++ code defaults to standard synchronization, and Win32 objects are chosen when Win32 waits or cross-process synchronization are needed.
A["Check the synchronization requirements"] --> B{"Win32 wait or cross-process"}
B -->|"No"| C["Standard mutex and RAII by default"]
B -->|"Yes"| D["Choose Win32 objects"]
C -.-> E["SRW 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.
flowchart TB
accTitle: Separate DLL thread handling into explicit functions
accDescr: Do not synchronize in DllMain while the loader lock is held, and separate thread startup and waiting for exit into outer functions.
A["DllMain"] --> B["Loader lock held"]
B --> C["No starting, synchronizing, or joining here"]
D["Explicit function outside DllMain"] --> E["Manage 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.
flowchart TB
accTitle: Circular wait from synchronous notification between UI and worker
accDescr: When the UI waits for the worker to finish while the worker waits for SendMessage to be handled, a circular wait results.
A["UI thread"] -->|"Waits for the worker to finish"| B["Worker thread"]
B -->|"Waits for SendMessage to complete"| A
C["Notification from the worker"] --> D["Delegate with PostMessage"]
D --> E["Handled 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.
flowchart TB
accTitle: Prepare the design and observation, then stress test
accDescr: Confirm sharing and stopping in the design, make them observable with logs and dumps, and then test with varied load and execution order.
A["Check sharing, locks, lifetime, and stopping"] --> B["Prepare logs and dumps"]
B --> C["Test with varied load and execution order"]
C --> D["Feed the problems found back into the design"]
D --> A
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.
- Is a bare
std::threadused anywhere (could it be ajthread, and is join guaranteed on exception paths too)? - Is
detach()used anywhere? - Are lambda captures explicit, and do reference-captured variables outlive the thread?
- Can you state that there is not a single unsynchronized shared mutable access (= undefined behavior)?
- Is there no hand-written
lock()/unlock(), and are multiple locks taken together withscoped_lock? - Does every
condition_variable::waithave a predicate? - Is
volatileused for a shared flag (has it been made astd::atomic)? - Is the stop path designed with
stop_token(or an atomic flag plus notification), and is completion of the join confirmed? - Is a
std::asyncfuture discarded anywhere? - Does
DllMainstart, 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.
Related Articles
- Practical Multithreading Best Practices: .NET Edition
- Practical Multithreading Best Practices: C Edition
- Practical Multithreading Best Practices: Java Edition
- Wrapping Native DLLs with C++/CLI in Practice
- Shared Memory Pitfalls and Practical Best Practices
- COM STA/MTA Fundamentals - Threading Models and How to Avoid Hangs
- The Depths of Windows I/O (Part 2) — Synchronous and Asynchronous I/O: What OVERLAPPED Really Means
Related Consulting Areas
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++.
- Technical Consulting and Design Review
- Bug Investigation and Root Cause Analysis
- Windows App Development
- Contact
References
-
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
-
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
-
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
-
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
-
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
-
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. ↩
-
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. ↩
-
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. ↩
-
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. ↩
-
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. ↩
-
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). ↩
-
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
-
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. ↩
-
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. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Practical Multithreading Best Practices: C Edition — Writing Safely the Win32 API Way
In C on Win32 the established practice is _beginthreadex, SRW locks and condition variables, Interlocked, and a stop event plus WaitForMu...
Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
Keep .NET/C# threads from crashing or hanging. Ride on Task, cut shared mutable state, lock with discipline, stop with CancellationToken,...
DllMain and the Loader Lock — The Real Reason You Are Told to "Do Nothing in DLL Initialization"
Why DllMain must not call LoadLibrary or wait on threads: the loader lock serializes DLL notifications, typical deadlocks, deferred initi...
Spurious Wakeups — Why a Condition Variable Wakes "Without a Notification" and How to Wait Correctly on Windows
Condition variable waits can wake with no notification (spurious wakeups). Why Windows allows it, and the correct while-and-predicate wai...
Practical Multithreading Best Practices: Java Edition — Conventions for the Virtual Thread Era
Run Java tasks on ExecutorService and virtual threads, not raw threads. Covers synchronized vs. ReentrantLock, cooperative stopping via i...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Bug Investigation & Long-Run Failures
Topic page for intermittent failures, communication diagnosis, long-run crashes, and failure-path test foundations.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Bug Investigation & Root Cause Analysis
We investigate difficult production issues such as intermittent failures, long-run crashes, leaks, and communication stoppages.
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.