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:
// 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:
hasil: 200000Di 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):
g++ -std=c++23 -g -Wall -Wextra -fsanitize=thread race-target.cpp -o race-target
./race-targetKali 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):
==================
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 warningsLaporan TSan selalu berformat “dua pihak plus satu lokasi”:
- Akses kedua:
Read of size 8 ... by thread T2di baris 9 — pembacaanpenghitungpada++penghitung. - 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. Location is global 'penghitung': variabel yang bersengketa, lengkap dengan ukurannya (8 byte untuklong).- Dua blok
created by: dari mana masing-masing thread berasal —maindi baris 14 dan 15 — sehingga kita tahu thread mana saja yang terlibat. SUMMARY: satu baris untuk log dan issue tracker. Program berakhir dengan kode akhir 66 (66 berarti TSan menemukan pelanggaran) dan baris penutupreported 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:
// 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:
hasil: 400000Dua 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=threadvs-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 .