34.5 semaphore, latch, barrier és szinkron stream

A C++20 koordinációs eszközei – a <semaphore>, <latch>, <barrier> és <syncstream> – a mutex és condition variable fölötti, gyakori mintákra szabott absztrakciók. Nem helyettesítik a memóriamodellt, és nem adnak automatikus cancellationt vagy fairness-t: a helyes használatot a hívónak kell szerződésként betartania. Ez a rész a RAII lockok, condition variable és wait predicate mintájára épít, a promise, future, async és task eredmény felé pedig ott kapcsolódik, ahol a várakozás eredménye és a task élettartama válik fontossá. A <barrier>, <latch>, <semaphore> és <syncstream> könyvtári támogatása compiler- és standard library verziófüggő; a konkrét buildprofilban ellenőrizni kell. Az itteni példák C++20-at feltételeznek, és nem állítják, hogy fordításuk vagy futásuk ebben a szövegben megtörtént.

34.5.1 Közös szerződés és eszközválasztás

Mind a négy eszköz koordinációt végez: egy művelet láthatóvá tesz egy korábbi állapotot egy másik szálnak. A szemafor release művelete szinkronizál azzal az acquire-rel, amely az inkrementált számlálót megfigyeli; a latch utolsó count_downja szinkronizál a wait visszatérésével; a barrier fázisának completionje szinkronizál a fázisra várakozók visszatérésével; az osyncstream::emit pedig a mögöttes streambuf felé továbbított rekord koherens átvitelét biztosítja. Ez nem azt jelenti, hogy a primitívek minden mellékhatást „varázsolnak” – a programozónak kell biztosítania, hogy a megosztott adatot csak a megfelelő szinkronizációs pont után olvassa.

Eszköz Ismételhető Tipikus használat Fő élettartam-kockázat
counting_semaphore igen kapacitáskorlát, engedély, jelzés túlrelease; várakozó a destruktorban
latch nem egyszeri startkapu, inicializálás befejezése count_down túllépés; várakozó a destruktorban
barrier igen ismétlődő fázisok szinkronizálása kimaradó résztvevő; completion szerződésszegés
osyncstream igen logrekord-koherencia a mögöttes stream élettartama; megosztott objektum

Közös szabály, hogy a semaphore, latch és barrier destruktorának előfeltétele: ne legyen blokkolt várakozó szál. Ennek megsértése nem definiált viselkedés; a gyakorlatban holtpont, összeomlás vagy csendes adatkorrupció lehet. A primitív nem tartja életben a várakozót, és a várakozó nem tartja életben a primitívet.

Bizonyításvázlat (vázlat). Ha egy szál a release/count_down/arrive műveletet hajtja végre, majd egy másik szál sikeresen visszatér a megfelelő várakozásból, a szabvány synchronization relationje miatt az előbbi szál előtti írások láthatók az utóbbi szálban. Ezért a „jelző” primitívek nem csupán időzítésre, hanem publikálásra is használhatók; a megosztott adatot azonban továbbra is védeni kell az egyidejű írásoktól.

34.5.2 Szemafor: kapacitás és engedély

A std::counting_semaphore<LeastMaxValue> egy nem másolható számlálót tart. Az acquire() addig blokkol, amíg a számláló nullánál nagyobb, majd csökkenti azt; a try_acquire(), try_acquire_for() és try_acquire_until() nem blokkoló, illetve időkorlátos változatok. A release(update = 1) növeli a számlálót, és felébreszthet várakozókat. A std::binary_semaphore a counting_semaphore<1> aliasa.

A release előfeltétele, hogy a növelés ne vigye a számlálót a max() fölé: 0≤update≤max()−counter0 \le update \le max() - counter. A max() legalább a template paraméter, de implementációfüggően nagyobb is lehet. Fontos következmény: a konstruktor kezdőértéke nem felső korlát. Ha counting_semaphore<2> s(2)-t hozunk létre, attól még a max() nagyobb lehet, és a „legfeljebb kettő engedély” invariantot a programnak kell betartania, nem a szemafor. A túlrelease nem „csak” logikai hiba: előfeltétel-sértés, tehát nem definiált viselkedés.

