async, future, dan promise

Konkurensi berbasis task dan gambaran umum thread pool.

Daftar isi

Topik ini membahas konkurensi berbasis tugas (task-based concurrency) menggunakan std::async, std::future, dan std::promise. Pada pola ini, pekerjaan dikemas sebagai tugas yang menghasilkan nilai, sedangkan pengaturan threadnya diserahkan kepada pustaka standar. Pembahasan mencakup peluncuran tugas asinkron, pengambilan hasilnya, penyerahan nilai antar thread dengan std::promise, serta gambaran umum thread pool. Setelah topik ini dibaca, perbedaan pola thread eksplisit dan pola tugas sudah terpetakan lengkap dengan risikonya.

std::async dan std::future#

std::async menjalankan sebuah fungsi sebagai tugas dan mengembalikan std::future yang akan memuat hasilnya. Objek std::future mewakili nilai yang belum tentu sudah ada, dan method get() menunggu sampai tugasnya selesai lalu mengembalikan hasilnya. Argumen pertama berupa std::launch::async memaksa tugas dijalankan pada thread tersendiri, sedangkan tanpa argumen itu pustaka bebas memilih menjalankan tugas segera atau menundanya. Program berikut menghitung rata-rata dua batch nilai ujian secara bersamaan.

cpp rekap-async.cpp
#include <functional>
#include <future>
#include <iostream>
#include <vector>

double rata_rata(const std::vector<int>& nilai, int awal, int akhir) {
    int jumlah = 0;
    for (int i = awal; i < akhir; ++i) {
        jumlah += nilai[i];
    }
    return static_cast<double>(jumlah) / (akhir - awal);
}

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

    std::future<double> fA = std::async(std::launch::async, rata_rata,
                                        std::cref(nilai), 0, 4);
    std::future<double> fB = std::async(std::launch::async, rata_rata,
                                        std::cref(nilai), 4, 8);

    double a = fA.get();
    double b = fB.get();

    std::cout << "rata-rata batch A  = " << a << "\n";
    std::cout << "rata-rata batch B  = " << b << "\n";
    std::cout << "rata-rata gabungan = " << (a + b) / 2 << "\n";
    return 0;
}
text Keluaran
rata-rata batch A  = 77.5
rata-rata batch B  = 78
rata-rata gabungan = 77.75

Dua tugas menghitung rata-rata masing-masing batch, dan keduanya berjalan pada thread berbeda karena std::launch::async dinyatakan eksplisit. Pemanggilan fA.get() menunggu tugas pertama, sedangkan fB.get() menunggu tugas kedua. Pencetakan dilakukan setelah keduanya selesai, sehingga keluarannya pasti sama pada setiap eksekusi. Nilai kembalian berupa double tersalur otomatis melalui std::future<double>, tanpa data bersama maupun kunci yang ditulis manual. Tipe kembalian tugasnya diperiksa compiler terhadap tipe yang disimpan std::future, sehingga kesalahan tipe tertangkap saat kompilasi.

Kompilasi dan eksekusi programnya memakai perintah berikut.

bash
# kompilasi program dengan dukungan thread
g++ -std=c++17 -pthread rekap-async.cpp -o /tmp/rekap-async
# jalankan program hasil kompilasi
/tmp/rekap-async
text Keluaran
rata-rata batch A  = 77.5
rata-rata batch B  = 78
rata-rata gabungan = 77.75

Exception yang dilempar di dalam tugas tidak hilang. Objek std::future menyimpannya dan melempar ulang exception yang sama saat get() dipanggil, sehingga penanganan error tugas dapat ditulis di sisi pemanggil dengan try dan catch yang biasa. Perilaku ini berbeda dari std::thread, yang menghentikan program bila exceptionnya tidak ditangkap di dalam thread itu sendiri.

std::promise dan std::future#

Pasangan std::promise dan std::future adalah kanal satu arah untuk menyerahkan satu nilai dari satu thread kepada thread lain. Thread pengirim memanggil set_value(), sedangkan thread penerima membacanya melalui get() pada std::future yang diperoleh dari method get_future(). Pola ini berguna ketika thread perlu dibuat secara manual, misalnya untuk pekerjaan berumur panjang, tetapi hasilnya tetap ingin diserahkan sekali di akhir.

cpp janji-hasil.cpp
#include <future>
#include <iostream>
#include <thread>
#include <utility>

void kerjakan(std::promise<int> janji, int batas) {
    int jumlah = 0;
    for (int i = 1; i <= batas; ++i) {
        jumlah += i * i;
    }
    janji.set_value(jumlah);
}

