Memasang mutex untuk sekadar menaikkan penghitung dari beberapa thread terasa seperti memakai gembok bank untuk kotak pensil: benar, tapi berat. Untuk operasi tunggal pada satu variabel, pustaka standar menyediakan std::atomic<T> — tipe yang menjamin setiap operasinya dilakukan seutuhnya, tanpa terseling, tanpa mutex. Thread yang bersaing tidak bisa memenggalan operasi di tengah jalan; salah satunya menunggu pada level perangkat keras, dengan biaya jauh lebih kecil daripada kunci penuh. Chapter ini membahas operasi-operasi atomik yang paling sering dipakai, memperkenalkan compare-and-exchange sebagai fondasi struktur lock-free, lalu menarik garis batas kapan atomic cukup dan kapan mutex tetap diperlukan.
fetch_add: Penghitung Aman Tanpa Mutex #
Program berikut menguji empat operasi atomik sekaligus. Bagian pertamanya adalah soal klasik: empat thread masing-masing menaikkan penghitung 100.000 kali — skenario yang tanpa sinkronisasi selalu menghasilkan angka yang salah.
#include <atomic>
#include <iostream>
#include <thread>
int main() {
// 1) fetch_add: penambahan atomik oleh empat thread
std::atomic<int> penghitung{0};
auto tambah = [&penghitung] {
for (int i = 0; i < 100000; ++i) {
penghitung.fetch_add(1);
}
};
std::thread t1(tambah), t2(tambah), t3(tambah), t4(tambah);
t1.join(); t2.join(); t3.join(); t4.join();
std::cout << "fetch_add : " << penghitung.load() << "\n";
// 2) exchange: menukar nilai, mengembalikan nilai lama
std::atomic<int> nilai{10};
int lama = nilai.exchange(50);
std::cout << "exchange : " << lama << " -> " << nilai.load() << "\n";
// 3) compare_exchange: hanya ubah bila nilai masih seperti dugaan
int dugaan = 50;
bool sukses = nilai.compare_exchange_strong(dugaan, 99);
std::cout << "CAS sukses: " << std::boolalpha << sukses
<< ", nilai kini " << nilai.load() << "\n";
int dugaanSalah = 50;
bool gagal = nilai.compare_exchange_strong(dugaanSalah, 1);
std::cout << "CAS gagal : " << gagal
<< ", dugaan diperbarui menjadi " << dugaanSalah << "\n";
// 4) atomic_flag: primitif paling ringan (set-test-clear)
std::atomic_flag bendera; // C++20: kondisi awal pasti "clear"
bool pertama = bendera.test_and_set();
std::cout << "flag pertama : " << pertama << "\n";
bendera.clear();
std::cout << "flag setelah clear: " << bendera.test() << "\n";
return 0;
}Output yang Dihasilkan:
fetch_add : 400000
exchange : 10 -> 50
CAS sukses: true, nilai kini 99
CAS gagal : false, dugaan diperbarui menjadi 99
flag pertama : false
flag setelah clear: falseBaris fetch_add : 400000 adalah bukti utamanya: empat thread kali 100.000 penambahan, dan hasilnya tepat — tiap fetch_add(1) terjadi seutuhnya, tidak ada yang hilang. Bedah operasi lainnya:
- Baris 10 (
penghitung.fetch_add(1)): menambah nilai secara atomik dan mengembalikan nilai SEBELUM penambahan. Versioperator++juga tersedia;fetch_addlebih eksplisit soal nilai kembaliannya. - Baris 15 (
penghitung.load()): membaca nilai atomik. Membaca lewatload()dan menulis lewatstore()adalah operasi dasar; keduanya atomik, jadi tidak ada pembacaan setengah jadi. - Baris 19 (
nilai.exchange(50)): menulis 50 dan mengembalikan nilai lama (10) dalam satu langkah atomik. - Baris 24 (
compare_exchange_strong(dugaan, 99)): compare-and-exchange — “bila nilai masih 50, tulis 99 dan laporkan sukses; bila sudah berubah, tulis nilai terkini kedugaandan laporkan gagal”. Karena nilai memang 50, operasi sukses dan nilai menjadi 99. - Baris 29 (CAS kedua): dugaan 50 sudah tidak cocok dengan nilai 99, jadi gagal — dan perhatikan
dugaanSalahikut diperbarui menjadi 99. Pola “dugaan diperbarui saat gagal” inilah yang dipakai untuk mengulang CAS dalam loop lock-free. - Baris 34-38 (
std::atomic_flag): tipe atomik paling ringan, hanya tahu dua keadaan (set/clear).test_and_set()pertama mengembalikanfalse(belum pernah di-set); setelahclear(),test()melaporkanfalselagi.atomic_flagadalah bahan bangunan spinlock paling primitif di standar.
compare_exchange: Fondasi Lock-Free #
Mengapa CAS begitu penting? Karena hampir semua struktur data lock-free — antrean tanpa kunci, stack tanpa kunci, pointer atomik — dibangun di atas satu pola: baca nilai, hitung nilai baru berdasarkan dugaan itu, lalu coba CAS. Bila berhasil, kita menang bersih tanpa pernah mengunci apa pun; bila gagal (ada thread lain lebih dulu), dugaan diperbarui dan coba lagi. Setiap operasi atomic yang bukan CAS sesungguhnya diimplementasikan di atas primitif semacam ini pada level CPU.
wait dan notify: Menunggu Tanpa Mutex (C++20) #
Sejak C++20, std::atomic membawa dua alat yang dulu hanya milik condition variable: wait() dan notify_*(). wait(nilaiLama) memblokir thread sampai nilai atomic berubah dari nilaiLama dan sebuah notify_one() atau notify_all() datang; kernel memindahkan thread yang menunggu ke status tidur, sehingga tidak ada loop sibuk yang membakar CPU. Perbedaannya dari condition variable terletak pada sifat atomiknya: tidak ada mutex yang perlu dikunci, dan pemeriksaan “apakah nilainya masih lama” sudah menyatu dengan operasi penerimaannya.
Program berikut menunjukkan pola penggantinya: thread pekerja menyimpan nilai baru dengan store ber-release, lalu membangunkan penunggu dengan notify_one(); thread utama menunggu lewat wait(0) dan membaca nilai akhirnya:
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
int main() {
std::atomic<int> kode{0};
std::thread pekerja([&kode] {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
kode.store(42, std::memory_order_release);
kode.notify_one();
});
// Tunggu tanpa loop sibuk: thread terblokir sampai notify datang.
kode.wait(0, std::memory_order_acquire);
std::cout << "wait() terlepas, kode = " << kode.load() << "\n";
pekerja.join();
return 0;
}Output yang Dihasilkan:
wait() terlepas, kode = 42Perlu diingat dua catatan perilaku. wait() boleh terlepas sebelum ada notifikasi (spurious wakeup), sehingga pola lengkapnya tetap membungkus wait dalam perulangan yang memeriksa syarat; contoh di atas aman karena satu nilai dan satu notifikasi. Dan notify_one() tetap perlu dipanggil walau nilai sudah berubah terlebih dulu — notifikasi bukan efek samping store, keduanya operasi terpisah yang sengaja dibedakan agar penunggu bisa dikontrol.
Kapan Atomic Cukup, Kapan Mutex Diperlukan #
Atomic bukan pengganti mutex di semua tempat. Batasnya jelas:
| Situasi | Alat yang cocok |
|---|---|
| Satu variabel, operasi tunggal (counter, flag, ID) | std::atomic<T> |
| Menjaga lebih dari satu variabel konsisten bersamaan | std::mutex (atau atomic per variabel + desain ulang) |
| Critical section yang membaca lalu mengubah banyak data | std::mutex |
| Menunggu sinyal/event | condition_variable (dengan mutex) |
| Struktur data multi-langkah (insert pohon, rehash tabel) | std::mutex — atomic per langkah tidak cukup |
Ingat contoh transfer rekening dari chapter deadlock: mengunci saldoA lalu saldoB secara terpisah dengan atomic tidak membuat transfer aman — yang dibutuhkan adalah kedua perubahan terlihat sebagai satu kesatuan, dan itu wilayah mutex. Aturan praktisnya: mulai dari atomic bila kebutuhannya memang satu variabel dan satu operasi; berpindah ke mutex begitu logikanya menyentuh dua variabel atau dua langkah.
Atomic memberi kita atomisitas, tetapi masih ada satu pertanyaan halus yang belum kita sentuh: kapan perubahan yang dilakukan satu thread PASTI terlihat oleh thread lain? Jawabannya ada di model memori C++ — memory_order, happens-before, dan pasangan release-acquire yang menanti di chapter berikutnya.
Referensi: cppreference — std::atomic , cppreference — std::atomic_flag .