A szemafornak nincs tulajdonosa abban az értelemben, mint a mutexnek: a release-et más szál is végrehajthatja, mint amelyik az acquire-t. Ez alkalmas jelzésre és kapacitáskorlátra, de nem nyújt kölcsönös kizárást „tulajdonosi” garanciával. Kölcsönös kizáráshoz továbbra is a std::mutex a természetes választás; a bináris szemafor inkább ping-pong jelzésre vagy esemény jelzésére való. C-ben a POSIX sem_t hasonló szerepet tölt be, de a sem_destroy élettartam-feltétele és a névvel ellátott szemaforok kezelése külön figyelmet igényel.

Leállításnál a counting_semaphore nem kínál stop_token túlterhelést. A try_acquire_for időkorlátos ciklusa adhat korlátos reakcióidőt, de ez polling: a stop kérés és a várakozás vége között legfeljebb a választott időköz telik el. Megszakítható várakozásra a C++20 condition_variable_any stop_token-es wait változata a természetes eszköz; a szemaforral épített „stop-aware” kaput külön kell megtervezni.

34.5.3 Latch: egyszeri kapu

A std::latch egy egyszer használható leszámláló. A konstruktorban megadott számlálót a count_down(update = 1) csökkenti; a wait() addig blokkol, amíg a számláló nullává nem válik; a try_wait() nem blokkoló ellenőrzés; az arrive_and_wait(update = 1) a count_down és a wait összevonása. A count_down előfeltétele, hogy a frissítés ne haladja meg az aktuális számlálót. Ha többször csökkentenénk nullánál, az előfeltétel-sértés.

A latch tipikus használata a startkapu: a fő szál létrehoz egy std::latch start(1) objektumot, a munkások start.wait()-tel várakoznak, majd a fő szál start.count_down() hívásával egyszerre engedi el őket. Ugyanígy használható „minden rész inicializálva” kapuként: nn résztvevő count_down után a várakozó továbblép. A latch nem resetelhető; ismétlődő fázisokhoz barrier kell. A latch élettartama alatt a számláló monoton csökken, és nulla után már nem nő.

34.5.4 Barrier: ismétlődő fázisok

A std::barrier egy fix résztvevőszámú, ismételhető fáziskapu. A konstruktor megadja a várt érkezők számát és opcionálisan egy completion függvényt. A résztvevők arrive()-tal érkeznek, amely egy arrival_token-t ad vissza, majd wait(token)-tel várnak a fázis befejezésére; az arrive_and_wait() a kettőt együtt végzi. Amikor az utolsó résztvevő is megérkezik, a completion függvény lefut, majd a várakozók visszatérnek, és kezdődhet a következő fázis.

A completion szerződése szigorú: pontosan a fázis utolsó érkezője futtatja, a várakozók csak utána térnek vissza, nem dobhat kivételt, és nem hívhatja a barrier tagfüggvényeit. A gyakorlatban rövid, nem blokkoló művelet legyen; a „ completionben mindenki helyett mindent megcsinálok” minta könnyen szűk keresztmetszet vagy holtpont forrása. A completion alatt a barrier már a következő fázisra készül, ezért abból a barrierre hivatkozni különösen veszélyes.

Az arrive_and_drop() a hívó szálat véglegesen kiveszi a következő fázisokból: az aktuális fázisban még érkezik, de a várt résztvevőszám csökken. Csak olyan szál hívhatja, amely részt vesz az aktuális fázisban, és utána már nem használhatja a barriert. Ha egy résztvevő egyszerűen kilép, és nem hív arrive_and_drop-ot, a többi szál örökre várhat. A barrier nem tudja kívülről „leszállítani” a várt számot; a drop az egyetlen mechanizmus. Leállításnál ezért vagy minden résztvevőnek végig kell mennie egy utolsó fázison, vagy a dropot gondosan, közös protokoll szerint kell használni.

34.5.5 osyncstream: rekordkoherencia

