Mutex dan data race

mutex, lock_guard, unique_lock, dan demo race condition.

Daftar isi

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.

cpp race-counter.cpp
// 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.

cpp penghitung-kunjungan.cpp
#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;
}
text Keluaran
total kunjungan = 100000

Kunci 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.

bash
# kompilasi program dengan dukungan thread
g++ -std=c++17 -pthread penghitung-kunjungan.cpp -o /tmp/penghitung-kunjungan
# jalankan program hasil kompilasi
/tmp/penghitung-kunjungan
text Keluaran
total kunjungan = 100000

std::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.

cpp catatan-unique-lock.cpp
#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;
}
text Keluaran
petugas 1 mencatat kunjungan
petugas 2 mencatat kunjungan
petugas 3 mencatat kunjungan
petugas 4 mencatat kunjungan

Empat 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.

Aspekstd::lock_guardstd::unique_lock
Penguncianlangsung saat dibuatsaat dibuat, dengan defer_lock, atau manual
Pelepasandi akhir cakupanmanual dengan unlock() atau di akhir cakupan
Pemindahan antar fungsitidak dapat dipindahkandapat dipindahkan dengan std::move
Biayapaling ringansedikit 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::mutex yang 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::mutex mengatur pergantian akses, sedangkan std::lock_guard mengurus perolehan dan pelepasan kunci berdasarkan cakupan sehingga unlock tidak pernah terlupakan.
  • std::unique_lock menambah kendali berupa penguncian tertunda dengan defer_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#