Chapter Condition Variable menunjukkan cara menunggu kondisi apa pun dengan tiga komponen: mutex, condition_variable, dan predicate. Untuk pola yang lebih spesifik, C++20 menyediakan tiga alat siap pakai yang menyingkirkan sebagian komponen itu: std::counting_semaphore untuk sinyal berbasis penghitung, std::latch untuk titik kumpul sekali pakai, dan std::barrier untuk titik kumpul berulang. Ketiganya menjawab pertanyaan yang sama — thread mana yang boleh melanjutkan dan kapan — tetapi masing-masing lewat gaya sinkronisasi yang berbeda. Chapter ini membahas semuanya lewat empat program teruji yang keluarannya kita jaga tetap deterministik, lalu menutup dengan panduan memilih alat dibanding condition_variable.

Semaphore: Sinyal Berbasis Penghitung #

std::counting_semaphore<LeastMaxValue> (header <semaphore>, C++20) menyimpan satu counter internal yang nilai awalnya kita tetapkan saat konstruksi. Operasi acquire() menurunkan counter sebesar satu; bila counter sudah 0, thread yang memanggilnya tertidur sampai ada release(). Operasi release() menaikkan counter dan membangunkan thread yang menunggu. Berbeda dari notify_one pada condition_variable, sinyal yang dikirim ketika tidak ada penunggu tidak hilang — sinyal tersimpan sebagai kenaikan counter, sehingga thread yang datang belakangan tetap melihatnya.

std::binary_semaphore adalah alias untuk counting_semaphore<1>: counter-nya hanya bernilai 0 atau 1, sehingga cocok sebagai sinyal tunggal yang dipegang lalu dilepas. Alat ini tidak terikat pada thread tertentu — release() boleh dilakukan oleh thread yang berbeda dari pemanggil acquire(). Dua binary_semaphore arah berlawanan membentuk kanal komunikasi bolak-balik antara main() dan pekerja, seperti program berikut.

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

// Dua semaphore arah berlawanan membentuk kanal komunikasi bolak-balik.
// Nilai awal 0 berarti "belum ada sinyal".
std::binary_semaphore sinyalMasuk{0};    // main -> pekerja
std::binary_semaphore sinyalSelesai{0};  // pekerja -> main

std::string pesanKerja;   // main menulis tugas di sini
std::string hasilKerja;   // pekerja menulis hasil di sini

void pekerja() {
    for (int ronde = 1; ronde <= 3; ++ronde) {
        sinyalMasuk.acquire();   // tidur sampai main mengirim sinyal
        hasilKerja = "hasil ronde " + std::to_string(ronde) +
                     " dari tugas: " + pesanKerja;
        sinyalSelesai.release(); // laporkan: hasil sudah siap
    }
}

int main() {
    std::thread w(pekerja);

    for (int ronde = 1; ronde <= 3; ++ronde) {
        pesanKerja = "olah data #" + std::to_string(ronde);
        sinyalMasuk.release();    // kirim tugas ke pekerja
        sinyalSelesai.acquire();  // tunggu pekerja melapor
        std::cout << hasilKerja << "\n";
    }

    w.join();
    return 0;
}

Output yang Dihasilkan:

text
hasil ronde 1 dari tugas: olah data #1
hasil ronde 2 dari tugas: olah data #2
hasil ronde 3 dari tugas: olah data #3

Keluaran tetap sama di setiap eksekusi karena setiap std::cout hanya berjalan setelah sinyalSelesai.acquire() kembali. Bedah barisnya:

  1. Baris 8-9 (dua binary_semaphore bernilai 0): nilai awal 0 berarti “belum ada sinyal”; dua objek diperlukan karena arah komunikasinya berbeda — main ke pekerja dan pekerja ke main.
  2. Baris 16 (sinyalMasuk.acquire()): bila main belum mengirim apa pun, pekerja tertidur total tanpa memakai CPU. Sinyal dari release() di baris 28 tersimpan di counter meskipun pekerja belum sempat sampai ke baris ini.
  3. Baris 17-19 (menulis hasil lalu release): penulisan hasilKerja dilakukan sebelum sinyalSelesai.release(); pasangan release-acquire pada semaphore menjamin penulisan tersebut terlihat utuh oleh main setelah sinyalSelesai.acquire() berhasil — jaminan model memori yang kita bedah pada chapter Model Memori C++.
  4. Baris 28-30 (kirim, tunggu, cetak): main() menulis tugas, melepas sinyalMasuk, lalu tidur di sinyalSelesai.acquire(). Baris std::cout baru dieksekusi setelah pekerja melapor, sehingga ketiga baris hasil tercetak dalam urutan ronde yang pasti.
  5. Baris 33 (w.join()): main() menunggu pekerja berakhir sebelum keluar dari scope.

