🔝 Retour au Sommaire
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::coutpour déboguer ralentit le thread et change le timing. Le bug disparaît. Retirez lestd::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.
g++ -fsanitize=thread -g -O1 -fno-omit-frame-pointer -o prog main.cppRappel 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.cppTSan 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.
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: ununlocksur un mutex happens-before le prochainlocksur ce mutex.std::atomic: une écriture avecmemory_order_releasehappens-before une lecture avecmemory_order_acquiresur la même variable atomique.std::thread: la création d'un thread (std::thread t(f)) happens-before la première instruction def. L'appel àt.join()happens-after la dernière instruction def.std::condition_variable: unnotifyhappens-before lewaitqui 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.
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.
// 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.
g++ -fsanitize=thread -g -O1 -fno-omit-frame-pointer -std=c++20 -o data_race data_race.cpp
./data_race==================
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
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.
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};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.
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);
}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.
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).
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.
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.
# 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-tsanLe 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
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.
# 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 |
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.
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();
}==================
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_lockest couvert en détail en section 21.2.4.
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) runSi 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) backtraceLe 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.
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()| 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.
- Bugs de memory ordering sur des atomics. Si vous utilisez
memory_order_relaxedlà où il faudraitmemory_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.
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
doneLe 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.
- ☐ 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=threadet exécute les tests - ☐
halt_on_error=1en CI — toute race détectée fait échouer le pipeline - ☐ Suppressions maintenables — un fichier
tsan_suppressions.txtversionné 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 devolatile, 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é.