C++ Thread Sanitizer (TSan): Detecting Data Races and Thread Bugs
ThreadSanitizer (TSan) is a dynamic analysis tool built into Clang and GCC that detects data races, deadlocks, and other concurrency bugs in C++ programs. It instruments your binary at compile time and reports races with precise stack traces.
Enabling TSan
Compile with -fsanitize=thread and link with the same flag:
# GCC
g++ -fsanitize=thread -fPIE -pie -g -O1 -o myprogram myprogram.cpp
<span class="hljs-comment"># Clang
clang++ -fsanitize=thread -g -O1 -o myprogram myprogram.cppThe -g flag adds debug info for readable stack traces. -O1 or -O2 is recommended — TSan works better with optimization than at -O0.
Example: Detecting a Data Race
#include <thread>
#include <iostream>
int counter = 0; // shared state
void increment() {
for (int i = 0; i < 1000; i++) {
counter++; // RACE: no synchronization
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter << "\n";
}$ clang++ -fsanitize=thread -g -O1 race.cpp -o race && ./race
==================
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x... by thread T2:
#0 increment() race.cpp:8
Previous write of size 4 at 0x... by thread T1:
<span class="hljs-comment">#0 increment() race.cpp:8
==================Fixing the Race
#include <atomic>
#include <thread>
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 1000; i++) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}Or with a mutex:
#include <mutex>
int counter = 0;
std::mutex mu;
void increment() {
for (int i = 0; i < 1000; i++) {
std::lock_guard<std::mutex> lock(mu);
counter++;
}
}CMake Integration
# CMakeLists.txt
option(ENABLE_TSAN "Enable Thread Sanitizer" OFF)
if(ENABLE_TSAN)
message(STATUS "ThreadSanitizer enabled")
target_compile_options(mylib PRIVATE -fsanitize=thread -fPIE -g)
target_link_options(mylib PRIVATE -fsanitize=thread -pie)
endif()Build with TSan:
cmake -DENABLE_TSAN=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo ..
makeIntegrating TSan with Google Test
TSan works transparently with GTest — just compile with the sanitizer flags:
// tests/counter_test.cpp
#include <gtest/gtest.h>
#include <atomic>
#include <thread>
#include <vector>
#include "counter.h"
TEST(CounterTest, ConcurrentIncrement) {
Counter counter;
std::vector<std::thread> threads;
for (int i = 0; i < 100; i++) {
threads.emplace_back([&counter]() {
for (int j = 0; j < 1000; j++) {
counter.increment();
}
});
}
for (auto& t : threads) t.join();
EXPECT_EQ(counter.value(), 100 * 1000);
}
TEST(CounterTest, ConcurrentReadWrite) {
Counter counter;
counter.increment(); // initial value
std::thread writer([&counter]() {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
std::thread reader([&counter]() {
for (int i = 0; i < 10000; i++) {
// Just reading concurrently — should not race if counter is thread-safe
(void)counter.value();
}
});
writer.join();
reader.join();
}# CMakeLists.txt for tests
add_executable(counter_tests tests/counter_test.cpp)
target_link_libraries(counter_tests counter GTest::gtest_main)
if(ENABLE_TSAN)
target_compile_options(counter_tests PRIVATE -fsanitize=thread -g)
target_link_options(counter_tests PRIVATE -fsanitize=thread)
endif()TSan Suppressions
For third-party code or known false positives, use a suppressions file:
# tsan_suppressions.txt
race:third_party_library_function
race:known_benign_race_function
deadlock:intentional_lock_orderTSAN_OPTIONS="suppressions=tsan_suppressions.txt" ./myprogramOr annotate in code:
#include <sanitizer/tsan_interface.h>
// Mark a benign race (use sparingly, document why)
__tsan_acquire(&some_lock);
int value = shared_value; // TSan knows we hold the lock
__tsan_release(&some_lock);Other Sanitizers to Pair with Tests
| Sanitizer | Flag | Detects |
|---|---|---|
| ThreadSanitizer | -fsanitize=thread |
Data races, deadlocks |
| AddressSanitizer | -fsanitize=address |
Buffer overflows, use-after-free |
| UndefinedBehaviorSanitizer | -fsanitize=undefined |
Null dereferences, integer overflow |
| MemorySanitizer | -fsanitize=memory |
Uninitialized reads |
Note: TSan cannot be combined with ASan or MSan. Run them in separate build configurations.
CI Pipeline
# .github/workflows/tsan.yml
name: Thread Sanitizer Tests
on: [push, pull_request]
jobs:
tsan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: sudo apt-get install -y cmake clang libgtest-dev
- name: Configure with TSan
run: |
cmake -B build \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DENABLE_TSAN=ON \
-DCMAKE_BUILD_TYPE=RelWithDebInfo
- name: Build
run: cmake --build build
- name: Run tests
run: |
TSAN_OPTIONS="halt_on_error=1 second_deadlock_stack=1" \
ctest --test-dir build --output-on-failurehalt_on_error=1 makes TSan exit immediately on the first race instead of continuing, which is useful in CI.
Common TSan Findings and Fixes
| Finding | Typical cause | Fix |
|---|---|---|
| Data race on plain variable | Missing mutex or atomic | Use std::atomic or std::mutex |
Data race on std::string |
Shared string across threads | Use mutex or per-thread copies |
| Lock-order reversal | Inconsistent lock acquisition order | Establish and document lock ordering |
| Race on initialization | Static local init (pre-C++11) | Use std::call_once or C++11 static init guarantees |
| Use of destroyed mutex | Mutex destroyed before threads finish | Ensure threads join before mutex goes out of scope |
Key Takeaways
- Compile with
-fsanitize=thread -g -O1— optimization helps TSan accuracy - TSan cannot be combined with AddressSanitizer; run in separate CI jobs
- Use
TSAN_OPTIONS="halt_on_error=1"in CI to fail fast on the first race - GTest works with TSan out of the box — no special test framework support needed
- Use suppressions only for known false positives in third-party code, always with documentation