본문으로 건너뛰기 C++ Runtime Checking: AddressSanitizer and ThreadSanitizer

C++ Runtime Checking: AddressSanitizer and ThreadSanitizer

C++ Runtime Checking: AddressSanitizer and ThreadSanitizer

Why Static Analysis Is Not Enough

Memory bugs in C++ are categorized as “undefined behavior” — the program may appear to work correctly for months, then corrupt data or crash under specific allocation patterns, load levels, or OS scheduling decisions. Static analysis catches some patterns, but many bugs only manifest at runtime.

AddressSanitizer (ASan) instruments every memory access. It maintains shadow memory tracking which bytes are valid, and reports the exact location of:

  • Heap buffer overflows
  • Stack buffer overflows
  • Use-after-free
  • Use-after-scope (returning reference to local)
  • Double-free

ThreadSanitizer (TSan) tracks thread operations — reads, writes, locks, and atomics — and reports data races: two threads accessing the same memory without synchronization where at least one thread writes.

Both sanitizers are available in GCC and Clang. MSVC supports /fsanitize=address from Visual Studio 2019 16.9+.


AddressSanitizer (ASan)

Build Flags

# GCC or Clang
g++ -std=c++20 \
    -fsanitize=address \        # enable ASan
    -fsanitize=undefined \      # also enable UBSan (compatible with ASan)
    -fno-omit-frame-pointer \   # better stack traces
    -g \                        # source line numbers in reports
    -O1 \                       # some optimization, but preserve frames
    -o my_program my_program.cpp

# CMake — add to debug config
cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer" \
      -DCMAKE_BUILD_TYPE=RelWithDebInfo ..

Use-After-Free Example

// uaf_example.cpp
#include <iostream>

int main() {
    int* p = new int(42);
    delete p;

    // Access after free — undefined behavior
    std::cout << *p << "\n";  // ASan catches this immediately

    return 0;
}
$ g++ -fsanitize=address -fno-omit-frame-pointer -g -o uaf uaf_example.cpp
$ ./uaf

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
    #0 0x... in main uaf_example.cpp:8
    ...
0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
    #0 0x... in operator delete(void*)
    #1 0x... in main uaf_example.cpp:5

ASan tells you the exact line of the use-after-free AND the exact line where the memory was freed. Compare this to debugging a crash dump with no sanitizer — you would see a corrupted stack or wrong value with no indication of when the memory was freed.

Heap Buffer Overflow

#include <vector>

int main() {
    std::vector<int> v = {1, 2, 3};

    // Write one element past the end
    v[3] = 99;  // ASan: heap-buffer-overflow on write

    return 0;
}
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 4 at address 0x... thread T0
    #0 0x... in main overflow.cpp:5

Without ASan, this may silently corrupt adjacent heap metadata and crash later — completely unrelated to the actual bug site.

Stack Buffer Overflow

void process(const char* input) {
    char buffer[8];
    strcpy(buffer, input);  // overflow if input > 7 chars + null
}

int main() {
    process("this is way too long for the buffer");
    return 0;
}
ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 35 at address 0x... thread T0
    #0 0x... in process stack_overflow.cpp:3

Memory Leak Detection (LSan)

LeakSanitizer is bundled with ASan on most platforms:

#include <memory>

int main() {
    int* leaked = new int[100];  // never deleted
    // program exits without freeing 'leaked'
}
# LSan runs automatically with ASan on Linux
$ ASAN_OPTIONS=detect_leaks=1 ./my_program

ERROR: LeakSanitizer: detected memory leaks
Direct leak of 400 byte(s) in 1 object(s) allocated from:
    #0 0x... in operator new[](unsigned long)
    #1 0x... in main leak.cpp:4

ThreadSanitizer (TSan)

Build Flags

# TSan is separate from ASan — do NOT combine them
g++ -std=c++20 \
    -fsanitize=thread \
    -fno-omit-frame-pointer \
    -g \
    -O1 \
    -o my_program my_program.cpp

Data Race Example

#include <thread>
#include <iostream>

int counter = 0;  // shared variable, no synchronization

