Skip to content

Latest commit

 

History

History
660 lines (465 loc) · 25.6 KB

File metadata and controls

660 lines (465 loc) · 25.6 KB

🔝 Retour au Sommaire

29.4.3 — ThreadSanitizer (-fsanitize=thread)

Section 29.4 : Sanitizers · Chapitre 29


Introduction

Les data races sont les bugs les plus vicieux du C++. Pas les plus courants — les buffer overflows gagnent ce titre. Mais les plus vicieux, au sens où ils résistent systématiquement aux techniques de débogage classiques.

Une data race se produit quand deux threads accèdent à la même adresse mémoire sans synchronisation, et qu'au moins un des accès est une écriture. Le résultat est un comportement indéfini — pas simplement imprévisible, mais indéfini au sens du standard. Le compilateur et le processeur sont libres de réordonner les opérations, de cacher des valeurs dans des registres, et de produire des résultats qu'aucune interleaving séquentielle des threads n'expliquerait.

Ce qui rend les data races si difficiles à diagnostiquer :

  • Elles sont non déterministes. Le bug ne se manifeste que quand les threads s'exécutent dans un certain ordre, qui dépend du scheduler du noyau, de la charge CPU, et de la météo. Relancez le programme 1000 fois, le bug apparaît 3 fois.
  • Elles disparaissent sous observation. Ajouter un std::cout pour déboguer ralentit le thread et change le timing. Le bug disparaît. Retirez le std::cout, le bug revient. Ajoutez un breakpoint GDB, même effet — GDB arrête un thread, l'autre avance, le timing change.
  • Elles se manifestent loin de leur cause. Une écriture non synchronisée corrompt une structure de données. Le crash survient 500 millisecondes plus tard, dans un thread différent, dans une fonction sans rapport avec la race.

ThreadSanitizer — TSan — résout ce problème en détectant les data races au moment où elles se produisent, indépendamment de leurs conséquences. TSan ne cherche pas les symptômes (crash, corruption) ; il détecte la cause (deux accès concurrents non synchronisés). Même si la race ne produit aucun bug visible aujourd'hui, TSan la signale — parce que c'est un UB, et que demain, sur un autre CPU, avec un autre scheduler, elle peut devenir catastrophique.


Activation

g++ -fsanitize=thread -g -O1 -fno-omit-frame-pointer -o prog main.cpp

Rappel critique : TSan est incompatible avec ASan et MSan. Il doit être compilé dans un binaire séparé :

# Build ASan + UBSan (détection mémoire + UB)
g++ -fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer -o prog-asan main.cpp

# Build TSan (détection data races) — binaire séparé
g++ -fsanitize=thread -g -O1 -fno-omit-frame-pointer -o prog-tsan main.cpp

Surcoût de performance

TSan a un coût significativement plus élevé qu'ASan ou UBSan :

Métrique Surcoût typique
Temps d'exécution 5-15x plus lent
Mémoire 5-10x plus de mémoire
Taille du binaire ~2x plus gros

Ce surcoût est inhérent à la technique : TSan doit intercepter et enregistrer chaque accès mémoire, puis vérifier si un autre thread a accédé à la même adresse sans synchronisation intermédiaire. Le volume de données à traiter est considérable.

En pratique, le ralentissement rend TSan inutilisable pour du profiling de performance ou pour des programmes à forte charge. Mais pour exécuter une suite de tests unitaires ou un jeu de tests fonctionnels, le surcoût est acceptable — et le retour sur investissement est immense quand il détecte une race condition qui aurait autrement pris des jours à diagnostiquer.


Comment TSan fonctionne

Le modèle "happens-before"

TSan repose sur la relation happens-before, formalisée par Leslie Lamport. L'idée est simple : deux accès mémoire ne sont pas en conflit si l'un "se passe avant" l'autre de manière garantie par un mécanisme de synchronisation.

Les opérations qui établissent une relation happens-before incluent :

  • std::mutex : un unlock sur un mutex happens-before le prochain lock sur ce mutex.
  • std::atomic : une écriture avec memory_order_release happens-before une lecture avec memory_order_acquire sur la même variable atomique.
  • std::thread : la création d'un thread (std::thread t(f)) happens-before la première instruction de f. L'appel à t.join() happens-after la dernière instruction de f.
  • std::condition_variable : un notify happens-before le wait qui le reçoit.