Batas Counter pada counting_semaphore #

Parameter template LeastMaxValue memasang langit-langit counter: counting_semaphore<3> tidak boleh memiliki counter di atas 3. Namanya perlu dibaca teliti — Least Max Value adalah jaminan MINIMAL, bukan nilai pastinya; fungsi statis max() mengembalikan batas sebenarnya yang selalu minimal sebesar parameter template, dan pada libstdc++ nilai itu persis 3 untuk counting_semaphore<3>. release() yang membuat counter melampaui max() adalah undefined behavior, jadi jumlah release() harus selalu seimbang dengan jumlah acquire() yang mungkin terjadi. Counter internal tidak bisa dibaca langsung; untuk mengamatinya, program berikut sengaja berjalan satu thread dan mencatat nilai logis counter di samping setiap operasi.

cpp
#include <iostream>
#include <semaphore>

int main() {
    // counting_semaphore<3>: counter internal tidak boleh naik di atas 3
    std::counting_semaphore<3> slot(0);   // counter mulai dari 0

    std::cout << "max()            = " << slot.max() << "\n";
    std::cout << "awal             -> counter logis 0\n";

    slot.release();        // naikkan counter sebesar 1
    std::cout << "release()        -> counter logis 1\n";
    slot.release(2);       // naikkan counter sebesar 2
    std::cout << "release(2)       -> counter logis 3\n";

    bool pertama = slot.try_acquire();   // counter > 0: turun tanpa memblokir
    std::cout << "try_acquire()    -> "
              << (pertama ? "berhasil, counter logis 2" : "gagal") << "\n";
    bool kedua = slot.try_acquire();
    std::cout << "try_acquire()    -> "
              << (kedua ? "berhasil, counter logis 1" : "gagal") << "\n";

    slot.acquire();        // counter 1: langsung turun, tidak menunggu
    std::cout << "acquire()        -> counter logis 0\n";

    bool ketiga = slot.try_acquire();    // counter 0: pasti gagal
    std::cout << "try_acquire()    -> "
              << (ketiga ? "berhasil" : "gagal, counter masih 0") << "\n";
    return 0;
}

Output yang Dihasilkan:

text
max()            = 3
awal             -> counter logis 0
release()        -> counter logis 1
release(2)       -> counter logis 3
try_acquire()    -> berhasil, counter logis 2
try_acquire()    -> berhasil, counter logis 1
acquire()        -> counter logis 0
try_acquire()    -> gagal, counter masih 0

Hanya main() yang berjalan pada program ini — justru itu poinnya: perilaku counter murni teramati tanpa gangguan thread lain. Bedah barisnya:

  1. Baris 6 (counting_semaphore<3> slot(0)): counter mulai dari 0; angka 3 memasang langit-langitnya, dan max() pada baris cetak pertama mengonfirmasi batas tersebut.
  2. Baris 11-14 (release() lalu release(2)): release() menaikkan counter satu; release(2) menaikkan dua sekaligus — versi berparameter berguna ketika beberapa “izin” dihasilkan bersamaan.
  3. Baris 16-21 (dua try_acquire): keduanya berhasil karena counter masih positif; setiap keberhasilan menurunkan counter tepat satu.
  4. Baris 23-26 (acquire() lalu try_acquire()): acquire() langsung turun tanpa menunggu karena counter bernilai 1; try_acquire() berikutnya pasti gagal karena standar menentukan try_acquire hanya menurunkan counter bila nilainya di atas 0.

Latch: Titik Kumpul Sekali Pakai #

std::latch (header <latch>, C++20) adalah penghitung menurun yang nilainya ditetapkan sekali saat konstruksi. Thread bisa tidur di wait() sampai counter mencapai 0, sementara thread lain menurunkannya lewat count_down() yang tidak pernah memblokir. Ada juga try_wait() yang memeriksa counter tanpa menunggu, dan arrive_and_wait() yang menggabungkan menurunkan counter sekaligus menunggu. Tidak ada cara menaikkan atau mengatur ulang counter — setelah 0, latch terkunci di kondisi itu; sifat sekali pakai inilah yang membedakannya dari barrier.

Dua pola pemakaian klasik terlihat bersamaan pada program berikut: latch gerbang yang melepas semua pekerja bersamaan, dan latch penghitung selesai yang mengumpulkan laporan seluruh tim.

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

