Data races en de limieten van ThreadSanitizer in C en Go

Om vertrouwen te krijgen in de code die we schrijven, maken we tests. Maar wanneer we concurrency (gelijktijdigheid) toevoegen, worden bugs nondeterministisch. Om extra zekerheid te krijgen bij concurrent code, kunnen we een race-detector inschakelen die ons meldt dat er tijdens een run een race is opgetreden in een bepaald stuk code. Dit is nuttig, omdat onze tests mogelijk zijn geslaagd, terwijl er toch een race plaatsvond. Het bestaan van de race betekent, ongeacht of de tests falen, dat er absoluut een latent defect aanwezig is.

De meeste grote taalimplementaties die een race-detector hebben (Clang, GCC, Go, Swift, OCaml) maken gebruik van LLVM’s ThreadSanitizer. ThreadSanitizer (TSan) is niet goed gedocumenteerd. Het heeft drie grote iteraties doorgemaakt. Hoewel het algoritme voor TSan versie twee (uit 2012) vindbaar is, suggereert de auteur van TSan dat we voor versie drie (uit 2021) simpelweg de broncode moeten lezen om het te begrijpen.

Er is een rijke geschiedenis van algoritmen voor het detecteren van data races, zoals Eraser (dat TSan versie één beïnvloedde), FastTrack (met overeenkomsten met TSan versie twee en drie) en RaceTrack van Microsoft. Elk algoritme heeft zijn eigen beperkingen, en dat geldt ook voor TSan. Clang, GCC en Go bieden generieke toegangspunten voor race-detector-bibliotheken, ook al is TSan momenteel de enige die serieus wordt gebruikt.

In dit artikel behandelen we de basis van data races. Vervolgens implementeren we een geïdealiseerde interpreter in Python voor multi-threaded C-code, samen met vector clocks in de stijl van FastTrack, om te laten zien hoe TSan ongeveer werkt. Daarna laten we zien hoe architectuurkeuzes het mogelijk maken om TSan op enkele vlakken te overbelasten, waardoor overduidelijke data races over het hoofd worden gezien. TSan kan bijvoorbeeld data races niet betrouwbaar rapporteren wanneer de grens van 255 threads wordt overschreden. Dit is geen zeldzame situatie, zeker niet bij webservices in talen als Go, waar vaak één goroutine per request wordt gebruikt.

Sommige van deze kwesties werden besproken in hoofdstuk 6 van de PhD-thesis uit 2025 van Farzam Dorostkar, "Identifying and mitigating implementation-induced data race detection blind spots in ThreadSanitizer v3".

Er zijn ook enkele Go-specifieke scenario's die door TSan niet worden gedetecteerd. We bespreken een ontwerpbeslissing in sync.Pool die elke race tussen twee goroutines maskeert waarvan de gepoolde objecten — zelfs in niet-gerelateerde pools — naar hetzelfde slot hashen.

Dit is niet bedoeld om te zeggen dat TSan een slecht hulpmiddel is of dat de auteurs hun werk beter hadden moeten doen. TSan is onmisbaar. Toch is het goed om de limieten ervan te kennen.

Achtergrond

De C11-standaard stelt: "de uitvoering van een programma bevat een data race als het twee conflicterende acties bevat in verschillende threads, waarvan er ten minste één niet atomair is, en waarvan geen van beide vóór de andere plaatsvindt. Elke dergelijke data race resulteert in ongedefinieerd gedrag."

Go stelt: "een data race wordt gedefinieerd als een schrijfactie naar een geheugenlocatie die gelijktijdig plaatsvindt met een andere lees- of schrijfactie naar diezelfde locatie, tenzij alle betrokken toegangen atomaire data-toegangen zijn, zoals geleverd door het sync/atomic pakket."

Een data race is, theoretisch gezien, iets dat we automatisch kunnen detecteren zonder extra aanwijzingen.

Voorbeeld in C

In dit voorbeeld lezen twee threads een teller, voeren wat logica uit en updaten vervolgens de teller.

// counter_data_race.c
#include <pthread.h>
#include <stdlib.h>

long counter;

void* bump(void* n) {
    for (long i = 0; i < *(long*)n; i++) {
        long v = counter;
        // some logic
        counter = v + 1;
    }
    return 0;
}

int main(void) {
    pthread_t p, q;
    long n = atol(getenv("N") ?: "100");
    pthread_create(&p, 0, bump, &n);
    pthread_create(&q, 0, bump, &n);
    pthread_join(p, 0);
    pthread_join(q, 0);
    return (counter == n * 2) ? 0 : 1;
}

Onder "normale" omstandigheden merken we de race misschien niet op; de code kan in CI consistent slagen. Als we de code echter "stressen" (bijvoorbeeld door N te verhogen naar 10.000), kunnen we de fout spotten. Met TSan kunnen we dit direct detecteren door te compileren met -fsanitize=thread -g:

$ gcc -fsanitize=thread -g counter_data_race.c -o counter_data_race
$ ./counter_data_race

TSan zal een WARNING: ThreadSanitizer: data race rapporteren, inclusief de exacte regels waar de lees- en schrijfacties plaatsvonden.

Voorbeeld in Go

Hetzelfde scenario in Go:

// counter_data_race.go
package main

import (
    "os"
    "strconv"
    "sync"
)

var counter int64

func bump(n int64, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := int64(0); i < n; i++ {
        v := counter
        // some logic
        counter = v + 1
    }
}

func main() {
    n := int64(100)
    if s := os.Getenv("N"); s != "" {
        if v, err := strconv.ParseInt(s, 10, 64); err == nil {
            n = v
        }
    }
    var wg sync.WaitGroup
    wg.Add(2)
    go bump(n, &wg)
    go bump(n, &wg)
    wg.Wait()
    if counter != n*2 {
        os.Exit(1)
    }
}

Ook hier gedraagt Go zich vergelijkbaar. Door -race toe te voegen aan het build-proces, vangt TSan de race onmiddellijk op. Interessant is dat de race-detector dit zelfs oppikt wanneer Go wordt beperkt tot slechts één thread (GOMAXPROCS=1). Dit komt omdat TSan hooks biedt voor elke thread-implementatie; Go moet TSan namelijk informeren telkens wanneer er een nieuwe goroutine wordt gemaakt.

Data race vs. General race

We kunnen een data race "foutief" oplossen op een manier die geen enkele race-detector kan spotten, waardoor we de data race omzetten in een algemene race (een logische fout).

C-voorbeeld van een general race:

// counter_general_race.c
#include <pthread.h>
#include <stdlib.h>

long counter;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

void* bump(void* n) {
    for (long i = 0; i < *(long*)n; i++) {
        pthread_mutex_lock(&m);
        long v = counter;
        pthread_mutex_unlock(&m);
        // some logic
        pthread_mutex_lock(&m);
        counter = v + 1;
        pthread_mutex_unlock(&m);
    }
    return 0;
}

int main(void) {
    pthread_t p, q;
    long n = atol(getenv("N") ?: "100");
    pthread_create(&p, 0, bump, &n);
    pthread_create(&q, 0, bump, &n);
    pthread_join(p, 0);
    pthread_join(q, 0);
    return (counter == n * 2) ? 0 : 1;
}

In dit geval is elke individuele toegang aan counter beschermd door een mutex, dus er is geen data race meer. Echter, de logische fout blijft: een andere thread kan de teller updaten tussen het moment dat thread A de waarde leest en het moment dat thread A de nieuwe waarde schrijft. De race-detector zal hier niets over zeggen, maar het programma kan nog steeds falen.

Het interpreteren van een geïdealiseerde subset van C

Om te begrijpen hoe detectie werkt, bouwen we een eenvoudige interpreter in Python die een subset van C modelleert. Deze interpreter negeert variabelendeclaraties en richt zich op functies en statements.

De Interpreter-logica

De interpreter beheert de status van threads, locks, functiebodies en variabelen. Belangrijke functies zijn:

  • write: Schrijft een waarde naar een variabele.
  • compare: Vergelijkt twee waarden.
  • thread_create: Markeert een thread als actief en planbaar.
  • thread_join: Blokkeert totdat een thread afsluit.
  • mutexlock / mutexunlock: Beheert mutex-eigenaarschap en blokkeert threads indien nodig.

De run-functie plant threads zodra ze niet langer geblokkeerd zijn, totdat alle actieve threads zijn beëindigd.

Instrumentatie

Wanneer we compileren met -fsanitize=thread, doet de compiler twee dingen:

  1. Hij herschrijft de code zodat geheugentoegangen extra aanroepen maken naar functies zoals tsanread of tsanwrite.
  2. Hij linkt tegen libtsan, die deze symbolen implementeert.

TSan handelt zelf de wrapping van pthreads-synchronisatiemethoden af. Als u een andere threading-bibliotheek gebruikt, moet u TSan daarover informeren.

Eraser

Eraser is een historisch algoritme voor race-detectie gebaseerd op het observeren van vastgehouden locks. In Eraser doorloopt elk adres in het geheugen een van de volgende vier toestanden:

  1. Virgin: Nog niet benaderd.
  2. Exclusive: Alleen door één thread benaderd.
  3. Shared: Gelezen door meer dan één thread, geschreven door slechts één thread.
  4. SharedModified: Wanneer een nieuwe thread een Exclusive adres schrijft, of elke thread een Shared adres schrijft.