Si deux accès à la même mémoire ne sont pas reliés par une chaîne happens-before, et que l'un d'eux est une écriture, c'est une data race — et TSan la signale.

Shadow memory et vector clocks

TSan maintient une shadow memory (similaire en concept à celle d'ASan, mais beaucoup plus volumineuse) qui enregistre, pour chaque adresse mémoire, les derniers accès effectués par chaque thread. Chaque accès est estampillé avec un vector clock — un vecteur d'horodatages logiques, un par thread, qui capture les relations happens-before.

Quand un thread accède à une adresse, TSan compare l'estampille de l'accès courant avec celle du dernier accès d'un autre thread à la même adresse. Si les vector clocks ne montrent pas de relation happens-before, c'est une race.

C'est cette mécanique qui explique la consommation mémoire élevée : 8 octets de shadow memory par octet de mémoire applicative (dans l'implémentation courante). Un programme qui utilise 200 Mo de mémoire en conditions normales en consomme 1-2 Go sous TSan.


Anatomie d'une data race

Exemple minimal

// data_race.cpp
#include <thread>
#include <cstdio>

int counter = 0;

void increment(int n) {
    for (int i = 0; i < n; ++i) {
        ++counter;    // Data race : lecture-modification-écriture non atomique
    }
}

int main() {
    std::thread t1(increment, 100000);
    std::thread t2(increment, 100000);
    
    t1.join();
    t2.join();
    
    std::printf("Counter: %d\n", counter);
    return 0;
}

Le ++counter est une séquence lecture-modification-écriture sur une variable non atomique, exécutée simultanément par deux threads. Le résultat final dépend de l'interleaving des opérations — il sera inférieur à 200 000 dans la plupart des exécutions.

Compilation et exécution

g++ -fsanitize=thread -g -O1 -fno-omit-frame-pointer -std=c++20 -o data_race data_race.cpp
./data_race

Rapport TSan

==================
WARNING: ThreadSanitizer: data race (pid=54321)
  Write of size 4 at 0x000000601078 by thread T2:
    #0 increment(int) data_race.cpp:9 (data_race+0x4f4b26)
    #1 void std::__invoke_impl<...>(...)  invoke.h:61 (data_race+0x4f5a10)

  Previous write of size 4 at 0x000000601078 by thread T1:
    #0 increment(int) data_race.cpp:9 (data_race+0x4f4b26)
    #1 void std::__invoke_impl<...>(...)  invoke.h:61 (data_race+0x4f5a10)

  Location is global 'counter' of size 4 at 0x000000601078 
    (data_race+0x601078)

  Thread T1 (tid=54322, running) created by main thread at:
    #0 pthread_create (data_race+0x42d4a0)
    #1 std::thread::_M_start_thread(...)  thread.cc:285 (libstdc++.so.6+0xd2de4)
    #2 main data_race.cpp:13 (data_race+0x4f4c10)

  Thread T2 (tid=54323, running) created by main thread at:
    #0 pthread_create (data_race+0x42d4a0)
    #1 std::thread::_M_start_thread(...)  thread.cc:285 (libstdc++.so.6+0xd2de4)
    #2 main data_race.cpp:14 (data_race+0x4f4c40)

SUMMARY: ThreadSanitizer: data race data_race.cpp:9 in increment(int)
==================
Counter: 138547  
ThreadSanitizer: reported 1 warnings  

Lecture du rapport

Le rapport TSan contient quatre blocs d'information :

L'accès courant : Write of size 4 at 0x000000601078 by thread T2 — une écriture de 4 octets (un int) par le thread T2, à data_race.cpp:9 (la ligne ++counter).

L'accès précédent en conflit : Previous write of size 4 at 0x000000601078 by thread T1 — une écriture précédente par le thread T1, à la même adresse et la même ligne. Les deux écritures ne sont pas synchronisées → data race.

L'emplacement mémoire : Location is global 'counter' — TSan identifie la variable par son nom. C'est la variable globale counter. Pour les allocations heap, TSan montre la pile d'appels d'allocation.

La création des threads : les deux derniers blocs montrent où chaque thread a été créé — data_race.cpp:13 et data_race.cpp:14. Vous savez quel code a lancé les threads impliqués.

Ces quatre informations — qui écrit, qui a écrit avant, sur quelle variable, et d'où viennent les threads — suffisent dans la grande majorité des cas pour comprendre la race et la corriger.


Patterns de data races courants

Pattern 1 : Variable partagée sans protection

