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

C++ Runtime Checking: AddressSanitizer and ThreadSanitizer

C++ Runtime Checking: AddressSanitizer and ThreadSanitizer

이 글의 핵심

Memory bugs and data races in C++ often appear only in production under specific load patterns. AddressSanitizer catches heap corruption and use-after-free; ThreadSanitizer catches data races. Both belong in your CI pipeline.

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

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Catch C++ memory bugs and data races with AddressSanitizer and ThreadSanitizer. Covers build flags, UAF/overflow example… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, AddressSanitizer, ThreadSanitizer, memory safety, data race, ASan, TSan 등으로 검색하시면 이 글이 도움이 됩니다.