Data race adalah bug yang paling menolak ditemui lewat cara biasa: program bisa lulus semua test selama berbulan-bulan, lalu menghasilkan angka salah satu kali dalam seribu kali jalan di mesin pelanggan. Penyebabnya sederhana di kata, sulit di bukti — dua thread mengakses memori yang sama tanpa pengamanan dan setidaknya satu menulis. Mata manusia jarang berhasil menyusuri jutaan interleaving; ThreadSanitizer (TSan) yang menggantikan kita: ia mencatat setiap akses memori dari setiap thread dan melaporkan pasangan akses yang pelanggaran urutannya. Section Concurrency dan Parallelism sudah membedah teori data race; chapter ini fokus pada alat pembuktinya.

Data Race dalam Lima Belas Baris #

Program berikut diberi dua thread yang masing-masing menaikkan penghitung 100000 kali. Hasil yang benar tentu 200000:

cpp race-target.cpp — dua thread, satu penghitung, tanpa pengamanan
// race-target.cpp — dua thread menaikkan penghitung yang sama tanpa pengamanan.
#include <iostream>
#include <thread>

long penghitung = 0;

void naikBanyak() {
    for (int i = 0; i < 100000; ++i) {
        ++penghitung;
    }
}

int main() {
    std::thread t1(naikBanyak);
    std::thread t2(naikBanyak);
    t1.join();
    t2.join();
    std::cout << "hasil: " << penghitung << "\n";
    return 0;
}

Di lingkungan penulis, salah satu jalannya mencetak:

text Output salah satu eksekusi (tidak menentu antar jalan)
hasil: 200000

Di jalan yang lain angkanya bisa 173284, 199871, atau nilai lain — persis sifat data race yang membuatnya licin. ++penghitung bukan satu langkah mesin, melainkan tiga: baca, tambah, tulis; dua thread yang saling berseling pada tiga langkah itu kehilangan sebagian kenaikan. Perhatikan juga: compiler membiarkan kode ini tanpa satu pun peringatan — bukti bahwa bug ini butuh alat runtime, bukan sekadar flag peringatan.

Laporan ThreadSanitizer #

Kompilasi program yang sama dengan -fsanitize=thread (flag -g agar laporan berisi berkas dan baris):

bash
g++ -std=c++23 -g -Wall -Wextra -fsanitize=thread race-target.cpp -o race-target
./race-target

Kali ini setiap jalan pasti berhenti dengan laporan — berikut laporan nyata dari sandbox penulis, dipangkas frame internal std::thread agar fokus (alamat, pid, dan BuildId berbeda di setiap mesin):

text Contoh output ThreadSanitizer (disingkat)
==================
WARNING: ThreadSanitizer: data race (pid=30402)
  Read of size 8 at 0x558d2e7701d8 by thread T2:
    #0 naikBanyak() race-target.cpp:9
    #1 void std::__invoke_impl<void, void (*)()>(...) ... invoke.h:61
    #2 ... (frame internal std::thread, dipangkas)

  Previous write of size 8 at 0x558d2e7701d8 by thread T1:
    #0 naikBanyak() race-target.cpp:9
    #1 void std::__invoke_impl<void, void (*)()>(...) ... invoke.h:61
    #2 ... (frame internal std::thread, dipangkas)

  Location is global 'penghitung' of size 8 at 0x558d2e7701d8

  Thread T2 (tid=30405, running) created by main thread at:
    #0 pthread_create ... (libtsan.so.2)
    #2 main race-target.cpp:15

  Thread T1 (tid=30404, running) created by main thread at:
    #0 pthread_create ... (libtsan.so.2)
    #2 main race-target.cpp:14

SUMMARY: ThreadSanitizer: data race race-target.cpp:9 in naikBanyak()
==================
ThreadSanitizer: reported 1 warnings