int main() {
    std::promise<int> janji;
    std::future<int> hasil = janji.get_future();

    std::thread pekerja(kerjakan, std::move(janji), 10);

    std::cout << "menunggu hasil pekerja\n";
    std::cout << "jumlah kuadrat 1 sampai 10 = " << hasil.get() << "\n";

    pekerja.join();
    return 0;
}
text Keluaran
menunggu hasil pekerja
jumlah kuadrat 1 sampai 10 = 385

Pemanggilan std::move(janji) memindahkan kepemilikan std::promise ke dalam thread pekerja, karena templat ini tidak dapat disalin. Baris pertama tercetak sebelum get(), sedangkan baris kedua baru tercetak setelah pekerja memanggil set_value(), sehingga urutan keluarannya tetap. Apabila pekerja selesai lebih dahulu, get() tetap mengembalikan nilai yang sudah diset tanpa menunggu. Apabila pekerja mengalami error, set_exception() menyampaikan exception yang sama kepada penerima melalui jalur yang mirip.

Gambaran umum thread pool#

Thread pool (kolam thread) adalah sekelompok thread yang dibuat sejak awal dan dipakai bergantian untuk mengerjakan antrean tugas. Tanpa kolam, setiap tugas menanggung biaya pembuatan dan pembongkaran thread sendiri, yang berarti besar bila tugasnya kecil dan jumlahnya banyak. Pustaka standar C++ belum menyediakan thread pool siap pakai, sehingga pilihan yang tersedia adalah membangunnya sendiri dari std::thread, antrean tugas yang dilindungi mutex, dan variabel kondisi seperti std::condition_variable, atau menggunakan library pihak ketiga yang mapan. std::async berada di tengah spektrum tersebut, yaitu implementasinya boleh memakai kembali thread yang sudah ada, tetapi standar tidak menjanjikan bentuk kolam tertentu. Untuk beban kerja yang seragam dan berumur panjang, kolam yang dikendalikan sendiri memberi kendali penuh atas jumlah thread dan urutan penjadwalannya.

Ukuran kolam biasanya dikaitkan dengan jumlah inti yang dilaporkan hardware_concurrency(), dengan penyesuaian menurut sifat tugasnya. Tugas yang menghabiskan waktu prosesor bekerja paling efisien ketika jumlahnya mendekati jumlah inti, sedangkan tugas yang sering menunggu jaringan atau berkas boleh berjumlah lebih banyak dari itu. Antrean tugas di dalam kolam memerlukan perlindungan mutex atau struktur data yang aman untuk beberapa thread, karena beberapa pekerja mengambil tugas dari antrean yang sama. Ketiga komponen tersebut, yaitu thread pekerja, antrean tugas, dan mekanisme peniduran pekerja, menjadi tulang punggung hampir semua implementasi kolam thread.

Kesalahan umum#

  • Memanggil get() dua kali pada std::future yang sama. Setelah pemanggilan pertama, objeknya tidak lagi valid, sehingga perilaku pemanggilan berikutnya tidak terdefinisi. Simpan hasilnya sekali, lalu teruskan nilainya kepada pihak lain.
  • Tidak menyimpan nilai kembalian std::async. Objek std::future yang sementara langsung dimusnahkan dan destruktornya menunggu tugas selesai, sehingga tugas itu berjalan berurutan tanpa keluaran apa pun sebagai petunjuk.
  • Menyimpulkan urutan tugas dari urutan peluncurannya. Dua tugas std::launch::async tidak menjamin urutan penyelesaian, sehingga hasilnya diambil melalui get() masing-masing sebelum dirangkum.
  • Membuat thread baru untuk setiap tugas kecil. Biaya pembuatan thread dapat melampaui pekerjaannya sendiri, sehingga tugas sejenis dikelompokkan atau diserahkan kepada kolam thread.

Ringkasan#

  • std::async menjalankan fungsi sebagai tugas, sedangkan std::future::get() menunggu penyelesaiannya dan mengembalikan hasilnya atau melempar ulang exception tugasnya.
  • Kebijakan std::launch::async memaksa eksekusi pada thread tersendiri, sedangkan std::launch::deferred menundanya sampai get() dipanggil pada thread pemanggil.
  • Pasangan std::promise dan std::future menyalurkan satu nilai antar thread secara satu arah, termasuk exception melalui set_exception().
  • Pustaka standar tidak menyediakan thread pool, sehingga kebutuhan tersebut dijawab dengan implementasi sendiri atau library pihak ketiga.

Referensi#