Practical Multithreading Best Practices: C++ Edition — Eliminating Accidents by Structure with RAII and jthread
· Go Komura · Windows, Multithreading, C++, Visual Studio, Business Applications, Bug Investigation, Design
“A design that worked fine in C# started crashing occasionally once we ported it to C++.” “We used std::thread, and when an exception was thrown the whole application died instantly via terminate.” “We were stopping things with a volatile bool flag, but in release builds only, it wouldn’t stop.” — C++ multithreading carries a danger that managed languages simply don’t have: a data race is, as-is, undefined behaviour (UB). It isn’t just that you might read a corrupted value; the compiler’s optimisation assumptions collapse, and you end up in a state where literally anything can happen.
This article is the C++ edition of our practical multithreading series. Aimed at developers writing business applications, equipment control software, and DLLs in modern C++ (C++17/20), it translates the general principles of multithread design — don’t add threads directly, reduce shared mutable state, apply lock discipline, design how things stop before anything else — into C++ and Windows tooling, alongside pitfalls specific to C++, all grounded in primary sources as of August 2026. It is written to stand on its own. The same principles worked out for other languages are also covered in the “.NET Edition”, “C Edition”, and “Java Edition”.
1. The Bottom Line First
- In C++, a data race is not “you might read a corrupted value” — it’s undefined behaviour. Leaving not a single unsynchronised shared mutable access anywhere in the code is an absolute requirement, more so than in other languages.1
- Don’t use
std::threadbare. If astd::thread’s destructor runs while the thread is still joinable,std::terminatekills the process instantly. C++20’sstd::jthreadautomatically joins in its destructor and has a stop request mechanism (stop_token) built in.23 - Always hold locks through RAII. Stop hand-writing
mtx.lock(); uselock_guard/scoped_lockinstead. The destructor reliably releases the lock even if an exception is thrown. When acquiring several locks at once,scoped_locktakes care of it with a deadlock-avoidance algorithm.4 volatileis not a synchronisation tool. Usestd::atomicfor shared flags and counters, andstd::mutexto protect multiple variables together.std::atomicprovides both atomicity and ordering based onmemory_order.5- Do rendezvous waiting with
condition_variable’s predicate-formwait. Condition variables are subject to spurious wakeups (waking without being notified), so callingwaitwithout a predicate is a breeding ground for bugs.6 jthread+stop_token(C++20) is the basic shape for how to stop a thread. In environments predating that, build cooperative stopping by hand withstd::atomic<bool>plus a condition variable. Treat forcible thread termination as something that simply does not exist in the C++ world.3- Know that a
future’s destructor can block before you usestd::async. Discard the return value and you end up with the same effect as serial execution.7 - Win32 synchronisation objects only earn their place for “working with Win32 wait APIs” and “cross-process” scenarios. Everywhere else, writing against the standard library is the better choice for portability and maintainability.8
2. Why Multithreading Is Hard — Race Conditions, Deadlocks, and Undefined Behaviour
Boiled down, the problems multithreading introduces come in two kinds, regardless of language.
A race condition is a bug where the outcome depends on the order in which multiple threads reach a particular piece of code. The classic example is a shared counter: the single expression ++count breaks down at the machine-code level into three steps — read, add, write back. If two threads enter those three steps at the same time, one thread’s addition gets overwritten and lost by the other’s write-back. The result changes from run to run, and which result you get is unpredictable.
sequenceDiagram
participant A as Thread A
participant M as Shared variable count
participant B as Thread B
Note over M: count = 10
A->>M: Read (10)
B->>M: Read (10)
A->>A: Add locally (11)
B->>B: Add locally (11)
A->>M: Write back (11)
B->>M: Write back (11)
Note over M: Two increments happened,<br/>yet count = 11 — Thread A's addition was lost
Figure 1: A classic race condition in which an increment on a shared counter is lost. If another thread interleaves during the three steps of ++count, whichever write-back happens last overwrites the other
A deadlock is a state where two threads each wait on a lock the other holds, so neither can proceed. Thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1 — that alone is enough for both to stop forever.
flowchart LR
A["Thread A<br/>holding lock 1"] -->|"waiting to acquire lock 2"| B["Thread B<br/>holding lock 2"]
B -->|"waiting to acquire lock 1"| A
Figure 2: The circular wait of a deadlock. The moment the waiting arrows form a ring, every thread in that ring stops forever
What makes both of these awkward is that they are timing-dependent. An interleaving that only hits once in tens of thousands of runs on a development machine can happen every single day on a customer’s machine, with a different core count and different timing. “It doesn’t reproduce with a debugger attached” and “it went away when I added logging” both happen because observation itself changes the timing — that is classic race-bug behaviour. That is exactly why every principle in this article points in one direction: reduce the places that need synchronisation, before worrying about synchronising them correctly.
2.1. In C++, a Data Race Is Directly Undefined Behaviour
On top of that, C++ has a further layer that other languages don’t. Under the C++ standard, if multiple threads access the same memory location without synchronisation and at least one of them writes, that is a data race, and it is undefined behaviour. The concurrency chapter of the C++ Core Guidelines (CP.2, “Avoid data races”) states this as the very first absolute rule.1 Undefined behaviour is not the mild story of “you might read either the old value or the new one.” The compiler optimises on the premise that no data race exists, so behaviour that you could never predict from the source code — a condition check vanishing from a loop, writes being reordered or coalesced — legitimately occurs. The classic accident where “a volatile bool stop flag only fails to work in release builds” is a textbook case of exactly this.
2.2. RAII Is the Foundation
Another premise specific to C++ is exceptions and resource management. C++ has no finally; instead it has RAII (automatic release through destructors), and the multithreading toolset is designed on the assumption that you’ll use it. “Manage locks through an object’s lifetime”; “guarantee thread joining through an object’s lifetime too” — going along with that convention is the foundation for writing multithreaded C++ safely.
3. How to Start a Thread — the thread Trap, and jthread
3.1. The std::thread Destructor Is “Designed to Cause Accidents”
std::thread has a well-known trap. If its destructor runs while the thread is still joinable (neither joined nor detached), std::terminate is called and the process dies instantly.9
void process()
{
std::thread worker([]{ HeavyWork(); });
DoSomething(); // ← if an exception is thrown here...
worker.join(); // ← join is never reached; worker's destructor calls terminate
}
Making this exception-safe required guaranteeing the join with try/catch — a distorted state of affairs in a RAII language where threads alone had to be managed by hand. C++20’s std::jthread solves this. Because its destructor automatically issues a stop request and then joins, the code above becomes exception-safe simply by switching to std::jthread.2 On MSVC, <stop_token> and jthread are available from Visual Studio 2019 16.9 onwards.3
flowchart TB
T["Thread has been started"] --> Q{"What happens<br/>when the scope exits?"}
Q -->|"std::thread<br/>neither joined nor detached"| X["std::terminate<br/>process dies instantly"]
Q -->|"std::thread<br/>already joined"| OK1["Joins safely"]
Q -->|"std::jthread - C++20"| OK2["Automatic request_stop + join<br/>safe even if an exception is thrown"]
Figure 3: The lifetime of a thread object and how it ends. std::thread is specified to die instantly if you forget to join, so from C++20 onward, make jthread the default
As a rule, don’t use detach(). A thread that has lost any means of joining becomes a classic cause of shutdown crashes, racing against the destruction of static variables and the heap when the process exits.
3.2. Tools “Above the Thread Level” — async, future, and Parallel Algorithms
The .NET edition’s principle of “don’t create threads yourself” maps onto the following tools in C++.
std::async+std::future: for a one-off async task and receiving its result. There is, however, an important quirk: thefuture(or the lastshared_future) tied to a task launched viastd::asyncblocks until completion if its destructor runs while the task is still incomplete.7 For work actually launched withstd::launch::async, discarding the returned future is equivalent to synchronous execution at that point. Worse, if you don’t specify a launch policy, the implementation is free to choosedeferred(lazy execution) by default, in which case, if nobody callsget()/wait(), the work is never executed at all and silently vanishes. If you want to guarantee concurrent execution, specifystd::launch::asyncexplicitly, and have the owner manage the future’s lifetime.- PPL - Parallel Patterns Library -
concurrency::parallel_for/parallel_for_each: applying work in parallel across every element of a collection. However, if the work in a single iteration is too small, fork/join overhead eats up the gains, so as a rule, parallelise at the outer loop.10 - C++17’s parallel algorithms -
std::execution::par: on MSVC, the major algorithms are parallelised (not all of them).11 Note that if an exception escapes element processing under an execution policy,std::terminateis called. Placing your own exception boundary (try/catch) inside the callback follows the same thinking as the thread boundary in Section 6.
The line drawn elsewhere — “waiting on I/O is not something you solve by adding threads” — still applies without change. For native Windows code, OVERLAPPED I/O and IOCP are the tools that catch that work (for the mechanics, see “The Depths of Windows I/O, Part 2”).
4. Reducing Shared Mutable State — Splitting, Passing by Value, const, and Queues
Contention only arises when “multiple threads” and “shared mutable data” are both present. The number of threads is dictated by requirements, so what design can cut down is the sharing. The means fall into three families — splitting, making things immutable, and handing data off — and here is how you write each in C++.
Split. In work like parallel aggregation, rather than having every thread write into a shared total, give each thread its own local subtotal and merge them once, at the end. Writes to the shared value drop from “every iteration” to “once per thread”, cutting both the synchronisation cost and the window for contention by orders of magnitude. That single merge step can be done with a std::mutex or with a fetch_add on a std::atomic — either is fine.
Pass by value. If you hand the data a thread needs to it by copy (or move) at start-up, that data becomes exclusively the thread’s, and no synchronisation is needed. Capturing lambdas by reference ([&]) and then touching a variable whose lifetime has ended is a common accident, so lambdas passed to threads should use explicit captures, by copy or move as a rule. That said, “copied, therefore exclusive” only holds when the value is a deep value graph containing no aliases such as pointers or shared_ptr. Copying a struct that contains a raw pointer still leaves whatever it points to shared.
Share as const. Data that is only ever read is safe to read from any number of threads at once. Configuration values, master data, computation inputs, and the like can be shared without synchronisation if you make them a const share that isn’t rewritten after construction (std::shared_ptr<const Config>, for example). One caveat: what shared_ptr<const T> forbids is only mutation through that particular handle. If a non-const alias survives somewhere else, or a mutable member gets rewritten, contention remains — so design for that too, right down to “once construction is finished, release the non-const reference and let nobody write to it afterwards.” Simply deciding that “when a change is needed, build a new object and swap it in, rather than mutating in place” removes one piece of mutable state you’d otherwise have to guard (for the lifetime management of the swap itself, see the caution in Section 5.2).
Hand off through a queue. Route the flow of data between threads through a producer/consumer queue rather than a shared variable. The C++ standard has no channel type, so writing a small queue with std::mutex + std::condition_variable is the established pattern.
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");
}
// Waits until there is room (or a stop request) if full. 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 happen together, prefer stop,
return false; // and refuse pushes once stopping has begun
queue_.push(std::move(item));
}
not_empty_.notify_one(); // notify outside the lock
return true;
}
// Waits for a stop request (stop_token) or an item to arrive. nullopt when stopped.
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 happen together, prefer stop,
return std::nullopt; // and don't start new work once stopping has begun
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_; // condition_variable_any, to use the stop_token-aware wait
std::condition_variable_any not_full_;
std::queue<T> queue_;
};
There are two design points here. First, cap the capacity and make the producer side wait when full. A queue with no upper bound becomes a time bomb in setups where production outruns consumption: it “keeps running”, but memory keeps growing. Having Push block when full acts as natural backpressure, mechanically propagating overload upstream. Second, because condition variables are subject to spurious wakeups (waking without a notification), always call wait with a predicate. The predicate form of wait runs the “loop until the condition is true” logic for you internally.6
5. Lock Discipline — RAII and scoped_lock
Even after reducing shared mutable state, you often can’t get it to zero. Use exclusion for what remains shared, but locking without discipline only hides contention.
First, think of the unit of locking not as a “stretch of code” but as “data”. Assign one mutex to each set of mutable data you want to protect (make it a private member, not exposed outside), and take that same mutex at every place that touches that data — a broken version of this correspondence table is what most race bugs actually are. And the only thing you may do while holding a lock is read and write the data it protects. File I/O, network calls, and callbacks (calls into external code) while holding a lock don’t just extend how long you hold it — they open a path where the callee tries to take a different lock and deadlocks. Prepare outside the lock, and inside the lock do nothing but swap it in is the basic shape.
5.1. Hand-Writing lock()/unlock() Is Forbidden
Code that calls std::mutex’s lock() / unlock() directly ends up failing to release the lock on exceptions or early returns. Always leave lock acquisition and release to an RAII wrapper.
| Wrapper | Use |
|---|---|
std::lock_guard |
Holds a single mutex for exactly the duration of a scope — the most basic form |
std::scoped_lock (C++17) |
Acquires several mutexes at once. Solves the ordering problem with a deadlock-avoidance algorithm4 |
std::unique_lock |
For when you want to unlock and relock partway through, or need to pass it to condition_variable::wait |
When there are two or more locks, having the acquisition order swap depending on the thread is the classic deadlock pattern (the circular wait in Figure 2 is born exactly this way). The fix is to make a rule that “every thread acquires locks in the same order”, but when you’re acquiring them at the same time, C++ has a better answer: hand several mutexes to std::scoped_lock together and the library guarantees deadlock-free acquisition order for you.4 In situations like a transfer between two objects where you want “both locked”, never take them individually — always take them together.
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 together; the library resolves the order
from.balance -= amount;
to.balance += amount;
}
The identity check at the top is not decorative. If the same Account is passed as both from and to, you end up passing the same non-recursive mutex to scoped_lock twice, which causes a hang or undefined behaviour. Always attach a same-object exclusion to any function that “locks both”.
For data that is “read often, written rarely”, you can use std::shared_mutex (C++17) as a read/write lock.12 recursive_mutex is a type designed so that “the same thread re-acquiring it doesn’t break”, but a design that needs recursive acquisition is often a sign that a lock’s responsibility boundary has become blurred — consider revisiting the structure first.
5.2. The Correct Role of atomic
std::atomic provides atomic operations on a single variable, plus ordering based on memory_order.5 It earns its place in the same situations as Interlocked in the .NET edition: updating a single variable, such as a counter or flag. It cannot keep several variables consistent together, so for that you fall back to std::mutex.
Swapping a raw pointer (std::atomic<T*>) has a trap all its own. Even though the swap itself is atomic, nobody protects the old object’s lifetime once it has been replaced. If a reader loads the old pointer just before the writer swaps it out and deletes it, you get an access to freed memory. If you want to do a “swap and share an immutable object” design in C++, choose a means that comes paired with lifetime management — swapping a lock-protected std::shared_ptr<const T>, or C++20’s std::atomic<std::shared_ptr<T>>.
And to repeat: volatile is not a thread-synchronisation tool. Lock-free programming where you specify memory_order yourself is expert territory, requiring both a legitimate reason to relax it from the default (seq_cst) and a way to verify that you’ve done so correctly. In business applications, either use the default or just write it with mutex in the first place.
6. Designing How to Stop — stop_token and Cooperative Stopping
The first question to ask when reviewing a multithreaded design is “how does this stop?” And C++ has no means of safely stopping a thread from outside (just how dangerous Win32’s TerminateThread is gets covered in detail in the C Edition). So how a thread stops has to be built with C++’s tools around cooperative stopping — the stopping side only issues a request; the thread itself decides when and how to end, at a point that leaves things tidy; and completion of the join is what counts as “stopped”.
In C++20, std::jthread has the stopping mechanism built in. Calling request_stop() raises the stop request on the std::stop_token that the thread function received, and the loop polls it. condition_variable_any’s wait can take a stop_token directly, so a “thread waiting for work to arrive” can also be woken instantly by a stop request (Section 4’s BlockingQueue::Pop takes exactly this shape).
class Worker {
public:
void Start()
{
if (thread_.joinable()) // Reject a double Start while already running.
throw std::logic_error("already running"); // If we assigned instead of rejecting, a new
// thread would start running, and while it waits
// for the old thread to stop, two workers would
// end up running side by side
thread_ = std::jthread([this](std::stop_token st) {
try {
while (!st.stop_requested()) {
if (auto item = queue_.Pop(st)) { // wakes on a stop request too
try {
Process(*item, st); // pass st into work that can block internally too
} catch (...) {
ReportError(std::current_exception()); // log a single failure and keep going
}
}
}
} catch (...) {
// The last line of defence at the thread boundary (also catches failures
// in Pop or a move). If an exception escapes from here, std::terminate takes
// the whole process down, so make sure ReportError itself never throws
ReportError(std::current_exception());
}
});
}
// No explicit Stop is needed:
// Worker's destructor -> jthread's destructor -> request_stop() + join()
private:
BlockingQueue<WorkItem> queue_{100}; // capacity-capped (Section 4)
std::jthread thread_;
};
flowchart TB
OWNER["The stopping side<br/>- jthread's destructor, or request_stop"] -->|"stop request"| ST["stop_token"]
ST --> P["Compute loop:<br/>polls stop_requested()"]
ST --> W["Waiting thread:<br/>condition_variable_any::wait(lock, st, pred)<br/>wakes immediately"]
P --> E["Cleans up and returns on its own"]
W --> E
E --> J["join completes the rendezvous<br/>only now can we call it stopped"]
Figure 4: C++20 cooperative stopping. The stopping side only issues the request; the thread itself decides how it ends; completion of the join is what counts as stopped
One more point: the try/catch inside the worker cannot be omitted. What jthread makes exception-safe is the join, and only the join — if an exception escapes the thread function, std::terminate brings the process down, just as with std::thread. Decide explicitly, at the thread boundary, how to handle the failure of a single piece of work (log it and carry on, or report it to the owner over an error channel).
For the same reason, notice that Process is also passed the stop_token. If processing a single piece of work blocks internally (waiting on the network, a long computation, and so on) and that point cannot observe the stop request, the destructor’s implicit join will wait forever for that one item to finish. Cooperative stopping only holds together once the token has reached every place that waits. If the work includes an external call that cannot be interrupted, attach a timeout and put an upper bound on how long a single item is allowed to run.
In environments predating C++17, you build the same shape by hand with a std::atomic<bool> stop flag plus condition_variable’s notify_all. The key point here is to fold the stop-flag check into the condition variable’s predicate — if you just raise the flag and forget to notify, a waiting thread will never wake up.
7. Windows-Specific Concerns — the Boundary With the Win32 API
7.1. Choosing Between the Standard Library and Win32 Synchronisation Objects
Microsoft’s documentation recommends std::mutex / std::shared_mutex for C++ code that prioritises portability, and lays out Win32 synchronisation objects’ place as “when a Win32 wait API is needed” and “cross-process synchronisation”.8
| Situation | Choice |
|---|---|
| Ordinary intra-process exclusion | std::mutex + RAII (default) |
| Many reads, rare writes | std::shared_mutex |
Waiting on several objects at once with WaitForMultipleObjects |
Win32 kernel objects such as events and mutexes |
| Cross-process exclusion / notification | Named mutexes, events, semaphores |
| Intra-process locking using the Win32 API directly | SRW locks (CRITICAL_SECTION only when recursion is needed)8 |
For the concrete design of excluding access to shared memory across processes, see “Shared Memory Pitfalls and Practical Best Practices”.
7.2. Don’t Touch Threads Inside DllMain
A serious constraint when writing a DLL is the loader lock. DllMain is called while the loader lock is held, so operations inside it such as synchronising with another thread, waiting for a thread to end, or calling LoadLibrary cause deadlocks or unpredictable behaviour. Move any initialisation that starts or joins threads outside DllMain, into an explicit initialisation function.13
7.3. The UI Thread and COM Apartments
Windows desktop applications have a strong constraint that applies regardless of language: only the thread that created a window or control — the UI thread — may touch it. Windows delivers window messages to the message queue of the thread that created that window, so creating and manipulating the UI must be concentrated on that thread. When you want to update the screen from a worker thread, don’t touch it directly — ask the UI thread with PostMessage (asynchronous), and handle it in the window procedure on the UI thread’s side. Calling the synchronous form, SendMessage, while the UI thread is waiting for that worker to finish causes a deadlock where each waits on the other, so make the asynchronous form the default for notifications from a worker. STA/MTA, where COM is involved, is covered in “COM STA/MTA Fundamentals”. Also note that in C++/CLI code compiled with /clr, the standard thread headers such as <thread> and <mutex> are blocked.14
8. Verification and Debugging — Preparing on the Assumption It Won’t Reproduce
You cannot rely on testing to find race bugs, because an ordinary test counts a run that “happened not to race” as a pass. Think of the preparation in three layers.
The first line of defence is the design principles covered so far, exactly as they are. In review, confirm with a table: which mutable data is shared, which mutex protects each piece, whether the acquisition order of multiple locks is unique (or they’re taken together with scoped_lock), and where the stop path is. A design for which you cannot write this table is not finished, however well it currently runs.
Second, make abnormal states observable instead of hiding them. Attach a timeout with timed_mutex’s try_lock_for or condition_variable’s wait_for to any lock that ought never to fail to be acquired, and log a timeout as an anomaly — that turns an eternal hang into a detectable failure. Always log exceptions caught at the thread boundary’s try/catch (Section 6). When a hang or crash occurs in the field, capture a dump, check every thread’s stack, and look for whether their lock waits form a cycle. Setting up dumps and logging is covered in “Designing Logging and Dump Capture for Windows App Crashes”.
Third, shake things loose under load. Running for a long time with more parallelism than you have cores, randomising processing order, and injecting artificial delays are practical stress-testing techniques that make it easier to hit a race “jackpot” on a development machine. Bugs that disappear in a debug build often reproduce readily in an optimised release build under heavy load.
9. Summary — the C++ Checklist
Layer C++-specific checks on top of the principles common to every language: don’t create threads directly, minimise shared mutable state, a one-to-one correspondence between locks and data, and cooperative stopping.
- Is
std::threadbeing used bare (could it be ajthread? Is join guaranteed even on the exception path?) - Is
detach()not being used? - Are lambda captures explicit, and does any reference-captured variable outlive the thread?
- Can you say with confidence that there is not a single unsynchronised shared mutable access (= undefined behaviour) anywhere?
- Is there no hand-written
lock()/unlock(), and are multiple locks taken together withscoped_lock? - Is every
condition_variable::waitused with a predicate? - Is
volatilenot being used for a shared flag (is itstd::atomicinstead)? - Is the stop path designed around
stop_token(or an atomic flag plus notification), with completion of the join confirming the rendezvous? - Is the
futurefromstd::asyncnot being discarded? - Is
DllMainfree of starting, synchronising, or joining threads?
Multithreaded C++ is work done walking right alongside the cliff edge of undefined behaviour, but turn that around and it means that simply going along honestly with RAII and the standard library’s conventions puts real distance between you and that edge. jthread, scoped_lock, predicate-form wait, atomic — choosing the right defaults among these tools is, in C++, the practice of the design principles itself.
Related Articles
- Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
- Practical Multithreading Best Practices: C Edition — Writing Safely the Win32 API Way
- Practical Multithreading Best Practices: Java Edition — Conventions for the Virtual Thread Era
- Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke
- 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 multithread design reviews for C++ applications and DLLs, root-cause investigation (dump analysis) of race-condition bugs such as “crashes occasionally” or “only misbehaves in release builds”, and consulting on migrating legacy threaded code to modern C++.
- Technical Consulting & Design Review
- Bug Investigation & Root-Cause Analysis
- Windows Application Development
- Contact Us
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 at all once a data race exists; and on the design rules for concurrent code — the scope over which locks are held, the use of RAII, and so on — being systematised there. ↩ ↩2
-
cppreference.com, std::jthread. On C++20’s jthread differing from std::thread in that its destructor automatically calls request_stop() and then joins; on being able to receive a std::stop_token as the thread function’s leading argument; and on this guaranteeing both the join and the stop request even when an exception is thrown. ↩ ↩2
-
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 from Visual Studio 2019 16.9; and on the version-by-version support status of C++ standard library features. ↩ ↩2 ↩3
-
Microsoft Learn, scoped_lock Class. On C++17’s scoped_lock acquiring one or more mutexes at construction and releasing them in its destructor; on multiple mutexes, when passed together, being acquired with a std::lock-equivalent deadlock-avoidance algorithm; on it releasing reliably even if an exception is thrown; and on lock_guard/unique_lock also being an option when only a single mutex is involved. ↩ ↩2 ↩3
-
Microsoft Learn, <atomic>. On atomic operations being indivisible, so other threads can only observe the state before or after the operation; on establishing, based on the memory_order argument, ordering requirements over the visibility of other atomic operations, and suppressing compiler optimisations that would violate them; on atomic_flag always being lock-free; and on this header being blocked under /clr:pure. ↩ ↩2
-
Microsoft Learn, <condition_variable>. On waiting on a condition variable requiring a mutex, with the lock released for the duration of the wait; on spurious wakeups existing — waking with no notification — so the waiting side should explicitly re-check the condition on return, and the predicate form wait(lock, pred) performs that loop on your behalf; and on condition_variable_any being combinable with any mutex type. ↩ ↩2
-
Microsoft Learn, <future>. On the destructors of future and shared_future not blocking as a rule, with the sole exception that the future (or last shared_future) tied to a task launched with std::async blocks until the shared state becomes ready if its destructor runs while the task is still incomplete — a behaviour explicitly noted in the standard. ↩ ↩2
-
Microsoft Learn, About Synchronization. On guidance for choosing Win32 synchronisation primitives: std::mutex / std::shared_mutex and RAII being recommended for C++ code that prioritises portability; Win32 synchronisation objects being used when a Win32 wait API or cross-process synchronisation is needed; the default for new intra-process code being an SRW lock, with CRITICAL_SECTION reserved for when recursive acquisition is needed; and using Mutex for intra-process synchronisation being a “common mistake” because it always involves a kernel transition. ↩ ↩2 ↩3
-
cppreference.com, std::thread::~thread. On std::thread’s destructor calling std::terminate if it is called while the thread is still joinable (neither joined nor detached) — that is, on the decision to join or detach having to be settled before the thread object is destroyed, without exception. ↩
-
Microsoft Learn, Best Practices in the Parallel Patterns Library. On parallelism ideally being expressed at as high a level as possible (the outer loop); on fork/join scheduling overhead being able to outweigh the gains of parallel execution in parallel loops where each iteration’s work is small or unbalanced; 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 every algorithm is parallelised in every case; on the implementation policy of parallelising the most important algorithms while still providing the execution-policy signatures for those that are not. ↩
-
Microsoft Learn, C++ standard library header files. On the multithreading-related standard headers being laid out 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, Dynamic-Link Library Best Practices. On DllMain being called while the loader lock is held, imposing serious constraints on which APIs it may call; on synchronising with another thread inside DllMain being able to deadlock; on calling LoadLibrary or waiting for a thread to end being typical prohibited actions; on initialisation ideally being deferred as far as possible and moved outside DllMain; and on defining a lock hierarchy with the loader lock placed 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 letting you determine whether thread support is present. ↩
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
The established approach to multithreading in C with Win32 is thread creation via _beginthreadex, SRW locks and condition variables, Inte...
Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
A practical rundown of the design rules that keep multithreaded .NET/C# code from occasionally crashing or hanging: ride on Task instead ...
Practical Multithreading Best Practices: Java Edition — Conventions for the Virtual Thread Era
In Java, the established practice for multithreading is never to create threads directly but to build on ExecutorService and virtual thre...
Volume Shadow Copy (VSS): The Mechanism and the Practice — Why Backup Software Can Copy Files That Are Still in Use
Files that are in use can't normally be copied because of sharing violations — so how does backup software manage it? This article explai...
Windows App Outsourcing and Contract Development: What to Sort Out Before You Ask
Before commissioning Windows app outsourcing or contract development, here is how to sort out existing software modification, device inte...
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 / SRW locks?
- For ordinary C++ code that prioritises portability, std::mutex / std::shared_mutex together with RAII wrappers (lock_guard / scoped_lock) are the first choice. Reach for Win32 synchronisation objects when you need to combine them with a Win32 wait API such as WaitForMultipleObjects, or when you need cross-process synchronisation through a named object. If you're using the Win32 API directly within a process, the default for new code is an SRW lock, and you use CRITICAL_SECTION only when the same thread needs to acquire it recursively. Using a Win32 Mutex for intra-process exclusion is a classic mistake, since it always involves a kernel transition and is correspondingly slow.
- Is it okay to use std::thread's detach()?
- As a rule, avoid it. A detached thread loses any means of being joined, and you lose control over whether it is still running when the process exits. It's a classic accident: a detached thread keeps running after static variables or the heap have been destroyed, and causes a shutdown crash. Being able to wait for a thread to finish is a basic requirement of thread design, so use jthread (which joins automatically), or, if you're using thread, structure the code to always join before the scope ends. detach is permissible only in the narrow situation where the thread can share the process's fate and you can guarantee it never touches shared state at all.
- Can volatile be used for synchronisation between threads in C++?
- No. C++'s volatile is a qualifier for reads and writes you don't want the compiler to optimise away — memory-mapped I/O, for example — and it does not guarantee visibility or ordering between threads. If multiple threads access the same variable without synchronisation, that is a data race, and it is undefined behaviour. Use std::atomic for flags and counters shared between threads, and std::mutex when you need to protect several variables together. std::atomic provides both atomicity of the operation and ordering based on memory_order.
- std::async looks convenient, but are there any 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 onto it, that becomes equivalent to synchronous execution right there — an accident where you meant to go asynchronous but ended up serial. Also, when you don't specify a launch policy, whether the work actually runs on a separate thread is left to the implementation's discretion. If you use it, manage the future's lifetime explicitly, and specify std::launch::async wherever you need to guarantee concurrent execution.