Data race adalah cacat paling berbahaya di pemrograman konkuren: programnya dikompilasi tanpa protes, berjalan tanpa crash, dan hasilnya — kadang — benar. Standar C++ mendefinisikannya dengan presisi dan menjatuhkan hukuman yang berat: undefined behavior, artinya tidak ada jaminan apa pun terhadap perilaku program. Karena kejadiannya bergantung pada waktu, data race bisa lolos dari semua pengujian biasa, lalu muncul di mesin produksi yang lebih cepat. Chapter ini merumuskan definisinya, menunjukkan program yang mengandungnya (tanpa menjalankannya), memperkenalkan ThreadSanitizer sebagai detektor otomatis, dan menutup dengan versi aman yang benar-benar teruji.
Definisi Menurut Standar #
Standar C++ menyatakan dua ekspresi “bertabrakan” (conflict) bila keduanya mengakses lokasi memori yang sama, dan minimal salah satu adalah penulisan. Data race terjadi bila dua ekspresi yang bertabrakan dieksekusi pada saat bersamaan oleh thread yang berbeda TANPA relasi happens-before di antara keduanya — tidak ada mutex yang mengatur, tidak ada atomic yang melindungi, tidak ada join yang memisahkan. Rangkumannya tiga syarat: dua thread, data yang sama, minimal satu menulis — dan tanpa sinkronisasi.
Konsekuensinya bukan “hasil bisa agak salah”. Standar menyatakan program dengan data race berperilaku undefined behavior: kompiler bebas mengasumsikan race tidak pernah terjadi, menghapus pemeriksaan, mengatur ulang kode di sekitarnya, dan hasilnya bisa apa saja — dari angka yang melenceng sampai pembacaan memori yang tidak sah. Inilah alasan kita tidak pernah “menjalankan program race untuk melihat hasilnya” di course ini: hasilnya tidak berharga untuk dipelajari karena tidak bisa dipercaya.
Program yang Mengandung Data Race #
#include <thread>
int main() {
int data = 0;
bool flag = false;
std::thread t([&] {
data = 42; // tulis data biasa...
flag = true; // ...lalu sinyal — TANPA sinkronisasi
});
while (!flag) { } // menunggu tanpa happens-before
if (data != 42) return 1;
t.join();
return 0;
}Ada dua race sekaligus di sini. Pertama pada data: thread utama membacanya sementara thread pekerja menulisnya, tanpa sinkronisasi — persis tiga syarat di atas. Kedua pada flag, yang juga ditulis pekerja dan dibaca main() tanpa perlindungan. Seperti dibahas di chapter model memori, compiler boleh mengatur ulang data = 42 dan flag = true secara independen, sehingga main() bisa melihat sinyal menyala sementara isinya masih 0. Perbaikan yang tepat sudah kita pelajari: jadikan flag bertipe std::atomic<bool> dengan pasangan release-acquire, atau lindungi keduanya dengan mutex.
Deteksi: ThreadSanitizer #
Karena data race bisa tidak muncul selama ribuan eksekusi, manusia tidak cukup andal sebagai detektor. GCC/Clang menyediakan ThreadSanitizer (TSan): instrumentation pada waktu kompilasi yang memantau setiap akses memori dan membangun grafik happens-before saat program berjalan. Bila ada dua akses yang bertabrakan tanpa relasi, TSan melaporkannya beserta jejak kedua thread.
g++ -std=c++23 -Wall -Wextra -fsanitize=thread race.cpp -o race
./race # laporan TSan dicetak saat program berjalanKetika program di atas dikompilasi dan dijalankan dengan flag itu, TSan mengeluarkan laporan seperti berikut — diperpendek, dan alamat/pid/BuildId di dalamnya pasti berbeda di setiap mesin:
==================
WARNING: ThreadSanitizer: data race (pid=24541)
Read of size 1 at 0x7ffd31a64b1b by main thread:
#0 main (race+0x1346)
Previous write of size 1 at 0x7ffd31a64b1b by thread T1:
#0 main::{lambda()#1}::operator()() const (race+0x12b3)
Location is stack of main thread.
Thread T1 (finished) created by main thread at:
#0 pthread_create (libtsan.so.2)
#1 std::thread::_M_start_thread(...) (libstdc++.so.6)
SUMMARY: ThreadSanitizer: data race in main
==================
ThreadSanitizer: reported 1 warningsLaporan itu padat namun bisa dibaca: baris “Read of size 1 … by main thread” menunjukkan pembacaan flag di main(), dan “Previous write … by thread T1” menunjukkan penulisan sinyal di lambda — dua akses bertabrakan tanpa sinkronisasi. Yang perlu dibiasakan: jalankan semua program konkuren di bawah TSan selama pengembangan; ia hanya menemukan race yang benar-benar terjadi dalam eksekusi itu, jadi uji dengan skenario yang beragam. Flag lengkapnya terdokumentasi di Instrumentation Options GCC
.
Versi Aman yang Teruji #
Kebalikan dari program bermasalah di atas adalah pola desain yang menghindari data bersama sedini mungkin: setiap thread menghitung atas potongannya sendiri, menumpuk hasilnya di variabel LOKAL, dan melaporkannya satu kali lewat std::atomic. Titik temu antar thread tinggal satu — dan itu atomic, sehingga tidak ada tabrakan.
#include <atomic>
#include <iostream>
#include <numeric>
#include <thread>
#include <vector>
// Menjumlahkan potongan data TANPA variabel bersama:
// setiap thread menumpuk hasil lokal, lalu melaporkannya sekali di akhir
void akumulasi(const std::vector<int>& data, std::size_t awal,
std::size_t akhir, std::atomic<long long>& total) {
long long lokal = 0;
for (std::size_t i = awal; i < akhir; ++i) {
lokal += data[i];
}
total.fetch_add(lokal); // satu titik sinkronisasi yang aman
}
int main() {
std::vector<int> data(1000);
std::iota(data.begin(), data.end(), 1); // isi 1..1000
std::atomic<long long> total{0};
std::thread t1(akumulasi, std::cref(data), 0, 500, std::ref(total));
std::thread t2(akumulasi, std::cref(data), 500, 1000, std::ref(total));
t1.join();
t2.join();
std::cout << "total: " << total.load() << "\n";
return 0;
}Output yang Dihasilkan:
total: 500500Bedah baris kuncinya:
- Baris 11-14 (
lokal): akumulasi berjalan di variabel lokal milik tiap thread — tidak ada dua thread menyentuh memori yang sama, jadi tidak ada yang perlu dikunci. Vektordatahanya dibaca oleh kedua thread (dua pembaca tanpa penulis aman, sesuai aturan chapter pertama). - Baris 15 (
total.fetch_add(lokal)): satu-satunya titik temu, dan bersifat atomik — happens-before dijamin oleh atomic itu sendiri. - Baris 23-24 (
std::cref,std::ref):std::threadmenyalin argumen, jadi referensi dibungkus eksplisit:crefuntuk data baca-saja,refuntuk total yang ditulis atomik. - Baris 29 (
total.load()): pembacaan final terjadi setelah keduajoin(), dan hasilnya deterministik: 1+2+…+1000 = 500500.
Dengan definisi yang presisi dan detektor otomatis, data race berubah dari momok menjadi masalah yang bisa dikelola. Selanjutnya kita beralih dari masalah ke solusi komunikasi: bagaimana thread mengirim HASIL kerjanya dengan elegan — lewat kanal satu arah promise dan future.
Referensi: cppreference — C++ memory model , cppreference — Concurrency support library .