A std::osyncstream (pontosabban basic_osyncstream<char>) egy puffert tart, és az emit() híváskor – illetve a destruktorban – a puffert egybefüggő rekordként továbbítja a mögöttes streambuf felé. A cél, hogy a több szálból írt logüzenetek ne csússzanak egymásba. Nem globális sorrendet ad: két rekord sorrendje attól függ, melyik emit éri el előbb a mögöttes streamet. A rekordkoherencia csak a syncstreamen keresztül történő emitokra vonatkozik; a közvetlen std::cout-írásokkal való keveredést külön kell kezelni.

A legegyszerűbb minta a rekordonkénti ideiglenes objektum:

void log_line(const char* who, int round) {
    std::osyncstream(std::cout) << who << " round " << round << '\n';
}

Az ideiglenes osyncstream a teljes kifejezés végén megsemmisül, és ekkor emitál. Egy szálon belül létrehozott, hosszabb életű osyncstream is használható, de egy objektumot nem szabad több szál között megosztani: az operator<< és a belső puffer nem válik thread-safe-é attól, hogy az emit szinkronizált. A mögöttes streamnek és streambufnak túl kell élnie az osyncstream-et; ha az osyncstream egy lokális std::ostringstream-re mutat, amely már megsemmisült, a destruktor emitja dangling streambufra ír. Globális osyncstream esetén a statikus megsemmisítési sorrend külön kockázat, ezért a gyakorlatban lokális vagy szálonkénti példányt érdemes használni. Ha a mögöttes stream kivételre van kapcsolva, az emit dobhat; a destruktorban ez terminate-hez vezethet, ezért a hibakezelést ne a destruktorra bízzuk.

34.5.6 Várakozó élettartama, leállítás és fairness

A legfontosabb élettartam-szabály: a semaphore, latch és barrier destruktora előtt minden várakozónak ki kell lépnie. Ez tipikusan azt jelenti, hogy a tulajdonos szál joinolja a munkásokat, vagy egy külön leállítási fázisban mindenkit beenged. A std::jthread automatikus joinja önmagában nem old meg mindent: a stop kérés kooperatív, és nem szakít meg egy semaphore.acquire() vagy latch.wait() hívást. Ha a munkás ezekben blokkol, a jthread destruktora joinol, és a program holtpontba kerülhet. A stopot ezért a várakozásba kell beépíteni: condition variable esetén a stop_token-es wait túlterheléssel, szemafor esetén időkorlátos try_acquire_for ciklussal, barrier esetén pedig explicit leállítási fázissal vagy arrive_and_drop-pal.

A fairness nem garantált. A release, count_down és arrive által felébresztett várakozó kiválasztása nem specifikált; a primitívek nem FIFO-k, és nem biztosítanak prioritást. Éhezés előfordulhat, különösen sok várakozó és ritka release esetén. Ha a sorrendiség üzleti követelmény, azt külön adatszerkezettel és mutex/CV párossal kell megvalósítani, nem a primitív „fairnek” feltételezett viselkedésére bízva.

34.5.7 Kidolgozott példa

A következő példa négy munkást indít, egy latch startkapuval engedi el őket, egy kétengedélyes szemaforral korlátozza az egyidejű „IO” szakaszt, egy barrierrel pedig három fázisban szinkronizál. A naplózás rekordonkénti osyncstream-mel történik. A tulajdonos a main: a latch, a szemafor, a barrier és a std::vector<std::thread> a main élettartamában él; a munkások referenciákat kölcsönöznek. Invalidáló művelet a primitívek destruktora, ha várakozó van; a hiba kimenete holtpont vagy nem definiált viselkedés. A példa feltételezi, hogy a szálak létrehozása sikerül; termelési kódban a kivételbiztos joinról külön kell gondoskodni.

// sync_demo.cpp — C++20
#include <atomic>
#include <barrier>
#include <chrono>
#include <cstddef>
#include <cstring>
#include <exception>
#include <iostream>
#include <latch>
#include <semaphore>
#include <stdexcept>
#include <syncstream>
#include <thread>
#include <vector>

