C++ Thread Sanitizer (TSan): Detecting Data Races and Thread Bugs

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.cpp

The -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 ..
make

Integrating 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_order
TSAN_OPTIONS="suppressions=tsan_suppressions.txt" ./myprogram

Or 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-failure

halt_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

Read more