Le cas le plus simple — une variable accessible par plusieurs threads sans mutex ni atomic :

// Problème
std::string shared_status;

void writer() { shared_status = "ready"; }  
void reader() { if (shared_status == "ready") { /* ... */ } }  
// Solution 1 : mutex
std::mutex mtx;  
std::string shared_status;  

void writer() {
    std::lock_guard<std::mutex> lock(mtx);
    shared_status = "ready";
}
void reader() {
    std::lock_guard<std::mutex> lock(mtx);
    if (shared_status == "ready") { /* ... */ }
}

// Solution 2 : atomic (pour les types simples)
std::atomic<bool> ready{false};

Pattern 2 : Flag de contrôle non atomique

Un anti-pattern fréquent — utiliser un bool ordinaire comme signal entre threads :

// Problème
bool should_stop = false;    // Data race !

void worker() {
    while (!should_stop) {   // Lecture sans synchronisation
        process_item();
    }
}

void controller() {
    std::this_thread::sleep_for(std::chrono::seconds(10));
    should_stop = true;      // Écriture sans synchronisation
}

Le bool n'est pas atomique. Pire : le compilateur a le droit de hoister la lecture de should_stop hors de la boucle (puisqu'aucun mécanisme de synchronisation ne l'oblige à relire), transformant le while (!should_stop) en boucle infinie.

// Solution
std::atomic<bool> should_stop{false};

void worker() {
    while (!should_stop.load()) {
        process_item();
    }
}

void controller() {
    std::this_thread::sleep_for(std::chrono::seconds(10));
    should_stop.store(true);
}

TSan détecte le problème avec le bool ordinaire. Avec std::atomic, la synchronisation est établie et TSan ne signale rien.

Pattern 3 : Accès concurrent à un conteneur STL

Les conteneurs de la STL ne sont pas thread-safe. Un push_back concurrent avec une lecture (même size()) est une data race :

// Problème
std::vector<int> results;

void producer(int id) {
    results.push_back(id * 100);    // Data race sur le vector
}

int main() {
    std::thread t1(producer, 1);
    std::thread t2(producer, 2);
    t1.join();
    t2.join();
}
// Solution
std::mutex mtx;  
std::vector<int> results;  

void producer(int id) {
    std::lock_guard<std::mutex> lock(mtx);
    results.push_back(id * 100);
}

Pattern 4 : Double-checked locking incorrect

Un pattern d'optimisation classique — et classiquement mal implémenté :

// Problème : double-checked locking sans atomic
Widget* instance = nullptr;  
std::mutex mtx;  

Widget* get_instance() {
    if (instance == nullptr) {           // Lecture non synchronisée
        std::lock_guard<std::mutex> lock(mtx);
        if (instance == nullptr) {
            instance = new Widget();     // Écriture sous lock
        }
    }
    return instance;
}

La première lecture de instance (avant le lock) n'est pas synchronisée avec l'écriture qui se fait sous le lock. TSan le détecte.

// Solution : atomic avec les bons memory orders
std::atomic<Widget*> instance{nullptr};  
std::mutex mtx;  

Widget* get_instance() {
    Widget* p = instance.load(std::memory_order_acquire);
    if (p == nullptr) {
        std::lock_guard<std::mutex> lock(mtx);
        p = instance.load(std::memory_order_relaxed);
        if (p == nullptr) {
            p = new Widget();
            instance.store(p, std::memory_order_release);
        }
    }
    return p;
}

// Solution plus simple : static local (thread-safe depuis C++11)
Widget& get_instance() {
    static Widget instance;
    return instance;
}

📎 Le singleton thread-safe est couvert en détail en section 44.1.1.

Pattern 5 : Race dans le constructeur/destructeur

Un objet partagé entre threads peut être accédé avant la fin de sa construction ou pendant sa destruction :

// Problème
struct Server {
    std::thread worker;
    int port;
    
    Server(int p) : port(p) {
        worker = std::thread([this]() {
            serve(port);    // Race : le constructeur n'a peut-être pas fini
        });
    }
    
    ~Server() {
        // Race : le worker peut accéder à des membres détruits
        if (worker.joinable()) worker.join();
    }
};

TSan détecte la race entre la construction de l'objet et l'accès par le thread worker. La solution est de séparer la construction de l'objet et le démarrage du thread (méthode start() explicite).


Races sur des std::atomic avec des memory orders relaxés