namespace {
void log_line(int who, int round) {
    std::osyncstream(std::cout) << "worker " << who << " round " << round << '\n';
}

struct SlotGuard {                          // a szemafor kivételes úton is felszabadul
    std::counting_semaphore<2>& s;
    explicit SlotGuard(std::counting_semaphore<2>& sem) : s(sem) { s.acquire(); }
    ~SlotGuard() { s.release(); }
};

struct StartGuard {                         // a főszál unwindingja esetén is elengedi
    std::latch& start;                      // a workereket, de futás nélkül
    std::atomic<bool>& abort;
    bool released = false;
    void release() { released = true; start.count_down(); }
    ~StartGuard() { if (!released) { abort = true; start.count_down(); } }
};
} // namespace

int main(int argc, char** argv) {
    constexpr int workers = 4;
    constexpr int rounds = 3;
    const bool worker_fails = argc > 1 && std::strcmp(argv[1], "worker-fail") == 0;
    const bool main_fails = argc > 1 && std::strcmp(argv[1], "main-fail") == 0;

    std::latch start(1);
    std::atomic<bool> abort_run{false};
    std::counting_semaphore<2> io_slots(2);
    std::barrier round_done(workers, []() noexcept {
        std::osyncstream(std::cout) << "-- phase complete --\n";
    });
    std::vector<std::exception_ptr> errors(workers);  // szálanként saját hely

    try {
        std::vector<std::jthread> pool;     // unwinding esetén is join
        pool.reserve(workers);
        StartGuard guard{start, abort_run}; // a pool UTÁN: előbb fut le
        for (int i = 0; i < workers; ++i) {
            pool.emplace_back([&, i] {
                start.wait();
                if (abort_run) return;      // indítás meghiúsult: nincs munka
                try {                       // a worker kivételhatára
                    for (int r = 1; r <= rounds; ++r) {
                        {
                            SlotGuard slot(io_slots);
                            std::this_thread::sleep_for(std::chrono::milliseconds(5));
                            if (worker_fails && i == 2 && r == 2)
                                throw std::runtime_error("disk error");
                            log_line(i, r);
                        }
                        round_done.arrive_and_wait();
                    }
                } catch (...) {
                    errors[i] = std::current_exception();
                    round_done.arrive_and_drop();   // a többiek ne várjanak rá
                }
            });
        }
        if (main_fails) throw std::runtime_error("setup failed");
        guard.release();
    } catch (const std::exception& e) {     // ide csak a főszál hibája jut
        std::cerr << "main: " << e.what() << '\n';
        return 1;
    }

    int failed = 0;                          // a join után az errors olvasható
    for (int i = 0; i < workers; ++i) {
        if (!errors[i]) continue;
        ++failed;
        try { std::rethrow_exception(errors[i]); }
        catch (const std::exception& e) {
            std::cerr << "worker " << i << ": " << e.what() << '\n';
        }
    }
    return failed == 0 ? 0 : 1;
}

Fordítás GNU környezetben:

g++ -std=c++20 -O2 -pthread -Wall -Wextra -Wpedantic sync_demo.cpp -o sync_demo

A -pthread a szálkezeléshez szükséges. A példa nem használ stopot: a leállítás a ciklus természetes végével történik. A szemafor nem fair, ezért a példa nem garantálja, hogy minden munkás ugyanannyi időt kap.

A hibakezelésnél két külön határt kell megtervezni.

./sync_demo               # normál futás
./sync_demo worker-fail   # a 2-es worker a 2. körben dob
./sync_demo main-fail     # a főszál az indítás előtt dob

Buildjegyzőkönyv (lab-gcc profil). A program figyelmeztetés nélkül fordult. Normál futásban 12 körsor és 3 fáziszárás, kilépési kód 0. A worker-fail esetben a 2-es worker az 1. kör után kiesik: 10 körsor és 3 fáziszárás után a főszál worker 2: disk error üzenettel, 1-es kóddal lép ki, holtpont és terminate nélkül. A main-fail esetben main: setup failed, 0 körsor, 1-es kód, holtpont nélkül. Mindhárom eset ThreadSanitizer (setarch -R) alatt is figyelmeztetés nélkül futott.

34.5.8 Tipikus hibák

