Dasar-dasar std::thread

Meluncurkan thread, join, detach, dan batas-batasnya.

Daftar isi

Topik ini membahas dasar-dasar std::thread, yaitu kelas pustaka standar untuk meluncurkan thread dan mengatur umurnya. Pembahasan mencakup cara meluncurkan thread, menunggu penyelesaiannya dengan join, menyerahkannya dengan detach, serta batas-batas yang menyertai keduanya. Contoh yang dipakai membagi penghitungan satu batch nilai ujian ke dua thread, kemudian merangkum hasilnya di thread utama setelah keduanya selesai.

Meluncurkan thread pertama#

Sebuah thread adalah alur eksekusi yang berjalan sejajar dengan alur utama program di dalam proses yang sama. Thread diluncurkan dengan membuat objek std::thread yang diberi fungsi beserta argumennya. Sistem operasi menjalankan fungsi tersebut secara bersamaan dengan kode yang meluncurkannya, termasuk baris-baris setelah konstruksi objeknya. Program berikut membagi satu vektor nilai ujian menjadi dua batch, memberi setiap batch satu thread, lalu merangkum hasilnya setelah kedua thread selesai.

cpp jumlah-dua-thread.cpp
#include <functional>
#include <iostream>
#include <thread>
#include <vector>

void jumlahkan(const std::vector<int>& nilai, int awal, int akhir, int& hasil) {
    for (int i = awal; i < akhir; ++i) {
        hasil += nilai[i];
    }
}

int main() {
    std::vector<int> nilai = {80, 75, 90, 65, 70, 95, 85, 62};
    int jumlahA = 0;
    int jumlahB = 0;

    std::thread tA(jumlahkan, std::cref(nilai), 0, 4, std::ref(jumlahA));
    std::thread tB(jumlahkan, std::cref(nilai), 4, 8, std::ref(jumlahB));

    tA.join();
    tB.join();

    std::cout << "jumlah batch A = " << jumlahA << "\n";
    std::cout << "jumlah batch B = " << jumlahB << "\n";
    std::cout << "total          = " << (jumlahA + jumlahB) << "\n";
    return 0;
}
text Keluaran
jumlah batch A = 310
jumlah batch B = 312
total          = 622

Program di atas memuat dua thread, tA dan tB, yang masing-masing menghitung jumlah satu bagian data. Fungsi jumlahkan menerima data melalui referensi konstan, sedangkan hasilnya dikembalikan melalui parameter referensi hasil. Pemanggilan tA.join() membuat thread utama berhenti sementara sampai tA selesai, dan hal yang sama berlaku untuk tB. Karena kedua pemanggilan join ditempatkan sebelum pencetakan, urutan keluaran selalu sama walaupun urutan penyelesaian kedua thread tidak dapat dijamin.

Kompilasi dan eksekusi programnya dilakukan dengan perintah berikut.

bash
# kompilasi program dengan dukungan thread
g++ -std=c++17 -pthread jumlah-dua-thread.cpp -o /tmp/jumlah-dua-thread
# jalankan program hasil kompilasi
/tmp/jumlah-dua-thread
text Keluaran
jumlah batch A = 310
jumlah batch B = 312
total          = 622

Argumen thread disalin secara default#

Konstruktor std::thread menyalin atau memindahkan setiap argumen ke penyimpanan milik thread baru. Salinan ini diperlukan karena variabel lokal fungsi peluncur bisa sudah musnah ketika thread baru membacanya. Parameter yang dideklarasikan sebagai referensi harus dibungkus std::ref atau std::cref agar referensi aslinya yang diteruskan, bukan salinannya. Pada program sebelumnya, std::cref(nilai) meneruskan referensi konstan ke vektor nilai, sedangkan std::ref(jumlahA) meneruskan referensi yang boleh diubah.

Apabila pembungkus referensi itu dilupakan, argumen tetap disalin. Salinan tersebut tidak dapat diikat ke parameter referensi yang tidak konstan, sehingga compiler menolak kode itu dengan pesan error. Sebaliknya, parameter berupa referensi konstan masih menerima salinan tersebut tanpa pesan apa pun, sehingga thread bekerja pada salinan alih-alih pada data aslinya tanpa peringatan. Kebiasaan yang aman adalah menyebut std::ref atau std::cref secara eksplisit setiap kali thread diberi akses ke data milik pihak lain.

Method join() menghentikan eksekusi thread pemanggil sampai thread yang diwakili objek tersebut selesai. Setelah join kembali, seluruh pekerjaan thread tersebut sudah berakhir dan sumber dayanya sudah dilepaskan, sehingga hasil kerjanya aman dibaca. Objek std::thread yang masih mewakili thread yang berjalan, atau disebut joinable, wajib diberi join() atau detach() sebelum objeknya dimusnahkan. Apabila destruktor dijalankan saat statusnya masih joinable, pustaka standar memanggil std::terminate dan program berakhir seketika. Peluncuran dan penyelesaian setiap thread karenanya perlu dipasangkan secara eksplisit dalam satu fungsi yang sama.

Method joinable() melaporkan apakah sebuah objek masih mewakili thread yang belum selesai. Objek yang sudah diberi join, sudah diberi detach, atau belum pernah diluncurkan melaporkan false, sehingga nilainya dapat dipakai untuk memeriksa keamanan pemanggilan berikutnya. Pemeriksaan semacam ini berguna pada fungsi yang jalannya bercabang, misalnya ketika terjadi error sebelum join sempat dipanggil.

