Skip to Content
Course content

195: Using Sanitizers (UBSan, ASan, TSan) Together

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years back, I was mentoring a dev who was losing his mind over a flaky crash in a high-frequency trading module. He had a classic "Heisenbug"—it happened once every thousand runs, usually in production, but never on his machine. He knew he had a concurrency issue, so he turned on ThreadSanitizer (TSan). Then he suspected a buffer overflow, so he tried to add AddressSanitizer (ASan) to the same build. He spent three hours fighting the compiler, wondering why -fsanitize=address,thread was throwing a fit and refusing to link. He felt like he was being punished for wanting more safety.

The reality is that sanitizers aren't just simple flags you toggle on like "dark mode" in an IDE. They are heavy-duty instrumentation tools that fundamentally rewrite how your program interacts with memory and the CPU. While we'd love a single "God Mode" binary that catches everything, the internals of these tools often collide.

The Compatibility Wall

Here is the hard truth: you cannot run ASan and TSan in the same executable. I've seen plenty of engineers try to force it, but they're fighting a losing battle. ASan works by "poisoning" memory around your allocations to catch overflows, while TSan tracks every single memory access to detect data races. Both tools need to intercept malloc and free, and both need to insert "shadow memory" checks into your code. When you try to use both, they fight over the same shadow memory regions and the same function hooks. The result is usually a linker error or a binary that crashes the moment it starts.

UndefinedBehaviorSanitizer (UBSan), however, is the friendly neighbor of the group. Because it focuses on logical violations—like signed integer overflow or null pointer dereferences—rather than memory layout or thread interleaving, it generally plays well with others. You can usually pair UBSan with ASan or TSan without a problem. If I'm hunting a memory leak, I'll run ASan + UBSan. If I'm hunting a race condition, I'll run TSan + UBSan.

Orchestrating a Sanitizer Workflow

Since you can't have one "super-binary," you have to change how you think about your build process. I stop thinking about "the build" and start thinking about "build profiles." In a professional C environment, you don't just have debug and release; you have a matrix of sanitized builds.

I typically set up my Makefile or CMake configuration to handle this via variables. For example, I might have three distinct targets:

# Build for memory and UB checks
gcc -fsanitize=address,undefined -g -O1 main.c -o app_asan

# Build for threading and UB checks
gcc -fsanitize=thread,undefined -g -O1 main.c -o app_tsan

# Standard debug build
gcc -g -O0 main.c -o app_debug

The trick is to integrate these into your Continuous Integration (CI) pipeline. I don't run all of them on every single save—that's overkill and slows down the feedback loop. Instead, I run the ASan build on every PR, and the TSan build on a nightly schedule since race conditions often require more iterations to trigger. If you're working locally, just swap the binary you're executing based on what you're hunting. If the program is crashing with a SIGSEGV, reach for the ASan binary. If it's behaving erratically under load, switch to the TSan binary.

One last tip: keep your optimization level low (-O1 is usually the sweet spot). If you go to -O3, the compiler might optimize away the very patterns the sanitizers are looking for, or it might make the stack traces provided by the sanitizers almost impossible to read. Trust me, there is nothing worse than a TSan report that tells you a race occurred in a function that the compiler inlined into five other places.




📋 Practical Task

Debugging the Corrupt Thread-Safe Queue

You have been handed a piece of code implementing a basic producer-consumer queue. The previous developer claimed it was "thread-safe," but it's crashing sporadically in production. There are two distinct bugs: one is a data race on the queue index, and the other is a subtle buffer overflow when the queue wraps around.

Your Task:

  1. Create two separate build commands: one using -fsanitize=address,undefined and one using -fsanitize=thread,undefined.
  2. Run the ASan build to identify and fix the memory corruption bug.
  3. Run the TSan build to identify and fix the race condition.
  4. Verify that both sanitizers report a clean bill of health.
# Buggy Queue Implementation
# queue.c
#include <stdio.h><
#include <stdlib.h><
#include <pthread.h><
#include <unistd.h><

#define SIZE 10

typedef struct {
    int buffer[SIZE];
    int head;
    int tail;
    pthread_mutex_t lock;
} Queue;

void produce(Queue *q, int val) {
    // BUG 1: Potential race condition here (intentionally missing lock)
    q->buffer[q->tail] = val;
    // BUG 2: Off-by-one error in wrapping logic
    q->tail = (q->tail + 1) % (SIZE + 1); 
    pthread_mutex_lock(&q->lock);
    pthread_mutex_unlock(&q->lock);
}

void* worker(void* arg) {
    Queue *q = (Queue*)arg;
    for(int i = 0; i < 100; i++) {
        produce(q, i);
    }
    return NULL;
}

int main() {
    Queue q = { .head = 0, .tail = 0, .lock = PTHREAD_MUTEX_INITIALIZER };
    pthread_t t1, t2;
    pthread_create(&t1, NULL, worker, &q);
    pthread_create(&t2, NULL, worker, &q);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Finished successfully\n");
    return 0;
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.