TSan comprend le modèle mémoire C++. Il ne signale pas de race sur des std::atomic utilisés correctement. Mais il détecte les cas où un memory_order_relaxed ne fournit pas la synchronisation nécessaire :

std::atomic<int> data{0};  
std::atomic<bool> ready{false};  

void writer() {
    data.store(42, std::memory_order_relaxed);
    ready.store(true, std::memory_order_relaxed);    // Pas de release !
}

void reader() {
    while (!ready.load(std::memory_order_relaxed));  // Pas d'acquire !
    int value = data.load(std::memory_order_relaxed);
    // value peut ne pas être 42 — pas de happens-before entre les stores/loads
}

TSan ne signale pas de data race ici (les accès sont atomiques), mais le programme est néanmoins incorrect. Pour ce type de bug de memory ordering, l'outil complémentaire est le mode --tool=helgrind de Valgrind, ou une relecture manuelle du code avec le modèle mémoire en tête.

⚠️ TSan détecte les data races, pas les bugs de memory ordering sur des atomics. Ce sont deux catégories de bugs distinctes. TSan couvre la première (la plus courante et la plus grave). La seconde nécessite une analyse plus fine.


Faux positifs et suppressions

TSan produit plus de faux positifs qu'ASan, pour plusieurs raisons :

  • Les bibliothèques tierces non instrumentées peuvent effectuer des synchronisations via des mécanismes que TSan ne reconnaît pas (assembly inline, syscalls directs).
  • Certains patterns d'allocation mémoire (allocateurs personnalisés, pools) contournent les intercepteurs de TSan.
  • Des bibliothèques système (glibc, libstdc++) ont parfois des races bénignes que les mainteneurs considèrent acceptables.

Fichier de suppressions

# tsan_suppressions.txt

# Race bénigne dans la glibc (connue et documentée)
race:__ctype_init

# Race dans une bibliothèque tierce qu'on ne peut pas corriger
race:third_party::legacy::ConnectionPool

# Race dans le runtime de logging (le logger a son propre locking)
race:spdlog::details::thread_pool
TSAN_OPTIONS="suppressions=tsan_suppressions.txt" ./prog-tsan

Syntaxe des suppressions

Le fichier utilise des patterns de fonctions :

# Suppression par nom de fonction
race:nom_de_fonction

# Suppression par nom de fichier source
race_top:nom_de_fichier.cpp

# Suppression par bibliothèque
called_from_lib:liblegacy.so

# Suppression de deadlock (TSan détecte aussi les deadlocks potentiels)
deadlock:nom_de_fonction

Annotations dans le code

Pour les cas où une race est intentionnelle (pattern de double-checked locking avec des barrières mémoire manuelles, allocateur lock-free, etc.), TSan fournit des annotations :

#include <sanitizer/tsan_interface.h>

void benign_race_function() {
    // Signaler à TSan que l'accès suivant est intentionnellement non synchronisé
    __tsan_acquire(&shared_var);
    // ... accès à shared_var ...
    __tsan_release(&shared_var);
}

Ces annotations doivent être utilisées avec une extrême parcimonie. Si vous pensez qu'une race est bénigne, demandez-vous si vous avez réellement prouvé qu'elle l'est — dans le modèle mémoire C++, pas dans votre intuition sur le hardware. La majorité des "races bénignes" sont en réalité des bugs.


Options d'exécution

# Configuration recommandée pour le CI
TSAN_OPTIONS="halt_on_error=1:second_deadlock_stack=1:log_path=/tmp/tsan"

# Configuration pour le développement (plus verbeux)
TSAN_OPTIONS="halt_on_error=0:history_size=7:second_deadlock_stack=1"

Les options clés :

Option Défaut Effet
halt_on_error 0 1 = arrêter à la première race (recommandé en CI)
history_size 3 Profondeur de l'historique d'accès (0-7). Plus haut = plus de contexte dans les rapports, plus de mémoire
second_deadlock_stack 0 1 = afficher les deux piles d'appels dans les rapports de deadlock
suppressions Chemin vers le fichier de suppressions
log_path stderr Redirige les rapports vers un fichier
exitcode 66 Code de retour quand une race est détectée

L'option history_size

Cette option mérite une explication. TSan enregistre un historique des accès mémoire récents pour chaque thread. Quand une race est détectée, cet historique permet de montrer le "previous access" dans le rapport.

