Bagian 2 sengaja meninggalkan satu titik belum dibedah: diAtasAmbang, satu-satunya data dalam jalankanParalel yang ditulis oleh lebih dari satu thread. Sisanya — data input yang hanya dibaca dan slot parsial yang masing-masing milik satu thread — aman tanpa pelindung. Titik tunggal itulah pintu masuk pelajaran terpenting proyek ini: begitu dua thread memodifikasi satu memori yang sama tanpa pengaturan, programnya tidak hanya menghasilkan angka yang salah; menurut standar C++, seluruh programnya menjadi undefined behavior — compiler bebas mengasumsikan kondisi itu tidak pernah terjadi. Bagian ini menunjukkan tabrakan itu secara nyata, lalu memperbaikinya dengan pasangan klasik std::mutex dan std::lock_guard.

Satu Variable, Banyak Penulis #

Potongan berikut dari src/multithread.cpp memperlihatkan pembagian peran antara data yang aman dan data yang harus dijaga:

cpp src/multithread.cpp — variable bersama dan penjaganya
    // Statistik gabungan berupa satu variable bersama → wajib dilindungi
    // mutex agar penambahannya tidak saling menimpa.
    std::int64_t diAtasAmbang = 0;
    std::mutex kunciStatistik;

    std::vector<std::jthread> pekerja;
    pekerja.reserve(static_cast<std::size_t>(jumlahThread));
    for (int id = 0; id < jumlahThread; ++id) {
        pekerja.emplace_back([id, &data, &daftar, &parsial, &diAtasAmbang,
                              &kunciStatistik] {
            const std::size_t i = static_cast<std::size_t>(id);
            parsial[i] = jumlahRentang(data, daftar[i]);
            const std::int64_t kontribusi = hitungDiAtasAmbang(data, daftar[i]);
            const std::lock_guard<std::mutex> penjaga(kunciStatistik);
            diAtasAmbang += kontribusi;
        });
    }

Kenapa diAtasAmbang += kontribusi; berbahaya sedangkan parsial[i] = ...; tidak? += bukan satu operasi mesin, melainkan tiga: baca isi variable, tambahkan, tulis kembali. Bila thread A membaca 100 lalu thread B membaca 100 sebelum salah satunya selesai menulis, kedua thread menulis 101 dan satu kontribusi hilang begitu saja — kejadian yang disebut lost update. Semakin sering program dijalankan, semakin tidak bisa ditebak berapa banyak yang hilang, karena penjadwalan thread berbeda di setiap eksekusi.

Baris demi baris mekanisme pelindungnya:

  1. std::int64_t diAtasAmbang = 0;: data bersama yang benar-benar ditulis semua thread. Segala sesuatu yang berstatus “ditulis oleh banyak pihak” wajib kita tunjuk secara eksplisit — jangan sampai tersembunyi di dalam function.
  2. std::mutex kunciStatistik;: mutex (mutual exclusion) bukan data dan tidak berisi apa pun; ia hanya alat pemberian giliran. Hanya satu thread yang boleh mengunci kunciStatistik pada satu waktu; thread lain yang mencoba akan menunggu.
  3. kontribusi dihitung di luar kunci: pekerjaan berat — menyapu ribuan elemen — dikerjakan tanpa memegang kunci. Kunci hanya melindungi penambahan yang selesai dalam hitungan nanodetik. Memegang kunci selama kerja berat membatalkan paralelisme itu sendiri, karena thread lain antre hanya untuk menunggu.
  4. std::lock_guard<std::mutex> penjaga(kunciStatistik);: kunci diambil saat penjaga dibuat dan dilepas otomatis saat penjaga keluar scope — pola RAII yang kita pakai sejak proyek-proyek awal. Bila blok ini keluar lewat exception sekalipun, kunci tetap terlepas; tidak ada jalur yang meninggalkan mutex terkunci selamanya.
  5. diAtasAmbang += kontribusi;: tiga langkah read-modify-write kini berlangsung utuh dalam satu giliran. Nilai yang dibaca tidak mungkin basi, karena tidak ada thread lain yang boleh masuk sebelum penjaga bubar.

Melihat Tabrakan Tanpa Mutex #

Untuk menghargai pelindung itu, kita buka dulu kejadiannya tanpa pelindung. Program berikut sengaja memuat data race — jangan meniru polanya:

cpp demo-race.cpp — versi tanpa mutex (sengaja salah, undefined behavior)
// demo-race.cpp — versi TANPA mutex untuk melihat lost update.
// PERINGATAN: program ini sengaja mengandung data race (undefined behavior).
// Hasilnya tidak bisa dijadikan output terverifikasi — nilainya berubah-ubah.

#include <cstdint>
#include <print>
#include <thread>
#include <vector>

int main() {
    constexpr int JUMLAH_THREAD = 4;
    constexpr int PUTARAN = 100'000;

    std::int64_t penghitung = 0;   // tanpa pelindung!

    std::vector<std::jthread> pekerja;
    for (int id = 0; id < JUMLAH_THREAD; ++id) {
        pekerja.emplace_back([&] {
            for (int i = 0; i < PUTARAN; ++i) {
                penghitung += 1;
            }
        });
    }
    for (std::jthread& t : pekerja) {
        t.join();
    }

    std::println("diharapkan : {}",
                 static_cast<std::int64_t>(JUMLAH_THREAD) * PUTARAN);
    std::println("hasil      : {}", penghitung);
    return 0;
}

Karena program ini mengandung data race, hasilnya tidak bisa dijadikan output terverifikasi. Salah satu eksekusi nyata di mesin uji kita memberi:

text
diharapkan : 400000
hasil      : 261608

Empat thread seharusnya menambah 400.000, tetapi hasilnya 261.608 — hampir 140.000 penambahan hilang — dan angka itu berbeda di setiap eksekusi. Tidak ada crash, tidak ada pesan galat; programnya lulus dengan senyap sambil membawa jawaban yang salah. Kegagalan jenis ini jauh lebih mahal daripada program yang berhenti dengan error, karena hanya pemeriksaan yang menangkapnya.

Sekarang versi amannya, demo-mutex.cpp, dengan pola yang sama seperti pada jalankanParalel:

cpp demo-mutex.cpp — statistik bersama yang dijaga mutex
// demo-mutex.cpp — statistik bersama yang dijaga mutex.
// Empat jthread menambah satu variable yang sama sebanyak 100.000 kali.
// Bagian penambahannya dilindungi std::mutex, sehingga hasil akhir selalu
// tepat — bukan angka salah akibat update yang saling menimpa.

#include <cstdint>
#include <mutex>
#include <print>
#include <thread>
#include <vector>

int main() {
    constexpr int JUMLAH_THREAD = 4;
    constexpr int PUTARAN = 100'000;

    std::int64_t penghitung = 0;
    std::mutex kunci;

    std::vector<std::jthread> pekerja;
    for (int id = 0; id < JUMLAH_THREAD; ++id) {
        pekerja.emplace_back([&] {
            for (int i = 0; i < PUTARAN; ++i) {
                const std::lock_guard<std::mutex> penjaga(kunci);
                penghitung += 1;
            }
        });
    }
    for (std::jthread& t : pekerja) {
        t.join();
    }

    std::println("diharapkan : {}",
                 static_cast<std::int64_t>(JUMLAH_THREAD) * PUTARAN);
    std::println("hasil      : {}", penghitung);
    return 0;
}
bash
g++ -std=c++23 -Wall -Wextra -pthread demo-mutex.cpp -o demo-mutex
./demo-mutex

Output yang Dihasilkan:

text
diharapkan : 400000
hasil      : 400000

Kali ini hasilnya selalu tepat 400000, di eksekusi kapan pun dan di mesin mana pun. Biayanya juga terlihat jelas: setiap penambahan harus antre di kunci, sehingga bagian ini berjalan praktis berurutan. Karena itu aturan desainnya bukan “kunci semuanya”, melainkan kebalikannya — susun agar data tidak dibagi bila bisa. Program kita mengikuti aturan itu: jutaan operasi penjumlahan berjalan bebas di slot pribadi masing-masing, dan hanya satu angka statistik yang membutuhkan antrean.

Dengan data bersama yang kini terlindungi, seluruh mekanisme program paralel kita sudah berdiri: pembagian rentang, eksekusi jthread, dan sinkronisasi yang tepat sasaran. Yang tersisa adalah pertanyaan yang selalu menyertai program paralel — apakah semua ini benar dan apakah semua ini lebih cepat — dan itulah yang kita jawab dengan harness pengujian di Bagian 4.

Rujukan resmi: std::mutex (cppreference) , std::lock_guard (cppreference) .