C++ gives developers direct control over memory, object lifetime, and concurrency.
That control comes with a price.
A program may compile successfully and pass ordinary tests while still containing use-after-free errors, uninitialized reads, undefined behavior, or data races.
Sanitizers help detect such problems at runtime.
They instrument the program during compilation and monitor potentially dangerous operations while the binary executes.
However, sanitizers are not a magic wand. They can report only problems that occur in the code paths exercised during execution.
If a test never reaches the problematic branch, or a concurrent workload never produces the required thread interleaving, the sanitizer has nothing to report.
This article explains what the main C++ sanitizers detect, how ThreadSanitizer tracks data races, and how to use sanitizer builds effectively.
What Sanitizers Are
Sanitizers are dynamic analysis tools.
The compiler inserts additional checks into the program, and a sanitizer runtime tracks relevant operations while the instrumented binary is running.
Different sanitizers detect different classes of problems.
The most commonly used sanitizers are:
- AddressSanitizer (ASan) detects memory errors such as heap and stack buffer overflows, use-after-free, use-after-return, and use-after-scope.
- MemorySanitizer (MSan) detects uses of uninitialized values.
- UndefinedBehaviorSanitizer (UBSan) detects selected forms of undefined behavior, including signed integer overflow, invalid shifts, misaligned pointer access, and some out-of-bounds accesses.
- ThreadSanitizer (TSan) detects data races in multithreaded programs.
- LeakSanitizer (LSan) detects memory leaks and is often used together with AddressSanitizer.
When a sanitizer detects a problem, it prints a diagnostic report containing information such as:
- the type of error,
- the source location,
- the stack trace,
- the threads or memory accesses involved.
Depending on the sanitizer and its configuration, the program may terminate immediately or continue after reporting the problem.
Sanitizers Are Dynamic Checks
Sanitizers do not prove that a program is correct.
They analyse only the execution that actually happens.
For example, AddressSanitizer cannot detect a use-after-free error in a function that is never called. ThreadSanitizer cannot detect a data race if the problematic threads or code paths are not exercised.
This is the main limitation of sanitizer-based analysis:
an error must occur during the instrumented execution before the sanitizer can report it.
That is why sanitizers work best together with:
- unit tests,
- integration tests,
- stress tests,
- fuzzing,
- realistic workloads,
- good test coverage.
However, coverage alone is not sufficient for concurrency errors. Even if both relevant lines are executed, the thread interleaving that produces the race must also occur.
How ThreadSanitizer Works
ThreadSanitizer detects data races.
A data race occurs when:
- two threads access the same memory location,
- at least one access is a write,
- and the accesses are not ordered by synchronization.
For example:
int value = 0;
void first()
{
value = 1;
}
void second()
{
value = 2;
}
If two threads call first() and second() concurrently, both write to the same variable without synchronization.
Conceptually, the execution looks like this:
Thread 1: write value
Thread 2: write value
There is no defined ordering between these writes.
That is a data race, and the behaviour of the C++ program is undefined.
How TSan Instruments Memory Accesses
To detect data races, ThreadSanitizer instruments memory operations during compilation.
Consider this code:
int value;
void update()
{
value = 42;
int copy = value;
}
Conceptually, the compiler may transform it into something similar to:
int value;
void update()
{
__tsan_func_entry(__builtin_return_address(0));
__tsan_write4(&value);
value = 42;
__tsan_read4(&value);
int copy = value;
__tsan_func_exit();
}
This is only a simplified illustration. The exact instrumentation is an implementation detail and may differ between compiler and runtime versions.
Calls such as __tsan_write4() and __tsan_read4() notify the ThreadSanitizer runtime about memory accesses.
The runtime then records metadata about those accesses and checks whether conflicting operations from different threads are properly ordered.
Shadow Memory and Logical Time
ThreadSanitizer uses shadow memory to store metadata associated with application memory.
Conceptually, the metadata may describe:
- which thread performed an access,
- whether the access was a read or a write,
- the size of the access,
- the logical time at which it happened.
ThreadSanitizer also maintains logical time for threads.
The purpose of logical time is not to measure wall-clock time. It is used to represent ordering relationships between operations.
For a new memory access, TSan conceptually asks:
- Is this the same memory location?
- Was the previous access performed by another thread?
- Is at least one of the accesses a write?
- Are the accesses ordered by a happens-before relationship?
If the answers are:
same memory: yes
different threads: yes
write involved: yes
ordered by HB: no
then TSan reports a data race:
WARNING: ThreadSanitizer: data race
How Synchronization Establishes Happens-Before
Consider the previous example protected by a mutex:
#include <mutex>
int value = 0;
std::mutex mutex;
void first()
{
std::lock_guard lock(mutex);
value = 1;
}
void second()
{
std::lock_guard lock(mutex);
value = 2;
}
The execution now contains synchronization operations:
Thread 1: lock(mutex)
Thread 1: write value
Thread 1: unlock(mutex)
Thread 2: lock(mutex)
Thread 2: write value
Thread 2: unlock(mutex)
A successful unlock operation acts as a release. A later successful lock of the same mutex acts as an acquire.
Conceptually, when Thread 1 unlocks the mutex, ThreadSanitizer records synchronization information associated with that mutex.
When Thread 2 later locks the same mutex, its logical time is updated using that synchronization information.
As a result, the operations are ordered:
Thread 1 writes value
happens-before
Thread 2 writes value
Because the two writes are ordered through the mutex, ThreadSanitizer does not report a data race.
ThreadSanitizer also understands many other synchronization operations, including atomics and common threading primitives.
Data Races Are Not the Same as Race Conditions
ThreadSanitizer detects data races, but it does not detect every concurrency bug.
A program may use atomics correctly and still contain a logical race condition.
For example, several operations may individually be atomic, while the overall algorithm incorrectly assumes that they happen as one indivisible transaction.
In such a case:
- there may be no formal data race,
- ThreadSanitizer may report nothing,
- the program may still produce incorrect results.
So the correct statement is:
ThreadSanitizer detects data races, not all possible concurrency errors.
Why Sanitizers Miss Bugs
Consider this modified example:
int value = 0;
void first()
{
value = 1;
}
void second(bool should_write)
{
if (should_write) {
value = 2;
}
}
Suppose two threads execute:
// Thread 1
first();
// Thread 2
second(false);
The second thread does not write to value.
Therefore, the problematic pair of conflicting writes does not occur during this execution, and ThreadSanitizer has no data race to report.
The bug is still present in the code:
second(true);
could race with first().
But until the relevant branch and thread interleaving occur, the sanitizer cannot observe it.
This demonstrates an important rule:
sanitizers detect executed bugs, not all bugs that exist in the source code.
How to Apply Sanitizers Properly
Sanitizers modify the program and introduce runtime overhead.
The exact overhead depends on the sanitizer, the application, and the workload. ThreadSanitizer is usually considerably more expensive than AddressSanitizer because it tracks memory accesses and synchronization across threads.
For this reason, sanitizers are normally enabled in dedicated development and CI configurations rather than in production builds.
A practical CI setup may contain separate jobs for:
- AddressSanitizer and UndefinedBehaviorSanitizer,
- ThreadSanitizer,
- MemorySanitizer,
- ordinary unit and integration tests,
- code coverage.
Separate jobs make failures easier to diagnose and avoid combining incompatible or excessively expensive configurations.
Want to build a stronger quality gate for your C++ project?
Sanitizers are only one part of it.
In my newsletter, From complexity to essence in C++, I write about practical C++, CMake, testing, static analysis, sanitizers, and CI workflows.
Subscribe here:
https://sqglobe.com/from-complexity-to-essence-in-c/
Example Compiler Commands
AddressSanitizer and UndefinedBehaviorSanitizer can usually be enabled together:
clang++ \
-O1 \
-g \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
main.cpp
ThreadSanitizer should normally be enabled in a separate build:
clang++ \
-O1 \
-g \
-fsanitize=thread \
-fno-omit-frame-pointer \
main.cpp
MemorySanitizer also requires a separate instrumented build:
clang++ \
-O1 \
-g \
-fsanitize=memory \
-fno-omit-frame-pointer \
main.cpp
These examples compile and link the program in a single command. Using clang++ for the final link step ensures that the required sanitizer runtime is linked correctly.
Debug information improves stack traces, while preserving frame pointers often makes sanitizer reports easier to interpret.
The exact configuration depends on the compiler, operating system, standard library, and third-party dependencies used by the project.
MemorySanitizer requires particular care: ideally, all code involved in the execution, including dependent libraries, should also be instrumented. Otherwise, reports may be incomplete or contain false positives caused by uninstrumented code.
You can enable these compiler and linker flags through a dedicated CMake build configuration, as discussed in Inventing Additional Build Logic.
Improve the Workload, Not Only Line Coverage
High test coverage increases the chance that sanitizers will observe problematic code.
But line coverage alone does not guarantee effective sanitizer testing.
For memory errors, tests should exercise:
- different input sizes,
- error paths,
- object lifetime boundaries,
- allocation and deallocation patterns.
For concurrency errors, tests should also exercise:
- multiple threads,
- repeated operations,
- realistic workloads,
- different task timings,
- stress scenarios.
Running a concurrent test once may not produce the problematic interleaving.
Running it hundreds or thousands of times under ThreadSanitizer may reveal a race that ordinary tests miss.
Fuzzing can also work well with sanitizers because it explores inputs that developers may not have considered manually.
Conclusion
Sanitizers are among the most useful runtime analysis tools available for C++ projects.
They can detect serious problems such as:
- invalid memory access,
- use of uninitialized values,
- selected forms of undefined behavior,
- data races,
- memory leaks.
But they are not a proof of correctness.
A sanitizer can report only what it observes during execution. If the problematic code path is not reached, or the required thread interleaving does not occur, the bug remains invisible.
That is why sanitizers should not replace tests, code review, static analysis, or careful interface design.
They should complement them.
The practical rule is simple:
use sanitizers in dedicated CI configurations, run them against realistic workloads, and exercise as much behaviour as possible.
The more meaningful execution paths your tests cover, the more useful sanitizer reports become.
Sanitizers do not guarantee that your program has no bugs.
They make many dangerous bugs much harder to ship unnoticed.
Want to Go Deeper?
Sanitizers are most useful when they are part of a complete engineering workflow: reproducible builds, automated tests, CMake configuration, CI quality gates, and clear project structure.
I cover that workflow in my book:
No More Helloworlds — Build a Real C++ App
The book shows how to move beyond isolated examples and build a real C++ application with modern CMake, testing, tooling, and practical architecture decisions.
Learn more: https://sqglobe.com/no-more-helloworlds-build-a-real-c-app/