Wanneer een adres Shared of SharedModified is, noteert Eraser alle locks die op dat moment door de thread worden vastgehouden. Er wordt een intersectie bijgehouden van alle locks die historisch gezien zijn gebruikt bij elke toegang tot het adres. Als de intersectie leeg is bij een SharedModified status, wordt er een data race gerapporteerd.

Het basis-Eraser-algoritme is echter gevoelig voor false positives omdat het geen rekening houdt met synchronisatie-idiomen zoals barrier synchronization of thread join.

FastTrack en Vector Clocks

TSan v2 en v3 lijken sterk op FastTrack. In FastTrack heeft elke thread een monotonische tijdsteller (een clock). Elke thread bewaart ook een vector clock met de tijd waarvan hij op de hoogte is voor elke andere thread. Er is een globale mapping van adressen naar de thread die dit adres als laatste heeft gelezen of geschreven, en wanneer dat gebeurde.

  • Release: Wanneer een thread een primitief vrijgeeft (bijv. een mutex unlockt), wordt de vector clock van die thread opgeslagen in de primitief.
  • Acquire: Wanneer een thread een primitief gebruikt (bijv. een mutex lockt), neemt deze het maximum van elke tijd in zijn eigen vector clock en de vector clock van de primitief. Zo "reist" de volgorde tussen threads.
  • Start: Een nieuwe thread erft de vector clock van zijn parent.

Bij een geheugentoegang controleert de detector de globale mapping. Als de huidige thread's visie op de tijd van de laatste schrijver lager is dan de tijd waarop de schrijfactie daadwerkelijk plaatsvond, is er sprake van een data race.

Waar TSan tekortschiet

TSan is zeer nuttig, maar heeft zwaktes. Een voorbeeld is "over-ordering". In scenario's waar synchronisatie indirect plaatsvindt (bijvoorbeeld via een mutex die gelockt en direct weer geunlockt wordt), kan TSan soms een race over het hoofd zien, hoewel het in sommige gevallen willekeurig toch wordt opgepikt.

Budgetten en Resource-limieten

Om de impact op de workload te beperken, hanteert TSan strikte budgetten. Er zijn drie dimensies waar TSan overbelast kan raken:

1. Te veel threads

TSan houdt maximaal 255 threads bij voordat het ronddraait en slots hergebruikt. Als u een langlopende schrijver-thread heeft en vervolgens meer dan 253 andere threads aanmaakt en vernietigt, kan TSan de race tussen de oorspronkelijke schrijver en een nieuwe lezer-thread missen. Dit gebeurt omdat de identifier van de schrijver-thread is overschreven in de interne tracker.

2. Synchronisaties-releases

De teller in elke thread-slot van TSan is slechts 14 bits. Wanneer een thread zeer vaak dezelfde mutex lockt en unlockt (miljoenen keren), kan het budget van de thread-slot-teller worden uitgeput. Hierdoor kan TSan een race missen. In Go gebeurt dit sneller, omdat elke mutex lock/unlock-pair in TSan drie releases verbruikt, tegenover één bij pthreads.

3. Toegangshistorie (Access History)

TSan groepeert adressen in granules van 8 bytes, met een budget van vier "cells" per granule om toegangen te registreren. Elke unieke toegang (verschillende thread of verschillende bytes binnen de 8 bytes) claimt een cell. Zodra vier cells bezet zijn, wordt de volgende unieke toegang een oude cell uitgewist (eviction), zelfs als TSan net heeft vastgesteld dat die toegang niet kon racen. Als er een vijfde unieke toegang is binnen die 8 bytes, kan een eerdere race-situatie uit het geheugen worden gewist en dus onopgemerkt blijven.

De specifieke case van sync.Pool in Go

Een thread-safe object pool (sync.Pool) stelt goroutines in staat objecten te hergebruiken. Omdat hergebruik van objecten tussen threads eruitziet als een data race, communiceert de pool direct met de race-detector om aan te geven dat een adres is verkregen of vrijgegeven.

Om conflicten met andere synchronisatie op dat object te voorkomen, gebruikt Go een hash van het adres van het object om een van de 128 gereserveerde adressen te kiezen voor synchronisatie-tracking.

Het probleem is dat twee verschillende objecten (zelfs in ongerelateerde pools) naar hetzelfde hash-slot kunnen wijzen. Hierdoor denkt de race-detector dat er een volgorde (ordering) bestaat tussen twee goroutines, terwijl dat in werkelijkheid niet zo is. Elke data race tussen deze goroutines blijft dan ongerapporteerd.

Slotgedachten

Race-detectoren zijn ongelooflijk nuttig, maar ze hebben edge cases die vaak niet gedocumenteerd zijn. Het kennen van deze limieten — zoals de thread-limiet van 255 of de cell-limiet van 4 per 8 bytes — helpt bij het begrijpen waarom bepaalde bugs soms ontsnappen aan detectie.