Laporan TSan selalu berformat “dua pihak plus satu lokasi”:

  1. Akses kedua: Read of size 8 ... by thread T2 di baris 9 — pembacaan penghitung pada ++penghitung.
  2. Akses pertama: Previous write ... by thread T1 — penulisan sebelumnya pada baris yang sama. Dua akses dari thread berbeda, minimal satu menulis, tanpa pengurutan yang menghubungkan mereka: itu definisi data race.
  3. Location is global 'penghitung': variabel yang bersengketa, lengkap dengan ukurannya (8 byte untuk long).
  4. Dua blok created by: dari mana masing-masing thread berasal — main di baris 14 dan 15 — sehingga kita tahu thread mana saja yang terlibat.
  5. SUMMARY: satu baris untuk log dan issue tracker. Program berakhir dengan kode akhir 66 (66 berarti TSan menemukan pelanggaran) dan baris penutup reported 1 warnings.

Perlu dipegang: TSan tidak peduli hasil akhirnya 200000 atau tidak. Ia melaporkan pola akses yang melanggar aturan sinkronisasi bahasa — pelanggaran itu sendiri adalah bug, meski kebetulan hasilnya tampak benar pada jalan tertentu.

Memperbaiki: Satu Pemilik Perubahan #

Perbaikan paling ringkas untuk penghitung adalah std::atomic — operasinya menjadi tidak terpotong dan terurut antar thread:

cpp
// aman-race.cpp — penghitung yang diakses empat thread tanpa data race.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

std::atomic<long> penghitung{0};

void naikBanyak() {
    for (int i = 0; i < 100000; ++i) {
        ++penghitung;
    }
}

int main() {
    std::vector<std::thread> pekerja;
    for (int t = 0; t < 4; ++t) {
        pekerja.emplace_back(naikBanyak);
    }
    for (std::thread& t : pekerja) {
        t.join();
    }
    std::cout << "hasil: " << penghitung << "\n";
    return 0;
}

Output yang Dihasilkan:

text
hasil: 400000

Dua perubahan menjelaskan semuanya. Baris 8: std::atomic<long> penghitung{0}; — tipe yang menjamin setiap ++ dilakukan sebagai satu operasi tak-terpotong; compiler menerjemahkannya menjadi instruksi atomik CPU, dan standar C++ menjamin tidak ada thread yang melihat keadaan setengah jadi. Baris 9-18: versi ini diberi empat thread (bukan dua) sehingga klaim “400000 selalu” benar-benar teruji — dan memang hasilnya deterministik. Untuk memori yang butuh lebih dari satu operasi (memperbarui beberapa field sekaligus), alatnya std::mutex dengan std::lock_guard; prinsipnya sama: setiap akses berbagi harus berada di bawah pengamanan yang sama.

Prinsip Kerja dan Batas TSan #

TSan memakai algoritma happens-before: setiap akses memori dicatat beserta “jam” logical thread-nya, dan runtime memeriksa apakah pasangan akses pada alamat yang sama punya urutan yang terbukti (mis. dipisahkan mutex atau atomik). Bila tidak ada urutan yang bisa dibuktikan, race dilaporkan. Konsekuensi praktisnya:

  • Deteksinya presisi — laporan memuat kedua akses dan rantai pembuatan thread, bukan dugaan.
  • Ia lambat — program bisa berkali-kali lipat lebih lambat dan memakai memori jauh lebih besar; inilah alasan TSan tidak boleh menyala pada build produksi.
  • Ia hanya melihat jalur yang dieksekusi — race pada kode yang tidak pernah dijalankan test tidak akan terlihat; makin luas coverage test, makin bernilai TSan.
  • Ia tidak bisa digabung dengan ASan — keduanya menggantikan mekanisme memori dengan cara yang berbenturan; jalankan pada build terpisah (-fsanitize=thread vs -fsanitize=address).

Kebiasaan yang kami anjurkan: satu build TSan untuk test suite, satu build ASan+UBSan untuk test suite, keduanya hijau sebelum merge. Selama kode berjalan “dengan benar” di bawah ketiganya, wilayah runtime sudah terjaga. Pertanyaan berikutnya muncul sebelum program dijalankan sama sekali: apakah struktur kodenya menyimpan jebakan yang bisa ditemukan tanpa eksekusi? Di sanalah static analysis bekerja — topik chapter berikutnya.

Referensi: Clang — ThreadSanitizer , Google sanitizers .