Avec history_size=3 (défaut), l'historique est compact mais peut manquer des accès anciens — le rapport affiche parfois previous access not available au lieu d'une pile d'appels. Avec history_size=7, l'historique est beaucoup plus profond, mais la consommation mémoire augmente significativement.

En pratique, commencez avec la valeur par défaut. Si des rapports TSan manquent le "previous access", relancez avec history_size=5 ou 7.


Détection de deadlocks

En bonus, TSan détecte les deadlocks potentiels — des ordres d'acquisition de mutex qui peuvent mener à un blocage :

// potential_deadlock.cpp
#include <mutex>
#include <thread>

std::mutex mtx_a;  
std::mutex mtx_b;  

void thread1() {
    std::lock_guard<std::mutex> lock_a(mtx_a);
    std::lock_guard<std::mutex> lock_b(mtx_b);    // Ordre : A → B
}

void thread2() {
    std::lock_guard<std::mutex> lock_b(mtx_b);
    std::lock_guard<std::mutex> lock_a(mtx_a);    // Ordre : B → A ← inversion !
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);
    t1.join();
    t2.join();
}

Rapport TSan

==================
WARNING: ThreadSanitizer: lock-order-inversion (potential deadlock) (pid=54321)
  Cycle in lock order graph: M1 (0x000000601080) => M2 (0x6010000000a0) => M1

  Mutex M1 (0x000000601080) acquired here while holding mutex M2 (0x6010000000a0) 
  in thread T2:
    #0 pthread_mutex_lock (prog-tsan+0x42d4a0)
    #1 std::mutex::lock() mutex:100 (libstdc++.so.6+0xd1234)
    #2 thread2() potential_deadlock.cpp:12 (prog-tsan+0x4f4c20)

  Mutex M2 (0x6010000000a0) previously acquired by the same thread here:
    #0 pthread_mutex_lock (prog-tsan+0x42d4a0)
    #1 std::mutex::lock() mutex:100 (libstdc++.so.6+0xd1234)
    #2 thread2() potential_deadlock.cpp:11 (prog-tsan+0x4f4c00)

  Mutex M1 (0x000000601080) previously acquired by thread T1 here:
    #0 pthread_mutex_lock (prog-tsan+0x42d4a0)
    #1 std::mutex::lock() mutex:100 (libstdc++.so.6+0xd1234)
    #2 thread1() potential_deadlock.cpp:7 (prog-tsan+0x4f4b20)
[...]
==================

TSan détecte l'inversion d'ordre d'acquisition (A→B dans thread1, B→A dans thread2) même si le deadlock ne se produit pas dans cette exécution particulière. C'est un deadlock potentiel — il se produira quand les deux threads tenteront d'acquérir les mutex au même moment.

La solution est d'imposer un ordre global d'acquisition, ou d'utiliser std::scoped_lock qui acquiert plusieurs mutex de manière atomique et sans risque de deadlock :

void thread1() {
    std::scoped_lock lock(mtx_a, mtx_b);    // Acquiert les deux atomiquement
}

void thread2() {
    std::scoped_lock lock(mtx_a, mtx_b);    // Même ordre garanti
}

📎 std::scoped_lock est couvert en détail en section 21.2.4.


TSan et GDB

