Chapter std::async menutup rantai future dengan satu catatan penting: setiap panggilan ber-policy launch::async berpotensi membuka thread baru, dan thread itu dibongkar lagi setelah hasilnya diambil. Untuk lima tugas satuan, biaya tersebut masih wajar; untuk ratusan tugas kecil yang datang terus-menerus, membuka-menutup thread berulang kali menjadi pemborosan yang terasa. Pola thread pool menjawabnya dengan keputusan sederhana: buka sekelompok pekerja sekali di awal, taruh setiap tugas ke sebuah antrian, dan biarkan pekerja mengambilnya sendiri selama program hidup. Seluruh bahannya sudah kita pegang — mutex dan lock_guard dari chapter std::mutex, condition_variable dari chapter Condition Variable, serta std::future dari dua chapter sebelumnya — sehingga kita bisa langsung merakit pool mini yang benar-benar diuji di bawah ini.
Anatomi Thread Pool #
Thread pool tersusun dari tiga komponen yang saling menopang:
- Antrian tugas — sebuah
std::queueyang tiap elemennya callable tanpa argumen. Antrian ini ditulis dan diambil dari thread yang berbeda, sehingga seluruh aksesnya dilindungi mutex. - Worker loop — fungsi yang dijalankan setiap thread pekerja sepanjang hidupnya: tidur menunggu di condition variable, mengambil satu tugas, mengeksekusinya, lalu kembali tidur.
- Shutdown bersih — satu flag
berhentiyang ikut dibaca predicatewait(). Saat flag diaktifkan, pekerja menuntaskan sisa antrian lalu keluar dari loop, dan pemilik pool melakukanjoin()terhadap semua pekerja.
Pekerja tidak perlu tahu siapa pemesan tugas dan apa makna hasilnya — pekerja hanya mengeksekusi callable yang diambilnya. Konsekuensinya, elemen antrian kita bertipe std::move_only_function<void()>: wrapper callable dari C++23 yang boleh dipindah tetapi tidak harus bisa disalin. Pilihan ini penting karena tugas yang akan kita antriarkan membawa std::packaged_task, dan objek tersebut move-only.
Program Utama: Pool Mini #
Program berikut merakit ketiga komponen di atas menjadi satu class ThreadPool, mengirim enam tugas kuadrat, lalu mencetak hasilnya dalam urutan pengiriman:
#include <condition_variable>
#include <functional>
#include <future>
#include <iostream>
#include <mutex>
#include <queue>
#include <string>
#include <thread>
#include <utility>
#include <vector>
// Thread pool mini: sekelompok pekerja mengambil tugas dari satu antrian.
class ThreadPool {
public:
explicit ThreadPool(unsigned jumlahPekerja) {
for (unsigned i = 0; i < jumlahPekerja; ++i)
pekerja_.emplace_back([this] { loopPekerja(); });
}
~ThreadPool() {
{ // sinyal berhenti; antrian yang tersisa tetap dituntaskan
std::lock_guard<std::mutex> kunci(mutex_);
berhenti_ = true;
}
cv_.notify_all();
for (std::thread& p : pekerja_) p.join();
}
// Kirim tugas ke antrian; hasilnya diambil lewat std::future.
template <typename F>
auto jalankan(F tugas) -> std::future<decltype(tugas())> {
using Hasil = decltype(tugas());
std::packaged_task<Hasil()> paket(std::move(tugas));
std::future<Hasil> hasil = paket.get_future();
{
std::lock_guard<std::mutex> kunci(mutex_);
antrian_.push([paket = std::move(paket)] mutable { paket(); });
}
cv_.notify_one();
return hasil;
}
private:
// Worker loop: tidur di condition_variable sampai ada tugas atau berhenti.
void loopPekerja() {
for (;;) {
std::move_only_function<void()> tugas;
{
std::unique_lock<std::mutex> kunci(mutex_);
cv_.wait(kunci, [this] { return berhenti_ || !antrian_.empty(); });
if (berhenti_ && antrian_.empty()) return; // shutdown bersih
tugas = std::move(antrian_.front());
antrian_.pop();
}
tugas(); // tugas dieksekusi DI LUAR lock
}
}
std::vector<std::thread> pekerja_;
std::queue<std::move_only_function<void()>> antrian_;
std::mutex mutex_;
std::condition_variable cv_;
bool berhenti_ = false;
};
int main() {
std::vector<std::string> hasil;
{
ThreadPool pool(3);
std::vector<std::future<std::string>> tiket;
for (int id = 1; id <= 6; ++id) {
tiket.push_back(pool.jalankan([id] -> std::string {
return "tugas #" + std::to_string(id)
+ " -> kuadrat " + std::to_string(id * id);
}));
}
for (std::future<std::string>& t : tiket) hasil.push_back(t.get());
} // destruktor pool: sinyal berhenti + join semua pekerja
std::cout << "hasil tugas (urutan pengiriman):\n";
for (const std::string& baris : hasil) std::cout << baris << "\n";
std::cout << "pool dimatikan: semua pekerja sudah di-join\n";
return 0;
}Output yang Dihasilkan:
hasil tugas (urutan pengiriman):
tugas #1 -> kuadrat 1
tugas #2 -> kuadrat 4
tugas #3 -> kuadrat 9
tugas #4 -> kuadrat 16
tugas #5 -> kuadrat 25
tugas #6 -> kuadrat 36
pool dimatikan: semua pekerja sudah di-joinKeluaran selalu sama di setiap eksekusi — bukan kebetulan, melainkan desain yang akan kita bedah di akhir bagian ini. Mari kita telaah baris demi baris:
- Baris 15-17 (constructor): constructor langsung membuka
jumlahPekerjathread, dan setiap pekerja menjalankanloopPekerja()milik objek yang sama karena lambda menangkapthis. Angka 3 dimain()kita buat tetap agar keluaran bisa dijamin — pembahasannya ada di bagian ukuran pool. - Baris 31-34 (menyiapkan packaged_task):
decltype(tugas())meminta compiler menyimpulkan tipe hasil callable, sehinggajalankan()otomatis mengembalikanstd::futureyang tepat.std::packaged_task<Hasil()>membungkus tugasnya, danget_future()memberi pemanggil tiket menuju shared state tempat hasil akan disimpan. - Baris 35-39 (masuk antrian lalu notify): lambda di baris 37 membawa
packaged_taskke dalam antrian; katamutablewajib karenaoperator()milikpackaged_tasktidak const. Pengiriman dilakukan di bawahlock_guard, dannotify_one()pada baris 39 membangunkan satu pekerja yang sedang tidur. - Baris 50 (predicate wait): pekerja tidur sampai
berhenti_ || !antrian_.empty()bernilai true. Predicate menangkal lost wakeup — sinyal yang datang sebelum pekerja sempat tidur tetap terlihat saatwait()memeriksa predicate-nya. - Baris 51-54 (mengambil satu tugas): bila
berhenti_sudah aktif DAN antrian kosong, pekerja keluar dari loop. Selain itu, tugas terdepan dipindah keluar antrian — masih di dalam lock, karenafront()danpop()harus selesai tanpa diselingi pekerja lain. - Baris 55 (eksekusi di luar lock): scope lock sudah ditutup sebelum
tugas()dipanggil, sehingga tugas yang lama selesainya tidak membuat pekerja lain menunggu mutex. Tanpa pemisahan ini, pool kehilangan paralelismenya dan berubah menjadi eksekutor sekuensial. - Baris 20-27 (destruktor: shutdown bersih): flag
berhenti_diaktifkan di bawah lock,notify_all()membangunkan semua pekerja tidur, lalujoin()menunggu masing-masing benar-benar berakhir. Predicatewait()menjamin pekerja yang masih menyisakan antrian menuntaskannya dulu, sehingga tidak ada tugas yang terlewat saat pool dibongkar. - Baris 66-82 (kumpulkan dulu, cetak belakangan):
get()pada baris 77 memblokir sampai tugas tiket itu selesai, dan tiket-tiket disimpan di vektor sesuai urutan pengiriman. Pencetakan baru berjalan setelah scope ditutup — saat semua tugas selesai dan semua pekerja sudah di-join.
Ukuran Pool dan hardware_concurrency #
Berapa pekerja yang ideal? Standar menyediakan titik awal berupa std::thread::hardware_concurrency() dari header <thread> — fungsi yang melaporkan perkiraan jumlah thread yang bisa dijalankan benar-benar bersamaan oleh implementasi. Di lingkungan praktikum ini fungsi tersebut melaporkan 2; di komputer pembaca nilainya bisa 4, 8, atau angka lain. Nilai yang perlu diwaspadai adalah 0: standar menyatakan 0 berarti informasi tidak tersedia, sehingga pool harus punya nilai cadangan sebelum dipakai.
unsigned nInti = std::thread::hardware_concurrency(); // bisa 0 bila tidak diketahui
unsigned ukuran = (nInti == 0) ? 2u : nInti;Angka yang sehat bergantung pada bentuk pekerjaannya. Tugas yang sepanjang hidupnya menghabiskan CPU — kompresi, encoding, perhitungan numerik — bekerja paling efisien saat jumlah pekerja sekitar jumlah inti; lebih dari itu hanya menambah antrean penjadwal. Tugas yang sering tidur menunggu jaringan atau disk boleh diberi pekerja lebih banyak, karena pekerja yang menunggu tidak memerebutkan CPU. Pool 3 pekerja pada program utama di mesin 2 inti berarti sedikit oversubscription — untuk tugas sekecil kuadrat, perbedaannya tidak terasa, dan program tetap benar.
Thread Pool dan std::future #
jalankan() kita adalah jembatan antara dua dunia yang sudah kita kenal: sisi antrian berbicara dalam callable tanpa argumen, sisi pemanggil berbicara dalam std::future. Jembatannya adalah std::packaged_task — mekanisme yang sama dengan std::async pada chapter lalu: hasil maupun exception tugas disimpan di shared state, dan get() mengembalikan nilai atau melempar ulang exception-nya ke pemanggil. Karena itu pool kita gratis mendapat pengantaran exception: ganti salah satu tugas dengan versi yang melempar std::runtime_error, dan pemanggil cukup menangkapnya di sekitar get() persis seperti pada program std::async.
Bedanya terletak pada siapa yang membuka thread. std::async dengan policy async menitipkan keputusan itu ke implementasi dan berpotensi membuka thread baru untuk tiap panggilan; pool kita membuka thread tepat sebanyak yang kita minta, lalu memakainya ulang untuk semua tugas. Kepemilikan thread juga jelas: ThreadPool adalah objek RAII yang destructor-nya melakukan join(), sehingga lupa menutup pool tidak berujung pada std::terminate seperti kasus std::thread yang dibiarkan tanpa join pada chapter Siklus Hidup Thread.
Satu batasan pool sederhana ini perlu dicatat: pekerja hanya bisa berhenti di antara tugas. Sinyal berhenti_ baru diperiksa saat pekerja kembali ke wait(), sehingga tugas yang berjalan panjang tetap harus diselesaikan sampai tuntas. C++20 menyediakan jawaban untuk penghentian di tengah jalan — std::jthread dengan std::stop_token yang bisa diteruskan ke dalam tugas — dan mekanisme itulah topik chapter berikutnya.
Referensi: cppreference — Thread support library , cppreference — std::move_only_function , cppreference — std::thread::hardware_concurrency .