std::latch gerbangMulai{1};      // main melepas semua pekerja sekaligus
std::latch pekerjaSelesai{3};    // main menunggu 3 pekerja melapor
std::vector<std::string> laporan(3, "belum bekerja");

void pekerja(int id) {
    gerbangMulai.wait();   // tidur sampai counter gerbang mencapai 0
    laporan[id] = "pekerja " + std::to_string(id) +
                  " menyelesaikan tugasnya";
    pekerjaSelesai.count_down();   // laporkan: satu pekerja selesai
}

int main() {
    bool sebelumMulai = pekerjaSelesai.try_wait();  // counter masih 3

    std::vector<std::thread> tim;
    for (int id = 0; id < 3; ++id) {
        tim.emplace_back(pekerja, id);
    }

    gerbangMulai.count_down();   // aba-aba: semua pekerja mulai bersamaan
    pekerjaSelesai.wait();       // blokir sampai counter mencapai 0
    bool setelahTunggu = pekerjaSelesai.try_wait();  // counter sudah 0

    for (auto& t : tim) {
        t.join();
    }

    std::cout << "try_wait sebelum aba-aba : "
              << (sebelumMulai ? "true" : "false") << "\n";
    std::cout << "try_wait setelah wait()  : "
              << (setelahTunggu ? "true" : "false") << "\n";
    for (const auto& baris : laporan) {
        std::cout << baris << "\n";
    }
    return 0;
}

Output yang Dihasilkan:

text
try_wait sebelum aba-aba : false
try_wait setelah wait()  : true
pekerja 0 menyelesaikan tugasnya
pekerja 1 menyelesaikan tugasnya
pekerja 2 menyelesaikan tugasnya

Bedah barisnya:

  1. Baris 7-8 (dua latch): gerbangMulai{1} menyimpan satu “pas” aba-aba, sedangkan pekerjaSelesai{3} menuntut tiga laporan sebelum wait() boleh kembali.
  2. Baris 12 (gerbangMulai.wait()): ketiga pekerja memblokir di sini, lalu count_down() dari main di baris 26 melepaskan mereka bersamaan. Kita tidak mencetak apa pun dari dalam pekerja karena urutan bangunnya tidak dapat kita kendalikan.
  3. Baris 13-15 (slot pribadi lalu count_down): setiap pekerja hanya menulis ke laporan[id] miliknya sendiri; penurunan pekerjaSelesai menyinkron dengan kembalinya wait() di main, sehingga isi laporan terbaca aman tanpa mutex.
  4. Baris 19, 27-28 (try_wait sebelum dan sesudah): sebelum aba-aba, counter masih 3 sehingga try_wait() pasti false; setelah wait() kembali, counter pasti 0 sehingga try_wait() pasti true.
  5. Baris 30-32 (join): kebiasaan yang sama sepanjang section ini — kumpulkan hasilnya dulu, cetak setelah join.

Barrier: Titik Kumpul Berulang dengan Fase #

std::barrier (header <barrier>, C++20) mengumpulkan sekelompok thread berukuran tetap pada titik yang sama lalu melepaskan mereka bersamaan, dan berbeda dari latch, barrier siap dipakai lagi untuk putaran berikutnya. Satu siklus disebut fase: setiap peserta datang lewat arrive_and_wait(), dan ketika kuota kedatangan terpenuhi, fungsi penutup fase (completion function) dijalankan tepat satu kali oleh salah satu thread yang datang, baru semua penunggu dilepas. Konstruktor menerima fungsi penutup sebagai argumen opsional; tanpa argumen itu, penutup fase adalah operasi kosong. Ada pula arrive_and_drop(): datang di fase saat ini sekaligus menurunkan kuota fase-fase berikutnya — berguna ketika satu anggota mundur dari tim.

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

// Completion function menulis catatan setiap fase; dipanggil tepat
// sekali per fase oleh satu dari thread yang datang.
std::vector<std::string> catatanFase;