34.5.9 Feladatok

34.5.9.1 1. feladat: eszközválasztás

Válaszd ki a megfelelő C++20 eszközt, és indokold a döntést. Mindhárom esetben nevezd meg azt is, miért nem a másik kettőt választanád.

  1. Nyolc munkásnak meg kell várnia, hogy a konfiguráció betöltődjön; ez egyszer történik meg a program indulásakor.

  2. Legfeljebb három párhuzamos adatbázis-kapcsolat lehet egyszerre.

  3. Négy szál tíz iterációban dolgozik, és minden iteráció végén mindenkinek meg kell várnia a többieket, majd jöhet a következő iteráció.

34.5.9.2 2. feladat: stop-aware szemafor

Írj egy acquire_stop_aware függvényt, amely egy std::counting_semaphore<N> engedélyét próbálja megszerezni, de std::stop_token kérés esetén legfeljebb egy rövid időkorlátnyi késéssel visszatér. Készíts hozzá olyan RAII engedélyőrt, amely a már megszerzett engedélyt „adoptálja”, és a destruktorában release-el. Magyarázd meg, miért nem lehet a semaphore.acquire()-t egyszerűen megszakítani.

34.5.9.3 3. feladat: diagnosztika

A következő részlet három hibát tartalmaz. Azonosítsd őket, mondd meg, melyik szabályt sértik, és javítsd őket.

// részlet, nem önálló program
std::latch ready(2);
ready.count_down(3); // (A)

std::barrier b(2);
// az egyik résztvevő szálban:
b.arrive_and_drop();
b.arrive_and_wait(); // (B)

std::osyncstream make_log() {
    std::ostringstream oss;
    return std::osyncstream(oss); // (C)
}

34.5.10 Megoldások

34.5.10.1 1. megoldás

  1. std::latch, például std::latch ready(1): a munkások wait()-tel várnak, a betöltő count_down()-nal nyit. Azért nem barrier, mert a kapu egyszeri, és nincs ismétlődő fázis; azért nem szemafor, mert nem kapacitást osztunk, hanem egy egyszeri eseményre várunk.

  2. std::counting_semaphore<3> slots(3): az acquire()/release() pár adja a kapacitáskorlátot. Azért nem latch, mert a művelet ismétlődik; azért nem barrier, mert a résztvevők nem fázisonként szinkronizálnak, hanem erőforrásért versenyeznek.

  3. std::barrier rounds(4): minden iteráció egy fázis, a résztvevők száma fix. Azért nem latch, mert a latch nem resetelhető; azért nem szemafor, mert nem engedélyt osztunk, hanem mindenkinek meg kell várnia mindenkit.

34.5.10.2 2. megoldás

#include <chrono>
#include <cstddef>
#include <mutex>
#include <semaphore>
#include <stop_token>

template <std::ptrdiff_t N>
bool acquire_stop_aware(std::counting_semaphore<N>& sem, std::stop_token st) {
    while (!st.stop_requested()) {
        if (sem.try_acquire_for(std::chrono::milliseconds(10))) {
            if (st.stop_requested()) {   // stop-prioritás: a közben megszerzett
                sem.release();           // engedélyt visszaadjuk
                return false;
            }
            return true;
        }
    }
    return false;
}

template <std::ptrdiff_t N>
class sem_guard {
public:
    explicit sem_guard(std::counting_semaphore<N>& s) : s_(&s) { s_->acquire(); }
    sem_guard(std::counting_semaphore<N>& s, std::adopt_lock_t) noexcept : s_(&s) {}
    ~sem_guard() { if (s_) s_->release(); }

    sem_guard(const sem_guard&) = delete;
    sem_guard& operator=(const sem_guard&) = delete;
    sem_guard(sem_guard&& other) noexcept : s_(other.s_) { other.s_ = nullptr; }

private:
    std::counting_semaphore<N>* s_;
};

A stop-aware ág használata:

if (acquire_stop_aware(io_slots, st)) {
    sem_guard<2> g(io_slots, std::adopt_lock);
    // ...
}

