std::thread adalah pintu masuk utama kita ke dunia multithreading di C++: objek yang mewakili satu alur eksekusi yang sedang berjalan. Membuatnya sederhana — beri tugas berupa fungsi atau lambda, dan thread mulai berjalan seketika. Namun di balik kesederhanaan itu ada aturan main yang tidak bisa ditawar: setiap thread harus di-join() (atau dengan sengaja dilepas), argumen disalin secara default sehingga mengirim referensi butuh std::ref, dan thread yang di-destruct saat masih “menyala” akan menghentikan program lewat std::terminate. Chapter ini membahas semuanya lewat satu program teruji yang keluarannya dijaga tetap deterministik dengan disiplin “kumpulkan dulu, cetak setelah join”.

Membuat Thread dari Fungsi dan Lambda #

Konstruktor std::thread menerima sesuatu yang bisa dipanggil — function biasa, lambda, function object — plus argumen untuknya. Thread tidak menunggu: segera setelah objeknya dibuat, tugasnya mulai dieksekusi secara paralel dengan kode yang membuatnya. Kita bebas mencampur gaya: satu thread dari fungsi biasa, satu dari lambda, satu lagi dari lambda ber-capture, semuanya dalam satu program.

cpp
#include <iostream>
#include <string>
#include <thread>

// Fungsi yang dipakai sebagai tugas thread; hasil ditulis ke wadah
// milik pemanggil, bukan langsung ke cout (agar output tak berseling)
void tulisKe(const char* siapa, std::string& wadah) {
    wadah = std::string("Halo dari ") + siapa;
}

// Fungsi dengan parameter referensi: butuh std::ref agar thread
// tidak membuat salinan
void tambahLima(int& x) {
    x += 5;
}

int main() {
    int nilai = 10;
    int lain  = 100;
    std::string pesanA, pesanB, pesanC;

    // 1) thread dari fungsi biasa; referensi eksplisit via std::ref
    std::thread t1(tulisKe, "fungsi-biasa", std::ref(pesanA));

    // 2) thread dari lambda tanpa capture
    std::thread t2([&pesanB] { tulisKe("lambda", pesanB); });

    // 3) capture by value: thread memegang salinan `nilai` (tetap 10)
    std::thread t3([n = nilai, &pesanC] {
        pesanC = "salinan bernilai " + std::to_string(n + 5);
    });

    // 4) capture by reference: lambda mengubah variabel asli
    std::thread t4([&nilai] { nilai += 5; });

    // 5) parameter referensi dari fungsi: bungkus dengan std::ref
    std::thread t5(tambahLima, std::ref(lain));

    t1.join();
    t2.join();
    t3.join();
    t4.join();
    t5.join();

    // Semua pencetakan SETELAH join -> urutan output pasti
    std::cout << pesanA << "\n";
    std::cout << pesanB << "\n";
    std::cout << pesanC << "\n";
    std::cout << "nilai : " << nilai << "\n";   // diubah t4
    std::cout << "lain  : " << lain << "\n";    // diubah t5
    return 0;
}

Output yang Dihasilkan:

text
Halo dari fungsi-biasa
Halo dari lambda
salinan bernilai 15
nilai : 15
lain  : 105

Keluaran di atas sama persis di setiap eksekusi — dan itu hasil disiplin yang disengaja, bukan kebetulan. Bila kelima thread langsung mencetak ke std::cout, urutan barisnya akan bergantung pada penjadwalan: eksekusi pertama bisa membuka dengan Halo dari lambda, eksekusi kedua dengan Halo dari fungsi-biasa, bahkan dua potongan teks dari thread berbeda bisa terseling di tengah baris karena tiap panggilan << adalah operasi terpisah. Karena itu program ini memakai pola yang akan terus kita pakai sepanjang section: setiap pekerja menulis hasilnya ke wadah miliknya sendiri, dan pencetakan baru dilakukan main() setelah semua join().