Menyerahkan thread dengan detach#

Method detach() memutus hubungan antara objek std::thread dan thread yang diwakilinya. Setelah pemanggilan itu, thread terus berjalan sendiri di latar belakang, sedangkan objeknya segera menjadi tidak joinable dan dapat dimusnahkan dengan aman. Potongan berikut mengilustrasikan pola tersebut.

cpp Contoh: thread yang dilepaskan dengan detach
// tidak-dijalankan (proses dapat berakhir sebelum thread selesai, sehingga keluarannya tidak terjamin)
#include <iostream>
#include <thread>

void catat_log() {
    for (int i = 0; i < 5; ++i) {
        std::cout << "menulis log " << i << "\n";
    }
}

int main() {
    std::thread pekerja(catat_log);
    pekerja.detach();

    std::cout << "main selesai tanpa menunggu\n";
    return 0;
}

Potongan di atas dapat dikompilasi, tetapi keluarannya tidak dapat dipastikan. Proses main dapat berakhir sebelum thread pekerja sempat mencetak satu baris pun, sehingga sebagian atau seluruh pesannya hilang tanpa error apa pun. Risiko yang lebih serius muncul bila thread yang dilepaskan masih merujuk data milik fungsi peluncurnya, karena data itu musnah bersama fungsinya sementara thread masih membacanya. Karena tidak ada cara standar untuk menunggu thread yang sudah dilepaskan, pola detach hanya layak dipakai untuk pekerjaan yang benar-benar mandiri, misalnya pemantauan berkas konfigurasi yang umurnya sama dengan program. Untuk kebutuhan lain, join tetap menjadi pilihan yang dapat dipertanggungjawabkan.

Batas-batas std::thread#

Kelas std::thread menyelesaikan tugasnya pada tingkat paling dasar, yaitu meluncurkan eksekusi dan mengatur umurnya, tanpa mekanisme pertukaran hasil. Fungsi yang dijalankan sebuah thread bertipe void, sehingga nilai kembalinya tidak dapat diambil. Perpindahan hasil memerlukan data bersama yang dilindungi mutex, yaitu objek kunci yang hanya boleh dipegang satu thread pada satu waktu, atau alternatif berbasis tugas seperti std::async dan std::future. Kelas ini juga tidak dapat disalin, tetapi dapat dipindahkan dengan std::move, sehingga kepemilikan thread dapat diteruskan antar fungsi atau dikumpulkan dalam std::vector.

Jumlah thread yang diluncurkan sebaiknya disesuaikan dengan mesin yang menjalankannya. Method statis std::thread::hardware_concurrency() melaporkan perkiraan jumlah thread yang dapat berjalan bersamaan secara efektif pada mesin tersebut.

cpp Contoh: menanyakan jumlah inti prosesor
// tidak-dijalankan (keluarannya bergantung pada mesin yang menjalankannya)
#include <iostream>
#include <thread>

int main() {
    unsigned int inti = std::thread::hardware_concurrency();
    std::cout << "thread efektif: " << inti << "\n";
    return 0;
}

Error yang dilempar di dalam thread juga tidak dapat diteruskan ke thread lain. Apabila fungsi thread melempar exception tanpa menangkapnya sendiri, fungsi std::terminate dipanggil dan seluruh proses berakhir. Setiap fungsi yang dijalankan thread karenanya perlu menangkap exception miliknya dan menerjemahkannya menjadi nilai yang disimpan pada data bersama, misalnya kode error, agar pemanggil dapat menanganinya lebih lanjut.

Kesalahan umum#

  • Meluncurkan thread lalu lupa join. Destruktor menemukan status joinable dan memanggil std::terminate. Perbaikannya adalah memasangkan setiap peluncuran dengan join() pada jalur eksekusi yang sama, termasuk jalur yang melewati error.
  • Memanggil join() dua kali pada objek yang sama. Pemanggilan kedua melempar std::system_error karena statusnya sudah tidak joinable. Periksa joinable() terlebih dahulu bila jalur eksekusinya bercabang.
  • Meneruskan referensi tanpa std::ref. Thread menerima salinan datanya, sehingga perubahan yang dihitung thread tidak pernah terlihat oleh pemanggil.
  • Mengharapkan thread mengembalikan nilai. Fungsi thread bertipe void, sehingga hasilnya harus disalurkan melalui data bersama yang terlindungi, atau melalui mekanisme tugas seperti std::async.

Ringkasan#

  • std::thread meluncurkan fungsi secara bersamaan, dan argumennya disalin secara default kecuali dibungkus std::ref atau std::cref.
  • join() menunggu penyelesaian thread, sedangkan detach() melepaskannya, dan salah satu dari keduanya wajib dilakukan sebelum objek thread dimusnahkan agar program tidak berakhir melalui std::terminate.
  • detach menghilangkan kemampuan menunggu threadnya, sehingga hanya cocok untuk pekerjaan yang umurnya sama dengan program.
  • std::thread tidak menyediakan jalur nilai kembali dan tidak dapat disalin, tetapi dapat dipindahkan dengan std::move, dan jumlah thread efektif dapat ditanyakan melalui hardware_concurrency().

Referensi#