TSan et GDB se combinent, mais avec une particularité : le ralentissement de TSan modifie fortement le timing des threads. Un breakpoint GDB peut rendre une race non reproductible (le thread observé est ralenti, l'autre avance).

La méthode recommandée est de lancer TSan sans GDB, puis d'analyser le rapport :

# Étape 1 : détecter la race
./prog-tsan 2> tsan_report.txt

# Étape 2 : analyser le rapport pour identifier les lignes
cat tsan_report.txt

# Étape 3 : poser des breakpoints dans GDB (build normal, pas TSan)
gdb ./prog
(gdb) break data_race.cpp:9 if counter > 99990
(gdb) run

Si vous devez absolument utiliser GDB avec TSan :

TSAN_OPTIONS="halt_on_error=1" gdb ./prog-tsan
(gdb) run
# TSan détecte la race et appelle abort()
# GDB intercepte le SIGABRT
(gdb) backtrace

TSan et code lock-free

Le code lock-free (section 42.4) utilise des std::atomic avec des memory orders explicites et des opérations CAS (compare-and-swap). TSan comprend les opérations std::atomic du standard et ne signale pas de fausses races sur du code lock-free correctement écrit.

Cependant, si vous implémentez des structures lock-free avec des intrinsics compilateur (__sync_*, __atomic_*) ou de l'assembly inline, TSan peut ne pas reconnaître la synchronisation et signaler des faux positifs. Dans ce cas, les annotations __tsan_acquire / __tsan_release sont nécessaires.

La recommandation est claire : utilisez std::atomic avec les memory orders du standard. C'est portable, c'est correct par construction, et TSan le comprend nativement.


Intégration CMake

option(ENABLE_TSAN "Enable ThreadSanitizer" OFF)

if(ENABLE_TSAN)
    add_compile_options(-fsanitize=thread -fno-omit-frame-pointer)
    add_link_options(-fsanitize=thread)
    
    # TSan est incompatible avec ASan — vérification de sécurité
    if(ENABLE_ASAN)
        message(FATAL_ERROR "ASan and TSan cannot be enabled simultaneously")
    endif()
endif()

Différences GCC vs Clang

Aspect GCC 15 Clang 20
Détection des races ✅ Fiable ✅ Fiable
Détection des deadlocks ✅ Oui ✅ Oui
Qualité des rapports Bonne Légèrement meilleure (noms de mutex)
Surcoût mémoire ~8x ~5-8x
Annotations supportées tsan_interface.h tsan_interface.h
Faux positifs Rares Rares

Les deux compilateurs utilisent la même implémentation TSan (le runtime est commun, hébergé dans le projet compiler-rt de LLVM). Les différences sont mineures et tiennent à la qualité de l'instrumentation produite par chaque compilateur.


Limitations

Ce que TSan ne détecte pas

  • Bugs de memory ordering sur des atomics. Si vous utilisez memory_order_relaxed là où il faudrait memory_order_acquire/release, le programme a un bug, mais les accès sont atomiques — pas de data race au sens strict. TSan ne le signale pas.
  • Deadlocks avec des mécanismes non-standard. TSan comprend pthread_mutex, std::mutex, std::shared_mutex, et les variables de condition. Les mécanismes de synchronisation personnalisés (spinlocks maison, futex brut) ne sont pas reconnus sans annotations.
  • Starvation et livelock. Un thread qui ne progresse jamais parce qu'un autre accapare un mutex n'est pas une data race — TSan ne le détecte pas.

Faux négatifs

TSan ne détecte une race que si les deux accès conflictuels se produisent pendant l'exécution. Si le scheduler ne provoque pas l'interleaving problématique lors de l'exécution sous TSan, la race n'est pas détectée. Exécutez vos tests TSan plusieurs fois, idéalement avec des charges variées, pour maximiser la couverture.

# Exécuter les tests TSan 10 fois pour augmenter les chances de détection
for i in $(seq 1 10); do
    ./prog-tsan --test-suite || exit 1
done

Impact sur le timing

Le ralentissement de TSan (5-15x) modifie radicalement le timing entre threads. Paradoxalement, cela peut à la fois :

  • Révéler des races qui ne se manifestaient pas en conditions normales (l'interleaving change).
  • Masquer des races qui nécessitent un timing très précis pour se manifester (le timing change aussi).

C'est une raison de plus pour exécuter les tests TSan plusieurs fois avec des conditions variées.


Checklist TSan

  • Build TSan séparé — jamais combiné avec ASan dans le même binaire
  • Exécuter en CI — un job dédié qui compile avec -fsanitize=thread et exécute les tests
  • halt_on_error=1 en CI — toute race détectée fait échouer le pipeline
  • Suppressions maintenables — un fichier tsan_suppressions.txt versionné avec le code, commenté, et révisé régulièrement
  • Exécutions multiples — lancer les tests TSan plusieurs fois pour maximiser la couverture d'interleaving
  • Zéro race tolérée dans votre code — supprimez uniquement les races dans les bibliothèques tierces que vous ne contrôlez pas
  • Utiliser std::atomic — pas de volatile, pas d'intrinsics compilateur, pas d'assembly inline pour la synchronisation

À retenir : une data race est un comportement indéfini, au même titre qu'un buffer overflow ou un déréférencement de null. TSan la détecte même quand elle ne provoque aucun symptôme visible — parce qu'elle en provoquera un, tôt ou tard, dans les pires conditions possibles. Le surcoût de TSan (5-15x) est élevé mais localisé aux tests. Le coût d'une race non détectée est illimité.

⏭️ MemorySanitizer (-fsanitize=memory)