int main() {
    // hasil[id][fase-1]: slot pribadi tiap pekerja per fase
    std::vector<std::vector<std::string>> hasil(
        3, std::vector<std::string>(2, "tidak ikut"));

    auto penutupFase = []() noexcept {
        catatanFase.push_back("Fase " +
                              std::to_string(catatanFase.size() + 1) +
                              " selesai");
    };
    // 3 peserta, tiap fase ditutup oleh penutupFase
    std::barrier titikKumpul(3, penutupFase);

    auto bekerja = [&](int id) {
        if (id == 2) {
            // pekerja 2 ikut fase 1 lalu mundur dari tim
            hasil[2][0] = "pekerja 2 menyelesaikan fase 1";
            titikKumpul.arrive_and_drop();  // kuota fase berikut turun ke 2
            return;
        }
        for (int fase = 1; fase <= 2; ++fase) {
            hasil[id][fase - 1] = "pekerja " + std::to_string(id) +
                                  " menyelesaikan fase " +
                                  std::to_string(fase);
            titikKumpul.arrive_and_wait();  // datang + tunggu fase selesai
        }
    };

    std::vector<std::thread> tim;
    for (int id = 0; id < 3; ++id) {
        tim.emplace_back(bekerja, id);
    }
    for (auto& t : tim) {
        t.join();
    }

    for (const auto& barisPekerja : hasil) {
        for (const auto& sel : barisPekerja) {
            std::cout << sel << "\n";
        }
    }
    for (const auto& catatan : catatanFase) {
        std::cout << catatan << "\n";
    }
    return 0;
}

Output yang Dihasilkan:

text
pekerja 0 menyelesaikan fase 1
pekerja 0 menyelesaikan fase 2
pekerja 1 menyelesaikan fase 1
pekerja 1 menyelesaikan fase 2
pekerja 2 menyelesaikan fase 1
tidak ikut
Fase 1 selesai
Fase 2 selesai

Bedah barisnya:

  1. Baris 16-22 (fungsi penutup lalu konstruksi barrier): lambda noexcept mendorong satu catatan ke catatanFase; CTAD pada std::barrier titikKumpul(3, penutupFase) membuat barrier dengan lambda itu sebagai fungsi penutup fase, yang dijalankan tepat sekali per fase.
  2. Baris 24-30 (jalur pekerja 2): arrive_and_drop() menghitung kedatangannya di fase 1 sekaligus menurunkan kuota fase 2 dari 3 menjadi 2; setelah itu pekerja 2 keluar dan tidak pernah datang lagi.
  3. Baris 31-36 (jalur pekerja 0 dan 1): keduanya berputar dua fase lewat arrive_and_wait(), dan menulis hasil di slot hasil[id][fase-1] milik masing-masing.
  4. Baris 43-54 (join lalu cetak): pencetakan baru dilakukan setelah join; isi catatanFase selalu berurutan karena fungsi penutup fase 2 tidak mungkin berjalan sebelum penunggu fase 1 dilepas.

Memilih Alat yang Tepat #

Tabel berikut merangkum karakter keempat alat sinkronisasi yang sudah kita kenal di section ini:

AlatPakai ulangButuh mutexKekuatan utamaSkenario yang cocok
condition_variableYaYa (unique_lock)Menunggu predicate apa pun atas state bersamaAntrean kerja dengan syarat gabungan, tanda selesai produksi
counting_semaphoreYaTidak (untuk sinyal murni)Sinyal terhitung dan tersimpan di counterMembatasi jumlah akses ke sumber daya, handshake ringan
latchTidakTidakMenunggu N kejadian hingga counter 0Gerbang mulai bersama, penghitung tugas selesai
barrierYaTidakRondi berulang plus fungsi penutup faseAlgoritma iteratif yang melewati fase per tim thread

Aturan memilihnya mengikuti bentuk persoalan. Bila syarat menunggu berupa predicate bebas — antrean tidak kosong ATAU produksi sudah selesai — condition_variable tetap alat yang tepat, karena latch dan barrier hanya mengenal counter, dan semaphore hanya mengenal jumlah. Bila yang dibutuhkan sinyal murni berbasis hitungan tanpa state bersama, semaphore lebih ringkas dan sering lebih cepat; cppreference menyebutnya alternatif condition_variable yang lazim untuk keperluan sinyal. Bila kita menunggu N kejadian hanya sekali, pilih latch; bila kelompok thread yang sama berkumpul berulang-ulang antar-tahap, pilih barrier.

Kita kini memegang pas lengkap primitif sinkronisasi: mutex untuk proteksi data, condition_variable untuk menunggu kondisi apa pun, semaphore untuk sinyal terhitung, latch untuk titik kumpul sekali pakai, dan barrier untuk ronde berulang. Primitif-primitif ini menjadi bahan baku pola yang lebih besar — thread pool misalnya, membangunkan pekerjanya dengan semaphore seperti yang kita praktikkan di awal chapter. Jaminan kenapa penulisan sebelum release() pasti terlihat setelah acquire() tanpa mutex? Jawaban formalnya ada di chapter Model Memori C++ — setelah kita mampir dulu ke std::atomic.

Referensi: cppreference — std::counting_semaphore , cppreference — std::latch , cppreference — std::barrier .