A 10 ms a try_acquire_for névleges várakozási ideje, vagyis a stop-ellenőrzés polling-intervalluma, nem szigorú felső korlát: a szabvány megengedi, hogy az időkorlátos várakozás az ütemezés és az erőforrás-versengés miatt később térjen vissza ([C++20] [thread.req.timing]), és a visszatérés után a szálnak még ütemeződnie is kell. Szigorú leállási határidőt csak a platform ütemezési garanciáival együtt lehet vállalni. A stop és az engedélyszerzés egyidejűségekor két kimenet megengedett: ha a szerzés győz, a függvény igazat ad, és a hívó a guarddal visszaadja az engedélyt; ha stop-prioritás kell, a fenti változat a megszerzés után még egyszer megnézi a stopot, és a már megszerzett engedélyt visszaadja. Az engedélyszám mindkét kimenetnél megmarad. A semaphore.acquire() azért nem szakítható meg, mert a szemafor API-jának nincs stop_token paramétere; a szál blokkolása a primitív belső várakozásában történik, amelyet kívülről nem lehet „kiszakítani”. Az adoptáló konstruktor elengedhetetlen: ha a guard konstruktora újra acquire-t hívna, a már megszerzett engedély mellé egy másodikat is foglalnánk. Ellenőrzési feltétel: stop nélkül a függvény a release után igazzal tér vissza; stop kérés után — a névleges intervallum és az ütemezési késés után — hamissal, és a sikeres ágban a guard pontosan egyszer release-el. A teszt ne a 10 ms-os határidőt mérje, hanem az engedélymegmaradást.

Buildjegyzőkönyv (lab-gcc profil). Egy 2000 iterációs versenyteszt (counting_semaphore<1>, a release és a request_stop mindkét sorrendben) minden iteráció után pontosan egy engedélyt talált a szemaforban. Normál buildben mind a 2000 eset a stop ágra futott, TSan alatt (setarch -R) 750 megszerzés és 1250 stop — mindkét megengedett kimenet előfordult, engedély nem veszett el.

34.5.10.3 3. megoldás

  1. A ready.count_down(3) előfeltétele, hogy a frissítés ne haladja meg a számlálót; itt a számláló 2, a frissítés 3. Ez előfeltétel-sértés, nem definiált viselkedés. Javítás: ready.count_down(2), vagy ha három külön esemény van, std::latch ready(3).

  2. Az arrive_and_drop() után a szál véglegesen kimarad a barrierből, és nem hívhatja újra a barrier tagfüggvényeit. A b.arrive_and_wait() utána szerződésszegés. Javítás: a drop után a szál ne használja a barriert; ha még részt kell vennie, ne dropoljon, hanem normál arrive_and_wait-tel zárja le a fázist, és a leállást külön protokollal kezelje.

  3. A visszatérő osyncstream a lokális oss streambufjára mutat, amely a függvény végén megsemmisül. A visszatérés után az osyncstream destruktorának emitja dangling streambufra ír. Javítás: az oss-nek túl kell élnie az osyncstream-et, például a hívóban hozzuk létre mindkettőt, vagy a függvény előbb emitáljon, majd a puffert std::string-ként adja vissza. Ellenőrzési feltétel: az osyncstream élettartama soha ne haladja meg a mögöttes stream/streambuf élettartamát, és a destructor ne fusson olyan objektumon, amelynek a streambufja már megszűnt.

34.5.11 Összefoglaló

A counting_semaphore kapacitást és engedélyt modellez; a latch egyszeri, a barrier ismétlődő fáziskapu; az osyncstream a logrekordok koherenciáját adja. Egyik sem ad automatikus cancellationt vagy fairness-t, és mindegyiknél kritikus a várakozók élettartama: a destruktor előtt nem maradhat blokkolt várakozó. A stopot a várakozásba kell beépíteni – condition variable esetén stop_token-nel, szemafor esetén időkorlátos próbálkozással, barrier esetén explicit leállítási fázissal. A primitívek szinkronizációs pontot adnak, de a megosztott adat tulajdonjogát és a hozzáférési szerződést továbbra is a programnak kell biztosítania.