C++ futures deel 1: Het mentale model

Het idee is dat al deze klassen zijn gebouwd op basis van één enkele onbenoemde klasse die de C++-standaardbibliotheek de "state" (toestand) noemt. Om het overzichtelijk te houden, zal ik het in deze discussie de "bron" (source) noemen, aangezien dit het onderdeel is dat de waarde produceert. Het gedraagt zich vergelijkbaar met een C# task completion source.

Conceptueel ondersteunt de bron de volgende bewerkingen:

  • Een waarde instellen.
  • Een uitzondering (exception) instellen.
  • Wachten tot een waarde of uitzondering is ingesteld.
  • De waarde ophalen of de uitzondering opnieuw gooien (rethrow).

Hieronder volgt een vereenvoudigde versie die geen referenties ondersteunt.

template<typename T>
struct source
{
private:
    enum what {
        nothing,
        value,
        exception,
        consumed,
    } d; // discriminant

    union {
        T value;
        std::exception_ptr ex;
    } r; // resultaat

    std::mutex m;
    std::condition_variable cv;

De discriminant vertelt ons wat er in de union staat: niets (omdat er nog niets is ingesteld), een waarde, een uitzondering, of niets (omdat de waarde al is geconsumeerd).

De mutex garandeert thread-safe toegang tot de andere leden, en de conditievariabele stelt ons in staat om te wachten tot iemand een waarde of uitzondering instelt.

    void must_be_empty() {
        if (d != what::nothing) {
            throw std::future_error(future_errc::promise_already_satisfied);
        }
    }

Je kunt de set-methoden alleen aanroepen wanneer de bron leeg is, dus we hebben de controle op "leegte" in een aparte functie geplaatst.

public:
    void set_value(T& v) {
        std::lock_guard g(m);
        must_be_empty();
        new(&r.value) T(v); // "r.value = v"
        d = what::value;
        cv.notify_all();
    }

Je kunt een waarde of uitzondering slechts één keer instellen. Als er al een waarde of uitzondering aanwezig is, geeft het systeem een foutmelding. Anders wordt de waarde opgeslagen en worden alle wachtende threads op de hoogte gesteld.

Ik zal de overload die T&& gebruikt niet uitwerken; dit werkt op dezelfde manier, maar dan met een std::move(). Er is ook een setvalueatthreadexit, maar dat is een onderwerp voor een andere dag.

In plaats van een waarde kun je ook een uitzondering instellen:

    void set_exception(std::exception_ptr ex) {
        std::lock_guard g(m);
        must_be_empty();
        new(&r.ex) std::exception_ptr(ex); // "r.ex = ex"
        d = what::exception;
        cv.notify_all();
    }

Dit werkt in essentie hetzelfde als set_value, behalve dat we een uitzondering opslaan.

Nu we weten hoe waarden en uitzonderingen in de bron terechtkomen, kijken we naar het wachten tot de bron niet meer leeg is.

    void wait() {
        std::lock_guard g(m);
        while (d == what::nothing) {
            cv.wait(g);
        }
    }

    void wait_for(duration dur) {
        std::lock_guard g(m);
        cv.wait_for(g, dur, [&] { return d != what::nothing; });
    }

Je kunt onbeperkt wachten of wachten gedurende een specifieke tijdsduur. De waitfor-methode is de reden waarom we überhaupt een conditievariabele gebruiken. Zonder deze functie hadden we de wait- en notifyall-methoden van std::atomic kunnen gebruiken.

Het laatste onderdeel is het ophalen van de waarden.

    void can_get() {
        if (d == what::consumed) {
            throw std::future_error(future_errc::future_already_retrieved);
        }
        if (d == what::exception) {
            std::rethrow_exception(r.ex);
        }
    }

Ongeacht hoe we de waarde willen ophalen, is het niet toegestaan om een waarde op te vragen die al is geconsumeerd. Daarnaast wordt de uitzondering opnieuw gegooid als het resultaat een uitzondering is.

    T consume() {
        wait();
        std::lock_guard g(m);
        can_get();
        d = what::consumed;
        return std::move(r.v);
    }

    const T& get() {
        wait();
        std::lock_guard g(m);
        can_get();
        return r.v;
    }
};

Je kunt de waarde ophalen via get(), wat een constante referentie naar de onderliggende opslag retourneert. Of je kunt de waarde consumeren via consume(), wat de waarde uit de onderliggende opslag verplaatst en de status markeert als geconsumeerd, zodat niemand de waarde opnieuw kan ophalen of consumeren (omdat deze er niet meer is).

We kunnen nu onze andere objecten en functies bouwen op basis van deze klasse. Dat doen we de volgende keer.

***

Extra opmerkingen: Het schrijven van de destructor laat ik als oefening over aan de lezer. Het is niet moeilijk, maar wel vervelend vanwege de union.

Bonus opmerking: Ik schrijf deze serie omdat ik altijd vergeet hoe het precies werkt, en het opschrijven helpt me om het te onthouden.