Keempat rentang kerja sudah siap dari Bagian 1; sekarang kita beri masing-masing pemilik. Menjalankan thread di C++ mudah — satu panggilan constructor dan program langsung punya jalur eksekusi tambahan. Yang sulit justru di sekitarnya: thread tidak berjalan berurutan, urutan penyelesaiannya tak terkendali, dan siapa pun yang mencetak hasil langsung dari dalam thread akan mendapat output yang berbeda setiap eksekusi. Bagian ini membangun eksekusi paralel kita dengan satu disiplin yang menghindari semua itu: setiap thread bekerja pada datanya sendiri dan menulis hasilnya ke slot miliknya sendiri, sementara penyusunan laporan dilakukan thread utama setelah semua pekerja selesai.

Hasil yang Dirancang untuk Paralel #

Tipe hasil eksekusi paralel kita sengaja didesain sebelum thread dibuat:

cpp src/multithread.cpp — tipe hasil eksekusi paralel
// Hasil eksekusi paralel.
struct HasilParalel {
    std::int64_t jumlah = 0;            // hasil gabungan seluruh thread
    std::int64_t diAtasAmbang = 0;      // statistik: elemen > AMBANG
    std::vector<std::int64_t> parsial;  // jumlah per thread, urut id thread
};

Field parsial adalah kunci desainnya: vektor berisi satu angka per thread, dengan urutan berdasarkan id — bukan berdasarkan waktu selesai. Selama setiap thread hanya menulis ke slot miliknya, dua eksekusi dengan pembagian yang sama selalu menghasilkan isi parsial yang sama persis, seberapa pun acak urutan eksekusinya. Program kita tidak pernah mencetak urutan kerja thread; yang dicetak adalah nilai agregat yang kebetulannya berupa data.

jalankanParalel: Empat Jthread dan Slot Hasilnya #

Berikut function inti proyek ini dari src/multithread.cpp — tempat thread benar-benar dibuat:

cpp src/multithread.cpp — jalankanParalel, eksekusi paralel utama
HasilParalel jalankanParalel(const std::vector<int>& data, int jumlahThread) {
    const std::vector<Rentang> daftar = bagiRentang(data.size(), jumlahThread);

    // Pola collect-then-merge: tiap thread menulis ke slot hasil miliknya
    // sendiri. Slot berbeda = alamat memori berbeda = bebas data race.
    std::vector<std::int64_t> parsial(static_cast<std::size_t>(jumlahThread), 0);

    // 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;
        });
    }
    // Jthread otomatis di-join oleh destructor, tetapi kita butuh hasilnya
    // SEKARANG, jadi join secara eksplisit sebelum menggabungkan.
    for (std::jthread& t : pekerja) {
        t.join();
    }

    // Fase merge: thread utama menjumlahkan seluruh hasil parsial.
    HasilParalel keluar;
    for (const std::int64_t p : parsial) {
        keluar.jumlah += p;
    }
    keluar.diAtasAmbang = diAtasAmbang;
    keluar.parsial = std::move(parsial);
    return keluar;
}

Function ini memuat hampir semua pelajaran thread C++ modern dalam satu tempat:

  1. parsial(jumlahThread, 0): slot hasil dialokasikan lebih dulu, satu slot per thread. Slot yang berbeda berada di alamat memori yang berbeda, sehingga penulisan antar thread tidak pernah bersentuhan — bebas data race tanpa satu pun kunci.
  2. pekerja.emplace_back(...): setiap thread dibuat sebagai std::jthread — varian modern std::thread yang otomatis memanggil join di destructor-nya. Dengan itu, thread tidak mungkin “tergantung” bila sebuah jalur keluar lupa menunggu.
  3. Daftar capture [id, &data, &daftar, &parsial, &diAtasAmbang, &kunciStatistik]: id di-capture by value karena tiap thread butuh salinan nomornya sendiri; data besar di-capture by reference agar tidak disalin ke tiap thread. Capture id by value bukan gaya — bila by reference, semua thread membaca variable loop yang sama yang terus berubah, bug klasik pemula thread.
  4. parsial[i] = jumlahRentang(data, daftar[i]);: pekerjaan berat. Tiap thread menjumlahkan rentangnya sendiri dan menulis hanya ke slot i miliknya.
  5. Dua baris terakhir lambda: statistik diAtasAmbang memang variable bersama, sehingga penambahannya berada di dalam lock_guard. Mekanismenya kita bedah tuntas di Bagian 3.
  6. t.join() secara eksplisit: destructor jthread memang ikut men-join, tetapi kita membutuhkan hasilnya sekarang — program tidak boleh menyusun laporan dari data yang belum selesai dihitung.
  7. Fase merge: penjumlahan seluruh parsial dilakukan thread utama setelah semua thread bergabung kembali. Operasi ini berjalan satu thread saja, berurut, dan sepenuhnya deterministik.