void increment() {
    for (int i = 0; i < 100000; i++) {
        counter++;  // read-modify-write without lock — data race
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << counter << '\n';  // might print anything
}
$ g++ -fsanitize=thread -fno-omit-frame-pointer -g -o race race.cpp
$ ./race

WARNING: ThreadSanitizer: data race (pid=12345)
  Write of size 4 at 0x... by thread T2:
    #0 increment() race.cpp:7

  Previous write of size 4 at 0x... by thread T1:
    #0 increment() race.cpp:7

  Thread T1 (tid=..., running):
    #0 increment() race.cpp:7
    #1 main race.cpp:14

  Thread T2 (tid=..., started):
    ...

TSan identifies:

  • Which threads are racing
  • Which memory address they’re racing on
  • Whether each access is a read or write
  • The exact source line of each access

Correct Version

#include <thread>
#include <atomic>

std::atomic<int> counter{0};  // atomic — no race

void increment() {
    for (int i = 0; i < 100000; i++) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

Or with a mutex:

#include <thread>
#include <mutex>

int counter = 0;
std::mutex mtx;

void increment() {
    for (int i = 0; i < 100000; i++) {
        std::lock_guard<std::mutex> lock(mtx);
        counter++;
    }
}

CI Integration

Run separate sanitizer jobs in your CI pipeline:

GitHub Actions

# .github/workflows/sanitizers.yml
name: Sanitizers

on: [push, pull_request]

jobs:
  asan:
    name: AddressSanitizer
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build with ASan
        run: |
          cmake -B build-asan \
            -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer" \
            -DCMAKE_BUILD_TYPE=RelWithDebInfo
          cmake --build build-asan
      - name: Run tests
        run: |
          cd build-asan
          ASAN_OPTIONS=detect_leaks=1 ctest --output-on-failure

  tsan:
    name: ThreadSanitizer
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build with TSan
        run: |
          cmake -B build-tsan \
            -DCMAKE_CXX_FLAGS="-fsanitize=thread -fno-omit-frame-pointer" \
            -DCMAKE_BUILD_TYPE=RelWithDebInfo
          cmake --build build-tsan
      - name: Run tests
        run: ctest --test-dir build-tsan --output-on-failure

CMake Presets

// CMakePresets.json
{
  "configurePresets": [
    {
      "name": "asan",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "RelWithDebInfo",
        "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer"
      }
    },
    {
      "name": "tsan",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "RelWithDebInfo",
        "CMAKE_CXX_FLAGS": "-fsanitize=thread -fno-omit-frame-pointer"
      }
    }
  ]
}

Suppression Files

Not every sanitizer report represents a real bug. Third-party libraries with known benign races, or code paths that are logically safe but trigger false positives, can be suppressed:

# tsan.supp — TSan suppression file
race:libcrypto:CRYPTO_atomic_add       # known-safe OpenSSL atomics
race:my_legacy_lib::LegacyGlobal::init # single-init pattern, actually safe
TSAN_OPTIONS="suppressions=tsan.supp" ./my_program

Use suppressions sparingly. Every suppression is a potential real bug being hidden. Prefer fixing the underlying issue.


What Each Sanitizer Finds

SanitizerWhat it catchesOverhead
ASanHeap/stack buffer overflow, use-after-free, double-free, use-after-scope~2x CPU, ~2-3x memory
TSanData races between threads~5-15x CPU, ~5-10x memory
UBSanInteger overflow, null dereference, bad casts, alignment violations~minimal
LSanMemory leaks (bundled with ASan)Minimal extra

Recommended combination: run ASan + UBSan together (they’re compatible), and TSan in a separate job.


Key Takeaways

  • ASan catches memory corruption at the exact access site — not where the crash eventually manifests
  • TSan detects data races that may only appear under specific scheduling — not reproducible otherwise
  • Never ship sanitized binaries to production in performance-sensitive code — the overhead is 2-15x
  • CI integration is the highest-value use: fail the build when a sanitizer finds a bug, before it reaches production
  • ASan + UBSan can run together; TSan requires a separate build (don’t mix them)
  • Use suppression files sparingly for known-safe third-party patterns — not as a way to silence real bugs
  • LeakSanitizer (LSan) is bundled with ASan on Linux — catches memory leaks without Valgrind’s overhead

Frequently Asked Questions (FAQ)

Q. When would I use this in practice?

A. Catch C++ memory bugs and data races with AddressSanitizer and ThreadSanitizer.

Q. What should I read before this?

A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.

Q. Where can I study this more deeply?

A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers C++, AddressSanitizer, ThreadSanitizer, memory safety, data race, ASan, TSan.