Pendahuluan: Empat Baris Kode yang Melawan Intuisi ── Uji Litmus Store Buffering (SB)
Perhatikan program C konkuren bốn baris berikut. Dua variabel integer bersama x dan y diinisialisasi ke 0. Dua thread yang berjalan pada dua inti (core) CPU berbeda masing-masing mengeksekusi satu penulisan (Store) diikuti satu pembacaan (Load):
| |
Nilai akhir (r1, r2) apa saja yang mungkin terjadi setelah kedua thread selesai?
Menurut akal sehat:
- Jika Thread 1 selesai sebelum Thread 2, hasilnya
r1 = 0, r2 = 1. - Jika Thread 2 selesai sebelum Thread 1, hasilnya
r1 = 1, r2 = 0. - Jika keduanya menjalankan baris pertama (
x = 1dany = 1) sebelum baris kedua, hasilnyar1 = 1, r2 = 1.
Dalam urutan mana pun, setidaknya satu penulisan 1 pasti terjadi sebelum pembacaan oleh thread lawan; oleh karena itu, hasil r1 == 0 && r2 == 0 tampak mustahil secara matematis. Namun, jika Anda menjalankan kode ini pada prosesor modern Intel/AMD x86-64, chip Apple Silicon (ARMv8), atau dengan kompiler C/C++/Rust yang dioptimalkan (-O2), hasil “mustahil” r1 == 0 && r2 == 0 benar-benar terjadi ribuan kali per detik! Ini bukan kerusakan perangkat keras, melainkan akibat dari pengurutan ulang instruksi (Instruction Reordering) di bawah aturan “As-If” thread tunggal.
Bab 1: Ilusi Konsistensi Sekuensial (Sequential Consistency)
Pada tahun 1979, peraih Turing Award Leslie Lamport memformalkan model mental intuitif pemrogram sebagai Konsistensi Sekuensial (Sequential Consistency, SC): hasil dari setiap eksekusi adalah sama seolah-olah operasi dari semua prosesor dijalankan dalam satu urutan total global ($\prec_m$), dan operasi dari masing-masing prosesor muncul dalam urutan tersebut tepat sesuai urutan program (Program Order, $\prec_p$) yang tertulis di kode sumber.
Di bawah Konsistensi Sekuensial, ketidakmungkinan r1 == 0 && r2 == 0 dapat dibuktikan dalam tiga baris:
Agar $r_1 = 0$, pembacaan $L_1$ harus mendahului penulisan $S_2$ ($L_1 \prec_m S_2$). Demikian pula, $r_2 = 0$ mengharuskan $L_2 \prec_m S_1$. Menggabungkannya dengan urutan program $S_1 \prec_p L_1$ dan $S_2 \prec_p L_2$ menghasilkan siklus waktu yang kontradiktif $S_1 \prec L_1 \prec S_2 \prec L_2 \prec S_1$. Akan tetapi, karena x dan y adalah alamat berbeda tanpa ketergantungan data dalam satu thread, perangkat keras dan kompiler melanggar urutan global ini demi menyembunyikan latensi memori.
Bab 2: Model Memori Perangkat Keras ── Store Buffer, Protokol MESI, dan Invalidate Queue
2.1 Koherensi Cache MESI dan Reordering Store-Load melalui Store Buffer
Pada CPU 4 GHz modern, akses register hanya membutuhkan $0,25\text{ ns}$ (1 siklus) dan cache L1 sekitar $1\text{ ns}$, sedangkan sinkronisasi cache antar-inti atau akses DRAM membutuhkan $50\text{–}100\text{ ns}$ (hingga 300 siklus). Inti CPU menjaga koherensi baris cache melalui protokol MESI (Modified, Exclusive, Shared, Invalid). Saat Core 0 ingin mengubah variabel x yang berstatus Shared, ia harus menyiarkan pesan Invalidate (Read-For-Ownership, RFO) ke inti lainnya dan menunggu balasan konfirmasi (Ack).
Agar tidak menghentikan eksekusi selama puluhan siklus setiap kali menulis, semua CPU modern (termasuk x86-64!) menempatkan antrean FIFO privat bernama Store Buffer (buffer tulis) di antara unit eksekusi dan cache L1:
flowchart TD
subgraph Core0 ["Inti CPU 0 (Thread 1: x = 1; r1 = y;)"]
Exec0["Unit Eksekusi (Core 0)"]
SB0["Store Buffer Privat Core 0<br/>[Tertunda: x = 1] (Tidak terlihat oleh Core 1!)"]
L1_0["Cache Data L1 (Core 0)<br/>y = 0 (Shared)"]
Exec0 -- "1. Taruh x = 1 di Store Buffer & langsung lanjut" --> SB0
Exec0 -- "2. Baca langsung r1 = y dari L1 (r1 = 0!)" --> L1_0
SB0 -. "3. Kuras x = 1 ke L1 hanya setelah MESI Ack tiba" .-> L1_0
end
subgraph Core1 ["Inti CPU 1 (Thread 2: y = 1; r2 = x;)"]
Exec1["Unit Eksekusi (Core 1)"]
SB1["Store Buffer Privat Core 1<br/>[Tertunda: y = 1] (Tidak terlihat oleh Core 0!)"]
L1_1["Cache Data L1 (Core 1)<br/>x = 0 (Shared)"]
Exec1 -- "1. Taruh y = 1 di Store Buffer & langsung lanjut" --> SB1
Exec1 -- "2. Baca langsung r2 = x dari L1 (r2 = 0!)" --> L1_1
SB1 -. "3. Kuras y = 1 ke L1 hanya setelah MESI Ack tiba" .-> L1_1
end
L1_0 <== "Interkoneksi Koherensi Cache MESI (Invalidate / RFO)" ==> L1_1
Core 0 menyimpan x = 1 ke Store Buffer privatnya (belum terlihat oleh Core 1) dan langsung membaca r1 = y (0) dari cache L1 miliknya. Secara bersamaan, Core 1 menyimpan y = 1 ke Store Buffer-nya dan membaca r2 = x (0) dari cache L1 miliknya. Dengan demikian, pembacaan (Load) mendahului penulisan sebelumnya (Store) dalam visibilitas global: inilah Store-Load Reordering! (Jika Core 0 membaca kembali variabel x yang baru saja ditulisnya sendiri, mekanisme Store Buffer Forwarding langsung meneruskan x = 1 dari buffer lokal sehingga perilaku thread tunggal tetap terjaga.)
2.2 Model Kuat x86-TSO vs. Arsitektur Urutan Lemah (ARMv8, RISC-V, PowerPC)
Model x86-TSO (Total Store Order) dari Intel dan AMD menggunakan Store Buffer FIFO yang ketat: tidak pernah mengurutkan ulang Load-Load, Load-Store, maupun Store-Store, dan hanya mengizinkan pengurutan ulang Store -> Load. Sebaliknya, ARMv8-A (AArch64 / Apple Silicon), RISC-V (RVWMO), dan IBM PowerPC mengadopsi Model Memori Urutan Lemah (Weakly Ordered): akibat penggabungan tulis non-FIFO, pembacaan spekulatif out-of-order, dan Invalidate Queue, keempat kombinasi (Load-Load, Load-Store, Store-Store, Store-Load) dapat diurutkan ulang oleh perangkat keras!
| Model Memori Perangkat Keras | Load -> Load | Load -> Store | Store -> Store | Store -> Load | Atomisitas Multi-Salinan (MCA) |
|---|---|---|---|---|---|
| Konsistensi Sekuensial (SC) | Tidak | Tidak | Tidak | Tidak | MCA Penuh |
| x86 / x64 (TSO) | Tidak | Tidak | Tidak | Ya (Store Buffer) | Other-MCA |
| ARMv8-A (AArch64) | Ya | Ya | Ya | Ya | Other-MCA |
| RISC-V (RVWMO) | Ya | Ya | Ya | Ya | Other-MCA |
| IBM PowerPC | Ya | Ya | Ya | Ya | Non-MCA |
Bab 3: Pengurutan Ulang Kompiler dan Teorema DRF-SC ── Mengapa Data Race Adalah Undefined Behavior (UB)
Bahkan pada CPU x86, pengiriman pesan tanpa sinkronisasi data = 42; ready = 1; tetap gagal karena kompiler pengoptimasi (gcc, clang, rustc) bebas mengurutkan ulang keempat jenis akses memori. Lebih buruk lagi: melalui Register Promotion dan Loop-Invariant Code Motion (LICM), kompiler mengubah loop tunggu while (!ready) menjadi pembacaan satu kali ke register diikuti loop tak terbatas (infinite loop)! Selain itu, kata kunci volatile di C/C++ hanya ditujukan untuk register MMIO dan tidak menghasilkan memory barrier CPU.
Pada tahun 1990, Sarita Adve dan Mark Hill membuktikan teorema DRF-SC (Data-Race-Free Sequential Consistency):
- Data Race: Dua thread mengakses lokasi memori yang sama secara bersamaan, setidaknya satu adalah penulisan, setidaknya satu non-atomik, dan tidak ada relasi Happens-Before ($\xrightarrow{hb}$) di antara keduanya.
- Kontrak DRF-SC: Jika program bebas dari Data Race (DRF), kompiler dan CPU menjamin setiap eksekusi berperilaku persis seperti Konsistensi Sekuensial (SC). Sebaliknya, satu saja Data Race di C/C++ atau Unsafe Rust menghasilkan Undefined Behavior (UB).
Bab 4: Relasi Happens-Before ($\xrightarrow{hb}$) dan Sinkronisasi Acquire-Release
Dalam model memori formal C++11 / C11 / Rust, ketika Thread 1 melakukan penulisan atomik Release $W_{\text{rel}}(x, v)$ dan Thread 2 melakukan pembacaan atomik Acquire $R_{\text{acq}}(x, v)$ yang membaca nilai tersebut ($W_{\text{rel}} \xrightarrow{rf} R_{\text{acq}}$), terbentuk jembatan lintas-thread bernama Synchronizes-With ($\xrightarrow{sw}$). Penutupan transitif dari urutan internal thread (Sequenced-Before, $\xrightarrow{sb}$) dan $\xrightarrow{sw}$ mendefinisikan relasi Happens-Before ($\xrightarrow{hb}$):
$$W_{\text{rel}}(x, v) \xrightarrow{rf} R_{\text{acq}}(x, v) \implies W_{\text{rel}}(x, v) \xrightarrow{sw} R_{\text{acq}}(x, v)$$$$\xrightarrow{hb} \;\; \triangleq \;\; \left( \xrightarrow{sb} \;\cup\; \xrightarrow{sw} \right)^+$$Dengan memory_order_release dan memory_order_acquire, pengiriman pesan dijamin aman secara matematis:
| |
flowchart TD
subgraph Thread1 ["Thread Produsen (Core 0)"]
W_data["(1) data = 42 (Penulisan non-atomik)"]
W_flag["(2) flag.store(1, release) (Penulisan atomik Release)"]
W_data -- "Sequenced-Before (sb)<br/>[Barrier Release melarang (1) turun melewati (2)!]" --> W_flag
end
subgraph Thread2 ["Thread Konsumen (Core 1)"]
R_flag["(3) flag.load(acquire) == 1 (Pembacaan atomik Acquire)"]
R_data["(4) assert(data == 42) (Pembacaan non-atomik)"]
R_flag -- "Sequenced-Before (sb)<br/>[Barrier Acquire melarang (4) naik melewati (3)!]" --> R_data
end
W_flag == "Reads-From (rf) ⟹ Synchronizes-With (sw) terbentuk!" ==> R_flag
W_data -. "Melalui transitivitas: Happens-Before (hb) terjamin!" .-> R_data
Bukti formal:
$$W(\text{data}=42) \xrightarrow{sb} W_{\text{rel}}(\text{flag}=1) \xrightarrow{sw} R_{\text{acq}}(\text{flag}=1) \xrightarrow{sb} R(\text{data})$$$$\implies W(\text{data}=42) \xrightarrow{hb} R(\text{data}) \implies R(\text{data}) = 42$$Bab 5: Spektrum Memory Ordering dan Pemetaan Instruksi CPU (MFENCE, DMB, LDAR/STLR)
C++ dan Rust menyediakan tiga tingkat pengurutan utama:
Relaxed: Hanya menjamin atomisitas dan urutan modifikasi ($\xrightarrow{mo}$) pada satu variabel tanpa sinkronisasi $\xrightarrow{sw}$ (cocok untuk penghitung sederhana).Acquire/Release/AcqRel: Barrier satu arah (semantik Roach Motel) yang membentuk $\xrightarrow{sw}$, tetapi TIDAK mencegah pengurutan ulangStore -> Loadketikastore(release)mendahuluiload(acquire).SeqCst(bawaan): Menambahkan satu urutan total global $\xrightarrow{S}$ pada semua operasiSeqCstdan melarang pengurutan ulangStore -> Load(solusi untuk mencegahr1 == 0 && r2 == 0pada ujiSB).
| Operasi Atomik (C++ / Rust) | Instruksi x86-64 (TSO) | Instruksi ARMv8-A (AArch64) | Instruksi RISC-V (RVWMO) |
|---|---|---|---|
load(relaxed) | mov eax, [rdi] | ldr w0, [x0] | lw a0, 0(a0) |
load(acquire) | mov eax, [rdi] (Biaya fence hardware nol!) | ldar w0, [x0] (Load-Acquire) | lw a0, 0(a0) + fence r, rw |
store(release, v) | mov [rdi], esi (Biaya fence hardware nol!) | stlr w1, [x0] (Store-Release) | fence rw, w + sw a1, 0(a0) |
store(seq_cst, v) | xchg [rdi], esi (atau mov + mfence) | stlr w1, [x0] (terurut dengan ldar) | fence rw, rw + sw + fence rw, rw |
Barrier fence(seq_cst) | mfence (atau lock or [rsp], 0) | dmb ish (Data Memory Barrier) | fence rw, rw |
Pada x86-64, Acquire dan Release dikompilasi menjadi instruksi mov biasa tanpa biaya perangkat keras, sedangkan store(seq_cst) menggunakan xchg (prefiks LOCK implisit) atau mfence untuk menguras Store Buffer. Pada ARMv8-A, instruksi khusus LDAR dan STLR menerapkan Acquire-Release dan SeqCst secara efisien tanpa memerlukan barrier penuh DMB ISH.