Mencoba Sendiri dengan Program Kecil #

Simpan program berikut sebagai demo-jthread.cpp untuk melihat pola slot dan join bekerja pada 1.000 elemen:

cpp demo-jthread.cpp — empat jthread dengan slot hasil milik sendiri
// demo-jthread.cpp — empat jthread menghitung jumlah parsial miliknya sendiri.
// Tiap thread menulis hanya ke slot vector yang dialokasikan untuknya, lalu
// thread utama mencetak hasilnya SETELAH semua thread selesai (join), sehingga
// urutan output selalu sama walaupun urutan eksekusi thread tak terkendali.

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

int main() {
    std::vector<int> data(1000);
    for (std::size_t i = 0; i < data.size(); ++i) {
        data[i] = static_cast<int>(i % 7) - 3;   // pola tetap -3..3
    }

    std::vector<std::int64_t> parsial(4, 0);
    std::vector<std::jthread> pekerja;
    for (int id = 0; id < 4; ++id) {
        pekerja.emplace_back([id, &data, &parsial] {
            const std::size_t awal = static_cast<std::size_t>(id) * 250;
            std::int64_t total = 0;
            for (std::size_t i = awal; i < awal + 250; ++i) {
                total += data[i];
            }
            parsial[static_cast<std::size_t>(id)] = total;
        });
    }
    for (std::jthread& t : pekerja) {
        t.join();
    }

    std::int64_t gabungan = 0;
    for (std::size_t i = 0; i < parsial.size(); ++i) {
        std::println("thread {} selesai: jumlah parsial {}", i, parsial[i]);
        gabungan += parsial[i];
    }
    std::println("jumlah gabungan: {}", gabungan);
    return 0;
}

Kompilasi dengan -pthread — kebiasaan yang aman untuk program thread — lalu jalankan:

bash
g++ -std=c++23 -Wall -Wextra -pthread demo-jthread.cpp -o demo-jthread
./demo-jthread

Output yang Dihasilkan:

text
thread 0 selesai: jumlah parsial -5
thread 1 selesai: jumlah parsial -1
thread 2 selesai: jumlah parsial 3
thread 3 selesai: jumlah parsial 0
jumlah gabungan: -3

Angka per slot tetap sama setiap eksekusi: -5, -1, 3, dan 0, dengan gabungan -3. Nilai itu berasal dari pola data i % 7 - 3 yang berulang setiap tujuh elemen — bukan kebetulan, melainkan bukti bahwa slot-slot dihitung dengan benar. Coba jalankan programnya berkali-kali: keempat thread mungkin benar-benar selesai dalam urutan yang berbeda-beda di balik layar, tetapi output tidak pernah berubah, karena yang dicetak adalah isi slot berdasarkan nomor id, bukan kronologi eksekusi.

Dengan eksekusi paralel yang benar dan output yang stabil, tinggal satu sumber kegagalan yang belum kita buka: diAtasAmbang, satu-satunya data yang benar-benar dipakai bersama. Bagaimana empat thread menambah satu angka tanpa saling menimpa adalah topik Bagian 3.

Rujukan resmi: std::jthread (cppreference) , std::thread::join (cppreference) .