Topik ini membahas data race (perlombaan data) beserta cara menanganinya menggunakan std::mutex. Pembahasan mencakup demo race condition pada sebuah penghitung, pelindungan data dengan std::mutex dan std::lock_guard, serta std::unique_lock untuk kendali kunci yang lebih rinci. Contoh yang dipakai adalah penghitung kunjungan perpustakaan yang dinaikkan oleh empat thread sekaligus, sehingga perbedaan program terlindungi dan tidak terlindungi terlihat langsung pada keluarannya.
Data race dan akibatnya#
Data race terjadi bila dua thread mengakses lokasi memori yang sama pada saat yang sama dan salah satunya menulis. Standar C++ menyatakan kondisi ini sebagai perilaku tidak terdefinisi (undefined behavior), sehingga hasilnya dapat berupa nilai yang salah, error, atau program yang tampak normal tetapi rusak diam-diam. Penyebabnya sering terlihat sepele, yaitu operasi ++ yang tampak seperti satu langkah, padahal terdiri atas membaca, menaikkan, dan menulis ulang. Ketiga langkah tersebut dapat terpotong oleh thread lain di tengah rangkaiannya, misalnya setelah pembacaan dan sebelum penulisan ulang.
// tidak-dijalankan (keluaran race condition tidak dapat dipastikan sama antar eksekusi)
#include <iostream>
#include <thread>
int penghitung = 0;
void naikkan() {
for (int i = 0; i < 25000; ++i) {
++penghitung;
}
}
int main() {
std::thread t1(naikkan);
std::thread t2(naikkan);
std::thread t3(naikkan);
std::thread t4(naikkan);
t1.join();
t2.join();
t3.join();
t4.join();
std::cout << "total = " << penghitung << "\n";
return 0;
}Program di atas meluncurkan empat thread yang masing-masing menaikkan penghitung sebanyak 25000 kali, sehingga total yang diharapkan adalah 100000. Perilaku yang teramati inilah yang disebut race condition, yaitu keadaan ketika hasil akhir bergantung pada urutan eksekusi antar thread. Bloknya tidak diberi keluaran karena hasilnya memang tidak dapat dijadikan acuan. Pada tiga eksekusi berurutan yang dilakukan saat penyusunan, hasil yang tercetak adalah 52582, lalu 82343, lalu 73114, dan nilainya kembali berbeda pada eksekusi berikutnya. Selisih yang besar terjadi karena langkah baca-naik-tulis dari beberapa thread saling menimpa, dan bagian yang hilang itu tidak pernah dihitung kembali.
Melindungi data dengan std::mutex#
std::mutex adalah objek kunci yang hanya boleh dipegang satu thread pada satu waktu. Thread yang memanggil lock() berhenti sementara bila kunci sedang dipegang pihak lain, lalu melanjutkan begitu kuncinya berhasil diperoleh. Pembacaan dan penulisan data bersama diletakkan di antara lock() dan unlock(), dan wilayah itu disebut critical section (bagian kritis). Dengan pengaturan ini, langkah baca-naik-tulis pada satu penghitung tidak dapat diinterupsi oleh thread lain.
#include <iostream>
#include <mutex>
#include <thread>
int kunjungan = 0;
std::mutex mutex_kunjungan;
void proses_batch() {
for (int i = 0; i < 25000; ++i) {
std::lock_guard<std::mutex> kunci(mutex_kunjungan);
++kunjungan;
}
}
int main() {
std::thread t1(proses_batch);
std::thread t2(proses_batch);
std::thread t3(proses_batch);
std::thread t4(proses_batch);
t1.join();
t2.join();
t3.join();
t4.join();
std::cout << "total kunjungan = " << kunjungan << "\n";
return 0;
}total kunjungan = 100000Kunci pada program di atas diurus std::lock_guard, yaitu penjaga yang bekerja berdasarkan cakupan. Kunci diperoleh pada baris deklarasi kunci dan dilepaskan otomatis ketika kunci keluar dari cakupannya, yaitu di akhir setiap iterasi. Cakupan ini sengaja dibuat sekecil mungkin, sehingga thread hanya saling menunggu pada bagian yang benar-benar membutuhkan kunci. Bila lock() dan unlock() ditulis manual, jalur eksekusi yang melewati lock() tanpa sempat memanggil unlock(), misalnya karena exception, membuat kunci tertahan selamanya. Thread lain pun mengantre tanpa batas waktu, yaitu kondisi yang disebut deadlock. Objek std::mutex itu sendiri juga merupakan data bersama, sehingga ia dideklarasikan satu kali pada lingkup yang dapat dilihat seluruh thread yang membutuhkannya.
Kompilasi dan eksekusinya memakai perintah yang sama dengan program sebelumnya, dan hasil akhirnya pasti 100000 pada setiap eksekusi.
# kompilasi program dengan dukungan thread
g++ -std=c++17 -pthread penghitung-kunjungan.cpp -o /tmp/penghitung-kunjungan
# jalankan program hasil kompilasi
/tmp/penghitung-kunjungantotal kunjungan = 100000std::unique_lock untuk kendali lebih#
std::unique_lock adalah pembungkus kunci yang lebih lentur daripada std::lock_guard. Pembungkus ini dapat dibuat tanpa langsung mengunci melalui parameter std::defer_lock, dapat dikunci dan dilepaskan beberapa kali selama umurnya, dan tetap terlepas otomatis saat objeknya dimusnahkan. Kelenturan ini berguna ketika sebagian pekerjaan tidak membutuhkan kunci, sehingga kunci cukup dipegang pada bagian yang menyentuh data bersama.
#include <algorithm>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
std::mutex mutex_catatan;
std::vector<std::string> catatan;
void tulis_catatan(int id) {
std::unique_lock<std::mutex> kunci(mutex_catatan, std::defer_lock);
// pekerjaan ini tidak menyentuh data bersama, sehingga dilakukan tanpa kunci
std::string pesan = "petugas " + std::to_string(id) + " mencatat kunjungan";
kunci.lock();
catatan.push_back(pesan);
kunci.unlock();
}
int main() {
std::thread t1(tulis_catatan, 1);
std::thread t2(tulis_catatan, 2);
std::thread t3(tulis_catatan, 3);
std::thread t4(tulis_catatan, 4);
t1.join();
t2.join();
t3.join();
t4.join();
// urutan masukan bergantung penjadwalan, sehingga isi diurutkan sebelum dicetak
std::sort(catatan.begin(), catatan.end());
for (const std::string& baris : catatan) {
std::cout << baris << "\n";
}
return 0;
}petugas 1 mencatat kunjungan
petugas 2 mencatat kunjungan
petugas 3 mencatat kunjungan
petugas 4 mencatat kunjunganEmpat petugas menyusun pesannya terlebih dahulu tanpa memegang kunci, lalu mengunci mutex_catatan hanya selama satu pemanggilan push_back. Pemanggilan kunci.unlock() melepaskan kunci lebih awal, meskipun tanpa itu kunci juga akan terlepas di akhir fungsi. Pencetakan dilakukan setelah seluruh join, dan isi vektor diurutkan terlebih dahulu karena urutan masukannya bergantung pada penjadwalan thread. Keluaran yang dihasilkan tetap sama pada setiap eksekusi, sebagaimana program terkunci lainnya.
| Aspek | std::lock_guard | std::unique_lock |
|---|---|---|
| Penguncian | langsung saat dibuat | saat dibuat, dengan defer_lock, atau manual |
| Pelepasan | di akhir cakupan | manual dengan unlock() atau di akhir cakupan |
| Pemindahan antar fungsi | tidak dapat dipindahkan | dapat dipindahkan dengan std::move |
| Biaya | paling ringan | sedikit lebih besar karena menyimpan status |
Kesalahan umum#
- Melindungi penulisan tetapi tidak melindungi pembacaan. Setiap akses ke data bersama harus berada di dalam bagian kritis yang sama, termasuk akses yang hanya membaca, karena pembacaan yang bersamaan dengan penulisan tetap merupakan data race.
- Mengunci
std::mutexyang sama dua kali pada satu thread. Thread tersebut menunggu kunci yang dipegangnya sendiri dan mengalami deadlock, sehingga perlindungan berlapis perlu dirancang dengan struktur kunci yang berbeda. - Memegang kunci terlalu lama. Pekerjaan yang tidak menyentuh data bersama sebaiknya dipindah ke luar bagian kritis agar thread lain tidak mengantre tanpa perlu.
- Menyimpulkan program bebas data race karena berhasil berulang kali. Data race dapat tidak tampak pada satu mesin, lalu muncul pada mesin lain dengan jumlah inti prosesor yang berbeda.
Ringkasan#
- Data race terjadi bila dua thread mengakses data yang sama tanpa pengaturan dan salah satunya menulis, dan akibatnya adalah perilaku tidak terdefinisi.
std::mutexmengatur pergantian akses, sedangkanstd::lock_guardmengurus perolehan dan pelepasan kunci berdasarkan cakupan sehinggaunlocktidak pernah terlupakan.std::unique_lockmenambah kendali berupa penguncian tertunda dengandefer_lock, pelepasan manual, dan pemindahan, dengan biaya sedikit lebih besar.- Penghitung yang dilindungi mutex menghasilkan total pasti sebesar 100000, berbanding demo tanpa mutex yang keluarannya berubah pada setiap eksekusi.
Referensi#
- std::mutex — cppreference — Dokumentasi resmi kelas mutex beserta operasi lock dan unlock-nya.
- std::lock_guard — cppreference — Penjelasan penjaga kunci berbasis cakupan dan perilaku destruktornya.
- std::unique_lock — cppreference
— Daftar kemampuan pembungkus kunci lentur, termasuk
defer_lockdan pemindahan. - std::mutex::lock — cppreference — Syarat pemanggilan lock, termasuk perilaku bila kunci dipegang dua kali oleh thread yang sama.