C++ Part I · Module 25 · Debugging, Undefined Behavior, and Performance
Sanitizers
A sanitizer is instrumentation the compiler inserts so that a program reports its own undefined behaviour instead of merely suffering from it. That makes them the single most valuable tools in this module, because the bug classes in the preceding four chapters are exactly the ones that are otherwise silent — and the measurements there showed that each sanitizer sees a different and surprisingly narrow slice. This chapter is about using them deliberately: which ones exist on the machine in front of you, which bug each is actually looking for, what each costs in run time, how to read a report back to a line of your own code, and one detail that decides whether a continuous-integration gate works at all. That last point is worth stating up front, because it is the chapter's sharpest measurement and it catches people: of the three sanitizers available here, two abort the process on a finding and one prints its report and exits zero. A gate reading only the exit status therefore passes a run in which the undefined-behaviour sanitizer found a defect and said so.
Advanced19 min readC++Part IFundamentalsDebugging
Part I · Module 25 · Chapter 25.6 · Sanitizers
1. What Exists Here, and What Does Not
Before the techniques, the inventory — because two of the five commonly discussed sanitizers cannot be run on this machine, and a chapter that implied otherwise would be useless.
| sanitizer | flag | on arm64 macOS |
|---|---|---|
| AddressSanitizer | -fsanitize=address | builds and runs |
| UndefinedBehaviorSanitizer | -fsanitize=undefined | builds and runs |
| ThreadSanitizer | -fsanitize=thread | builds and runs |
| MemorySanitizer | -fsanitize=memory | rejected |
| LeakSanitizer | -fsanitize=leak | rejected |
clang++: error: unsupported option '-fsanitize=memory' for target 'arm64-apple-darwin25.5.0'
clang++: error: unsupported option '-fsanitize=leak' for target 'arm64-apple-darwin25.5.0'And ASan's own leak detection, which is a separate switch from the LeakSanitizer:
…AddressSanitizer: detect_leaks is not supported on this platform.So: uninitialised-read detection and leak detection are both unavailable through sanitizers here. Chapter 25.2 §5 is the consequence for the first; Chapter 25.7 covers the second with Apple's leaks instead. On Linux x86-64 all five are available, and that platform difference is worth knowing before you plan a CI matrix.
The frozen command for this chapter is the one to remember:
g++ -std=c++17 -g -fsanitize=address,undefined model.cpp -o model-g is not optional — without it the report has addresses instead of lines, which is most of its value gone.
2. What Each One Is Looking For
A sanitizer is narrow by design. Each has one source of truth, which Chapter 25.3 §4's callout established and which predicts its misses:
| sanitizer | source of truth | finds | does not find |
|---|---|---|---|
| ASan | allocation redzones and freed-block poisoning | heap and stack buffer overflow, use after free, double free | anything staying inside one allocation |
| UBSan | the declared types and the language rules | signed overflow, bad shifts, array-index-vs-type, null deref, bad casts | lifetime, races, anything type-correct |
| TSan | a happens-before relation between threads | data races, some lock-order inversions | anything single-threaded |
Those three columns are why they are complementary rather than redundant, and why the measured coverage in the preceding chapters came out partitioned:
- Chapter 25.3 §4: ASan caught a vector overflow and missed an in-struct array write; UBSan caught the in-struct write and missed the vector overflow. No single configuration caught both.
- Chapter 25.4 §3: ASan caught the two heap lifetime bugs and missed the two stack ones; the compiler's
-Wreturn-stack-addresscaught exactly the two ASan missed.
So the recommendation that follows is not "turn on sanitizers"; it is address,undefined together, plus _LIBCPP_HARDENING_MODE, plus -Wall -Wextra, because that combination is what the measurements show covers the bug classes in this module.
ASan and TSan cannot be combined — they both instrument memory access and conflict — so TSan is a separate build.
3. The Thread Sanitizer, and Why It Has No Substitute
Two threads incrementing an unsynchronised counter:
static long counter = 0;
static void bump(int n) { for (int i = 0; i < n; ++i) ++counter; }
// ... std::thread a(bump, 100000), b(bump, 100000);WARNING: ThreadSanitizer: data race (pid=…)
Location is global 'counter' at … (race+0x100008000)
SUMMARY: ThreadSanitizer: data race race.cpp:4 in bump(int)It names the variable, the file, the line and the function. That is the best possible diagnosis of a race, and the reason TSan has no substitute is what happens without it. The same program, ten runs at each optimisation level:
| build | runs with the wrong answer | values seen |
|---|---|---|
-O0 | 5 of 10 | 111848 … 195164, against an expected 200000 |
-O1 | 0 of 10 | 200000 every time |
-O2 | 0 of 10 | 200000 every time |
Optimisation hides the race. At -O1 and above the compiler keeps the counter in a register across the loop and writes it back once, so the window in which two threads can interleave shrinks to almost nothing and the answer comes out right every time. The race is still there — the program is still undefined — and the observable symptom has gone.
That is the inverse of the usual story, in which optimisation exposes latent undefined behaviour. Chapter 25.1 §3 measured optimisation changing an answer, and Chapter 25.2 §2 measured it hiding one of two bugs; here it hides the symptom of a race completely. Testing a release build is the configuration least likely to find a race, and a race that disappears when you optimise is not fixed.
4. Reading a Report Back to Your Own Code
A sanitizer report has a standard shape, and most of its value is in the parts people skip.
…ERROR: AddressSanitizer: heap-buffer-overflow on address …
… is located 8 bytes after 12-byte region […)
SUMMARY: AddressSanitizer: heap-buffer-overflow o.cpp:29 in mainFour things to read, in order:
The kind. heap-buffer-overflow, heap-use-after-free, stack-use-after-return, data race. This is the mechanism, and Chapter 25.4 §4 is the warning that it is not always the name of your bug — an invalidated iterator reports as heap-use-after-free because the free was inside push_back.
The relationship to a known region. 8 bytes after 12-byte region tells you how far out and how big the object was, which is often enough to identify which array it was.
The SUMMARY line, which names the file and line in your code. That is the anchor; start there.
The blocks after the first. ASan prints the access stack, then the allocation stack, then — for a use-after-free — the deallocation stack. Chapter 25.4 §6 makes the point that the deallocation stack is the cause while the access stack is only the symptom, and that skipping it is the commonest mistake in reading these reports.
UBSan's reports are one line and name the operands:
probe.cpp:5:64: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
so.cpp:3:66: runtime error: index 4 out of bounds for type 'int[4]'File, line, column, the operation, the actual values and the type. There is nothing to interpret, which is why UBSan findings are usually fixed in minutes.
When a stack trace is all library frames, Chapter 25.7 §4 is the technique. And -fno-omit-frame-pointer improves every one of these traces at essentially no cost, which is why Chapter 24.7 §7 lists it.
5. The Exit Status, Which Decides Whether Your Gate Works
UBSan, default : exit 0
UBSan, -fno-sanitize-recover=all : exit 134
ASan : exit 134
TSan : exit 134UBSan prints its report and lets the program continue. The run completes, the exit status is 0, and a test harness or CI job that checks only the status records a pass — on a run that printed a diagnosis of undefined behaviour.
That is a deliberate design choice: many UBSan findings are survivable, and continuing lets one run report several. It is the wrong default for a gate, and -fno-sanitize-recover=all is the fix. ASan and TSan abort by default, so they need nothing extra.
Chapter 24.7 §5 measured this and Chapter 23.8 §2 is the general shape: a finding that is not part of the artefact the gate reads is not a finding. Verified here directly rather than through a pipe, because reading an exit status through a pipe reports the pipe's status — a trap this curriculum has hit before.
6. What They Cost
A 600-iteration workload over a vector and a map of strings, minimum of seven whole-process runs each:
| build | time | range | vs none | binary |
|---|---|---|---|---|
| none | 27 ms | 24–35 | 1.0× | 36,680 bytes |
-fsanitize=address | 173 ms | 166–202 | 6.4× | 56,616 |
-fsanitize=undefined | 169 ms | 137–370 | 6.2× | 97,608 |
-fsanitize=address,undefined | 314 ms | 286–325 | 11.6× | 96,944 |
-fsanitize=thread | 445 ms | 402–1490 | 16.4× | 37,656 |
Three notes on reading that table honestly.
The TSan ratio is the unstable one. An earlier run of the same experiment on a smaller workload gave 28.8× where this gives 16.4×, and the range here spans 402 to 1490 ms. So the defensible claim is "roughly an order of magnitude or more", not a number. ASan's and UBSan's ratios were consistent between the two experiments (6.2/6.4 and 5.1/6.2).
ASan also costs memory, typically around 3× the heap, which this workload is too small to show and which matters for a long simulation.
The binary sizes do not track the run-time cost. UBSan's binary is the largest and TSan's is nearly unchanged, because UBSan embeds a descriptor and a message for every instrumented operation while TSan's work is in a runtime library.
What that means for practice: the sanitized build is for development and CI, not for the run you ship or the overnight regression whose wall-clock matters. Chapter 24.7 §6's two configurations are how you keep both available.
7. Options Worth Knowing
The runtimes are configured by environment variables, and a handful are worth having in your notes:
| variable | effect |
|---|---|
ASAN_OPTIONS=detect_stack_use_after_return=1 | catches Chapter 25.4's dangling local; off by default |
ASAN_OPTIONS=abort_on_error=1 | abort rather than exit, so you get a core file |
ASAN_OPTIONS=halt_on_error=0 | keep going after the first finding |
UBSAN_OPTIONS=print_stacktrace=1 | add a stack trace to UBSan's one-liner |
TSAN_OPTIONS=second_deadlock_stack=1 | both stacks for a lock-order report |
*_OPTIONS=log_path=/tmp/san | write reports to files, one per process |
*_OPTIONS=suppressions=file | silence known findings from third-party code |
Two compile-time companions matter as much as the runtime options. -fno-sanitize-recover=all from §5, and -fno-omit-frame-pointer for readable traces.
And one discipline about suppressions: a suppression file is how you keep a sanitized build usable when a third-party library has a known finding, and it is also how a real finding gets silenced for a year. Every entry needs a comment with the reason and a link, and the file needs reviewing — Chapter 24.7 §3's argument about -Werror applies identically.
8. The Table
| the question | the answer | measured |
|---|---|---|
| which sanitizers exist here | address, undefined, thread | §1 |
| which do not | memory and leak — rejected on arm64 | §1 |
| does ASan detect leaks here | no — detect_leaks is not supported | §1 |
| can I combine ASan and TSan | no — separate builds | §2 |
| do ASan and UBSan overlap | no — disjoint on the bugs measured | §2, Chapter 25.3 §4 |
| what does ASan cost | 6.4×, and ~3× heap | §6 |
| UBSan | 6.2× | §6 |
| both | 11.6× | §6 |
| TSan | 16.4×, range 402–1490 ms — report the range | §6 |
| does UBSan fail the run | no — exit 0 by default | §5 |
| do ASan and TSan | yes — exit 134 | §5 |
| is a clean run evidence of correctness | no — it is evidence about that execution | §5 callout |
| does a release build find races | no — 0 of 10 wrong at -O1 | §3 |
9. What to Actually Do
- Build the debug configuration with
-fsanitize=address,undefined -g, and add_LIBCPP_HARDENING_MODEand-Wall -Wextra— the measurements show all four cover different things. - Add
-fno-sanitize-recover=all. Without it UBSan reports the defect and exits 0. - Add
ASAN_OPTIONS=detect_stack_use_after_return=1, which is off by default and covers a whole storage class. - Run TSan as a separate build, because it cannot be combined with ASan.
- Run the sanitized build over the whole test suite and on long realistic runs, not a smoke test — a clean run is evidence only about the paths it took.
- Treat every finding as near-certain and fix it; these are not heuristic warnings.
- Never conclude correctness from a clean run.
- Read the whole report: the kind, the region, the
SUMMARYline, and then the allocation and deallocation stacks. - Do not debug races in a release build. Measured: the race was invisible in 10 of 10 runs at
-O1. - Comment and review every suppression, with a reason and a link.
- Keep
-gand-fno-omit-frame-pointeron, including in release.
10. Mistakes Worth Making Once
Assuming MSan is available. It does not build on arm64 macOS, and neither does LSan.
Expecting ASan to find leaks here. detect_leaks is not supported on this platform.
A CI gate on the exit status with UBSan. It found the bug and exited 0.
Running one sanitizer and calling it covered. Chapter 25.3 §4: ASan and UBSan caught different bugs and neither caught both.
Leaving detect_stack_use_after_return at its default. Chapter 25.4 §3's dangling local goes unreported.
Combining ASan and TSan. They conflict; build separately.
Debugging a race at -O2. Measured: 0 of 10 runs wrong, and the race is still there.
Reading only the first line of an ASan report. The deallocation stack is where the cause is.
Treating a clean sanitizer run as a proof. It is a statement about one execution.
A suppression with no comment. A real finding silenced indefinitely.
Shipping a sanitized binary. 6–12× slower, and the runtime is a dependency.
11. Interview Relevance
The frozen curriculum marks this chapter Critical. The opening question is usually "what do sanitizers do?", and the answer that lands names the mechanism for each: ASan poisons redzones around allocations and freed blocks; UBSan instruments operations against the language rules and the declared types; TSan builds a happens-before relation between threads. That immediately explains the misses — anything staying inside one allocation is invisible to ASan, anything type-correct to UBSan.
The measurement worth quoting is the complementarity, because it changes the recommendation: ASan and UBSan caught different out-of-bounds bugs in Chapter 25.3's experiment and neither caught both, which is why the answer is address,undefined together rather than a preference between them.
The two details that mark real use are the exit status — UBSan reports and exits 0 unless you add -fno-sanitize-recover=all, which silently breaks a CI gate — and the asymmetry of the evidence: a finding is near-certain, a clean run proves nothing about the paths you did not take. If races come up, that the race produced the correct answer 10 times out of 10 at -O1 while TSan named it exactly is the most useful thing you can say about why TSan is not optional.
12. What You Should Be Able to Say Now
- Which sanitizers exist on this platform — address, undefined, thread — and that memory and leak are rejected on arm64 macOS, including ASan's own
detect_leaks. - Each one's source of truth, and therefore what each structurally cannot see.
- That ASan and UBSan are complementary, not redundant — measured disjoint on Chapter 25.3's two bugs.
- That ASan and TSan cannot be combined.
- The costs: 6.4× / 6.2× / 11.6× / 16.4×, with TSan's ratio unstable enough to report as a range, and that binary size does not track run-time cost.
- That UBSan reports a defect and exits 0, so a status-only gate passes, and
-fno-sanitize-recover=allis the fix — while ASan and TSan exit 134. - How to read a report: the kind, the region, the
SUMMARYline, then the allocation and deallocation stacks. - That TSan named a race at
race.cpp:4 in bump(int)while the-O1build produced the right answer 10 of 10 times — so optimisation hides races and a release build is the worst place to look for one. - That a finding is near-certain evidence of a defect and a clean run is not evidence of correctness, which is why sanitizers belong on the whole suite and why structural fixes still matter.
13. Exercises
Exercise 1 — the inventory. Try all five -fsanitize= options on your machine and record which build. Then try ASAN_OPTIONS=detect_leaks=1 and record the result.
Exercise 2 — the exit status. Run a UBSan-instrumented program with a known finding and check $?. Add -fno-sanitize-recover=all and check again. Write the CI gate that would have passed the first.
Exercise 3 — the race. Build the unsynchronised counter at -O0, -O1 and -O2, run each ten times, and reproduce the table. Then run the -O1 build under TSan and confirm it still reports the race.
Exercise 4 — the cost. Measure the five configurations on your own test suite, as a minimum of several runs. Decide from your numbers which configuration runs per-commit and which runs nightly.
Exercise 5 — complementarity. Take Chapter 25.3's two out-of-bounds bugs and Chapter 25.4's four lifetime bugs and run all six under each sanitizer separately and together. Build the coverage matrix yourself.
Exercise 6 — the full report. Trigger a use-after-free and read the entire ASan report. Identify the access, allocation and deallocation stacks, and say which one names the cause.
14. Summary
- Three sanitizers exist on this platform —
address,undefined,thread.memoryandleakare rejected on arm64 macOS, and ASan'sdetect_leaksreportsnot supported on this platform, so uninitialised-read and leak detection need other tools (Chapter 25.2 §5, Chapter 25.7). - Each has one source of truth, which predicts its misses: allocation redzones for ASan, declared types and language rules for UBSan, a happens-before relation for TSan.
- They are complementary, not redundant. Chapter 25.3 §4 measured ASan and UBSan catching different out-of-bounds bugs with neither catching both; Chapter 25.4 §3 measured the compiler catching exactly the lifetime bugs ASan missed.
- The costs: ASan 6.4×, UBSan 6.2×, both 11.6×, TSan 16.4× with a range wide enough (402–1490 ms, and 28.8× in an earlier experiment) that it should be quoted as a range. Binary size does not track run-time cost.
- UBSan reports its finding and exits 0. ASan and TSan exit 134. So a gate reading only the status passes a run in which UBSan diagnosed undefined behaviour —
-fno-sanitize-recover=allis the fix. - TSan has no substitute for races. It named one at
race.cpp:4 in bump(int), and the same program produced the correct answer in 10 of 10 runs at-O1and-O2while being wrong in 5 of 10 at-O0. Optimisation hides races, so a release build is the worst place to look. - A finding is near-certain; a clean run is not evidence of correctness. Sanitizers report what an execution did, so they belong on the whole suite and on long realistic runs — and the structural fixes from the preceding chapters remain the thing that removes a bug rather than improving the odds of seeing it.
Chapter 25.7 picks up the gap this chapter leaves: leak detection, which no sanitizer here provides, and the tools that do.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
