Proyek penutup section ini menjawab pertanyaan yang muncul sendiri begitu kita tahu CPU punya lebih dari satu core: mengapa program kita hanya menggunakan satu dari empat jalur eksekusi? Tugasnya sederhana untuk dijelaskan — menjumlahkan 4.000.000 angka dan menghitung statistiknya — tetapi cara memecahnya menentukan segalanya. Di proyek ini kita membangun aplikasi multithreaded dalam empat bagian: membagi pekerjaan, menjalankan thread, menyinkronkan data, dan menguji kebenaran sekaligus performa. Bagian pertama justru tidak membuat thread sama sekali; kita menyiapkan fondasinya berupa pembagian rentang kerja yang bisa diperiksa kebenarannya di layar, karena pembagian yang salah tidak bisa diselamatkan oleh thread yang benar.

Peta Proyek dan Data Uji yang Deterministik #

Seluruh berkas proyek berada di dalam bundle yang sama:

text
aplikasi-multithreaded/
├── _index.md                            (halaman overview proyek)
├── part-1-membagi-pekerjaan/
├── part-2-menjalankan-thread/
├── part-3-menyinkronkan-data/
├── part-4-menguji-kebenaran-dan-performa/  (program final + harness)
├── src/
│   └── multithread.cpp                  (program final, lengkap, teruji)
└── tests/
    └── uji-multithread.cpp              (harness pengujian)

Program tujuan akhirnya di src/multithread.cpp, dan seperti proyek-proyek sebelumnya, tiap bagian membedah potongan yang sedang dikerjakan. Kita mulai dari konstanta dan tipe data dasarnya:

cpp src/multithread.cpp — konstanta dan tipe data
constexpr std::size_t JUMLAH_DATA = 4'000'000;
constexpr int JUMLAH_THREAD = 4;
constexpr int AMBANG = 500;

// ---------- Tipe data ----------

// Satu potongan data (chunk) milik satu thread.
struct Rentang {
    std::size_t awal = 0;
    std::size_t panjang = 0;
};

Data ujinya dibuat oleh PRNG — pseudo-random number generator — dengan seed tetap. Ini keputusan yang sama dengan yang kita pakai pada proyek-proyek sebelumnya: angka acak boleh dipakai selama deretnya bisa direproduksi:

cpp src/multithread.cpp — buatData dengan seed tetap
std::vector<int> buatData(std::size_t jumlah) {
    std::mt19937 mesin(42);   // seed tetap → deret angka selalu sama
    std::uniform_int_distribution<int> sebar(-1000, 1000);
    std::vector<int> data(jumlah);
    for (int& nilai : data) {
        nilai = sebar(mesin);
    }
    return data;
}

std::mt19937 yang diberi seed 42 selalu menghasilkan deret yang sama di mesin mana pun, dan std::uniform_int_distribution menaburkan nilainya merata pada rentang -1000 sampai 1000. Dengan dua baris itu, siapa pun yang menjalankan program mendapat 4.000.000 angka yang identik — syarat mutlak agar hasil program bisa dijadikan output terverifikasi di Bagian 4.

Memotong 4.000.000 Elemen Menjadi 4 Rentang #

Pembagian pekerjaannya dijalankan oleh function bagiRentang. Cukup satu strategi yang perlu dipahami: potong data menjadi rentang-rentang yang berurutan dan kontigu, bukan selang-seling:

cpp src/multithread.cpp — function bagiRentang
std::vector<Rentang> bagiRentang(std::size_t total, int jumlahThread) {
    std::vector<Rentang> daftar;
    daftar.reserve(static_cast<std::size_t>(jumlahThread));
    const std::size_t dasar = total / jumlahThread;
    const std::size_t sisa = total % jumlahThread;
    std::size_t awal = 0;
    for (int i = 0; i < jumlahThread; ++i) {
        const std::size_t panjang =
            dasar + (static_cast<std::size_t>(i) < sisa ? 1 : 0);
        daftar.push_back({awal, panjang});
        awal += panjang;
    }
    return daftar;
}