Bedah barisnya:

  1. Baris 23 (std::thread t1(tulisKe, "fungsi-biasa", std::ref(pesanA))): tugas berupa function biasa plus dua argumen. Argumen disalin ke dalam thread oleh konstruktor — kecuali yang dibungkus std::ref, yang diteruskan sebagai referensi sehingga pesanA yang asli terisi.
  2. Baris 26 (t2, lambda tanpa capture): lambda tanpa capture berlaku seperti fungsi biasa; ia menulis ke pesanB lewat referensi yang di-capture.
  3. Baris 29-31 (t3, capture by value): n = nilai menyalin nilai SAAT lambda dibuat, bukan saat lambda dijalankan. Karena salinannya 10, baris ini selalu menghasilkan teks “salinan bernilai 15” — tidak terpengaruh oleh t4 yang mengubah variabel asli.
  4. Baris 34 (t4, capture by reference): [&nilai] membuat thread menyentuh variabel asli; setelah join(), nilai pasti sudah 15. Referensi ini aman karena nilai tetap hidup sampai setelah join().
  5. Baris 37 (t5 dengan std::ref(lain)): konstruktor std::thread SELALU menyalin argumen yang dikirim ke fungsi — bahkan bila parameternya bertipe int&. Tanpa std::ref, kode ini bahkan tidak bisa dikompilasi karena thread mencoba menyalin untuk parameter referensi non-const. std::ref(lain) membungkus referensi sehingga tambahLima bekerja pada lain yang asli.
  6. Baris 39-43 (join()): main() menunggu kelima thread. join() memblokir sampai thread-nya benar-benar berakhir, dan setelah itu objek thread menjadi “tidak menyala” (not joinable).

detach(): Kenapa Kita Menghindarinya #

Selain join(), ada detach(): thread dilepas dan berjalan sendiri tanpa bisa digabung kembali. Setelah di-detach, joinable() menjadi false dan program tidak menunggu thread itu selesai — bahkan bila main() sudah selesai lebih dulu. Masalahnya nyata: thread yang dilepas sering masih menyentuh data yang hidupnya bersama scope pembuatnya, dan kita tidak punya cara untuk mengetahui kapan ia benar-benar selesai. Menghentikannya pun tidak ada mekanisme standar yang aman.

Karena itu konvensi C++ modern berbunyi: hindari detach(). Kelola umur thread secara eksplisit dengan join(), dan bila kita butuh “kerjakan lalu abaikan hasilnya”, lebih baik pakai std::jthread dengan sinyal penghentian (chapter jthread dan stop_token) atau thread pool yang menjadi bagian desain program, bukan thread liar tanpa pemilik.

Jika Lupa join(): std::terminate #

Aturan paling keras dari std::thread adalah ini: destructor thread yang masih joinable memanggil std::terminate() — program dihentikan seketika tanpa jalur pemulihan. Kompiler tidak menolaknya, runtime tidak memberi peringatan; program yang kelihatannya normal bisa mati tiba-tiba hanya karena satu return yang melewati objek thread tanpa join().

cpp Pola yang menghentikan program — JANGAN dijalankan
void tugas();

int main() {
    std::thread t(tugas);
    // ... t keluar dari main() tanpa join() atau detach()
    // destructor melihat t.joinable() == true
    // -> std::terminate() dipanggil -> program berhenti
}

Blok di atas sengaja tidak kita jalankan dan tidak kita klaim keluarannya. Pola yang benar sudah terlihat pada program utama chapter ini: setiap std::thread yang dibuat menerima join()-nya sebelum scope berakhir. Bila pola itu terasa melelahkan, kabar baiknya C++20 menyediakan std::jthread yang melakukan join otomatis di destructor — kita pelajari setelah melewati dasar-dasar siklus hidup thread di chapter berikutnya.

Referensi: cppreference — std::thread , cppreference — std::thread::join , cppreference — std::thread::detach .