Baris demi baris, mekanismenya seperti ini:

  1. dasar = total / jumlahThread: pembagian integer memberi jatah bawaan setiap thread — untuk 4.000.000 dan 4 thread, hasilnya 1.000.000.
  2. sisa = total % jumlahThread: elemen yang tak terbagi rata. Bila totalnya 10 dan thread-nya 4, dasar bernilai 2 dan sisa bernilai 2 — dua elemen harus mendapat jatah ekstra.
  3. dasar + (i < sisa ? 1 : 0): sisa dibagikan satu per satu ke thread terdepan. Thread 0 dan 1 masing-masing mendapat 3 elemen, thread 2 dan 3 mendapat 2. static_cast<std::size_t> menjaga perbandingan antar tipe tanpa narrowing — potongan yang baru kita bedah tuntas pada Chapter Narrowing Conversion.
  4. daftar.push_back({awal, panjang}); awal += panjang;: setiap rentang baru dimulai tepat di ujung rentang sebelumnya. Tidak ada celah, tidak ada tumpang tindih — total seluruh panjang selalu persis sama dengan total.

Strategi rentang kontigu ini bukan sekadar kesederhanaan. Elemen yang berurutan di memori memberi akses yang ramah cache, dan karena setiap thread hanya menyentuh potongannya sendiri, kita menunda hampir seluruh persoalan sinkronisasi ke Bagian 3.

Mencoba Pembagian Tanpa Thread #

Sebelum thread mana pun dibuat, kita periksa dulu hasil pembagiannya. Simpan program berikut sebagai demo-bagi.cpp — isinya memakai salinan bagiRentang agar bisa dicetak:

cpp demo-bagi.cpp — uji bagiRentang tanpa thread
// demo-bagi.cpp — mencoba pembagian rentang kerja tanpa thread.
// Function bagiRentang disalin dari src/multithread.cpp agar kita bisa
// memeriksa hasilnya di layar sebelum thread mana pun dijalankan.

#include <print>
#include <vector>

struct Rentang {
    std::size_t awal = 0;
    std::size_t panjang = 0;
};

std::vector<Rentang> bagiRentang(std::size_t total, int jumlahThread) {
    std::vector<Rentang> daftar;
    daftar.reserve(static_cast<std::size_t>(jumlahThread));
    const std::size_t dasar = total / jumlahThread;
    const std::size_t sisa = total % jumlahThread;
    std::size_t awal = 0;
    for (int i = 0; i < jumlahThread; ++i) {
        const std::size_t panjang =
            dasar + (static_cast<std::size_t>(i) < sisa ? 1 : 0);
        daftar.push_back({awal, panjang});
        awal += panjang;
    }
    return daftar;
}

int main() {
    std::println("10 elemen untuk 4 thread:");
    for (const Rentang& r : bagiRentang(10, 4)) {
        std::println("  awal {:>2}  panjang {}", r.awal, r.panjang);
    }
    std::println("4.000.000 elemen untuk 4 thread:");
    for (const Rentang& r : bagiRentang(4'000'000, 4)) {
        std::println("  awal {:>7}  panjang {:>7}", r.awal, r.panjang);
    }
    return 0;
}

Kompilasi dan jalankan:

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

Output yang Dihasilkan:

text
10 elemen untuk 4 thread:
  awal  0  panjang 3
  awal  3  panjang 3
  awal  6  panjang 2
  awal  8  panjang 2
4.000.000 elemen untuk 4 thread:
  awal       0  panjang 1000000
  awal 1000000  panjang 1000000
  awal 2000000  panjang 1000000
  awal 3000000  panjang 1000000

Kasus 10 elemen memperlihatkan aturan sisa dengan jelas: 10 / 4 memberi jatah dasar 2, lalu dua sisa dibagikan ke thread 0 dan 1, menghasilkan rentang berpanjang 3, 3, 2, 2. Kasus 4.000.000 untuk 4 thread habis dibagi rata sehingga keempat rentangnya persis 1.000.000 elemen. Perhatikan juga kolom awal: 0, 1.000.000, 2.000.000, 3.000.000 — masing-masing rentang menyambung rentang sebelumnya tanpa melewatkan satu elemen pun.

Function sekecil ini sering diremehkan, padahal seluruh kebenaran program paralel kita menumpuk di sini: bila ada satu elemen yang terlewat atau dihitung dua kali, tidak ada jumlah thread yang bisa memperbaikinya. Karena itu function ini murni — masukan sama, keluaran sama, tanpa efek samping — dan persis tipe function yang paling mudah diuji; di Bagian 4 harness memeriksanya dengan lima kasus, termasuk data kosong. Selanjutnya kita beri keempat rentang ini pemiliknya: thread.

Rujukan resmi: std::mt19937 (cppreference) , std::uniform_int_distribution (cppreference) .