21.2 RAII wrapper file descriptorhoz és lockhoz

Az előző részben a C cleanup-szerződéseit néztük meg: minden megszerzett erőforráshoz pontosan egy felszabadító út tartozik, és ezt az utat goto cleanup címkék, korai return-ök és hibakódok között kézzel kell összehangolni. A C++ válasza erre nem egy újabb könyvtár, hanem a nyelv élettartam-szabálya: ha az erőforrást egy objektum birtokolja, akkor a felszabadítás az objektum destruktorában fut le, méghozzá determinisztikusan, akkor is, ha a vezérlés kivétel miatt hagyja el a hatókört. Ezt a mintát nevezzük RAII-nak (Resource Acquisition Is Initialization; erőforrás-életciklus kötése objektum-élettartamhoz). Ebben a részben ezt a szerződést konkretizáljuk két tipikus DevOps-erőforrásra: a file descriptorra és a lockra. Mindkettőnél külön nevezzük meg a tulajdonost (owner) és a megfigyelőt (observer), mert a legtöbb termelési hiba nem a destruktorban keletkezik, hanem ott, hogy egy nem tulajdonló API-t tulajdonosként kezelünk.

21.2.1 Az invariáns: birtoklás, sentinel, owner és observer

Egy RAII wrapper definíciója pontosítva: olyan osztály, amelynek minden létező példánya legfeljebb egy erőforrást birtokol, a birtoklást a konstruktor szerzi meg, a destruktor adja fel, és a példány nem másolható, csak mozgatható. A „legfeljebb egy” megfogalmazás azért fontos, mert megengedünk egy explicit üres állapotot is, amit egy sentinel érték jelöl. File descriptor esetén ez a -1, mert a 0 érvényes descriptor (a standard bemenet). Ha valaki 0-t használ sentinelnek, a wrapper a destruktorában lezárja a szabványos bemenetet — ez a klasszikus hiba, és a tünete az, hogy a program egy későbbi read-nél EBADF-et kap.

Az osztály invariánsa tehát: minden konstruált példányra igaz, hogy fd_ == -1, vagy fd_ >= 0 és a descriptor kizárólag ezen a példányon keresztül birtokolt. Az invariáns a konstruktor után áll fenn, és minden publikus művelet után ismét fenn kell állnia; ez a feltétel a release, reset és mozgató műveletek szerződését is meghatározza.

Az owner/observer megkülönböztetés nyelvi szinten nem kényszeríthető ki, ezért dokumentálni kell. A void write_all(int fd, std::string_view data) alakú paraméter observer: a függvény nem zárja be a descriptort, és nem is tárolhatja el a híváson túl. A unique_fd alakú paraméter owner-átadás; ha egy függvény unique_fd&&-t kap, abból a hívó tudja, hogy a hívás után az ő változója üres. Keverni a kettőt (egy int fd tagba elmenteni egy owner descriptor értékét) az élettartam-szerződés megsértése, és pontosan ez az a hiba, amit a smart pointerek sem fognak helyettünk megoldani.

21.2.2 A megszerzés: konstruktor, factory és hibaút

Két konstruktor-szerep létezik, és érdemes szétválasztani őket. Az átvevő konstruktor egy már megszerzett, nyers handlet kap, és feltétel nélkül átveszi a tulajdonjogot; ez nem hibázhat, ezért noexcept. A megszerző konstruktor maga hívja a rendszerhívást, és ez hibázhat — márpedig a konstruktornak nincs visszatérési értéke. Három szokásos megoldás van: kivételt dobni, factory függvényt írni std::optional/std::expected visszatéréssel, vagy kétfázisú konstrukciót használni (ez utóbbi épp az, amit el akarunk kerülni).

A lenti példában a unique_fd(int) explicit átvevő konstruktor, az open_readonly pedig factory, amely std::optional<unique_fd>-dal jelzi a sikert. A C++20 alapban a std::expected még nem elérhető (az egy későbbi verzió eleme, ezért a laborkörnyezetben ellenőrizni kell), így az optional a hordozható választás; a hibaokot (errno) a hívó a factory visszatérése után olvassa ki. Fontos, hogy a factory noexcept: a ::open és az optional konstrukció nem dob, így a „sikertelenség” nem kivételként, hanem értékként jelenik meg.

#include <cerrno>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <optional>
#include <utility>

#include <fcntl.h>
#include <unistd.h>

class unique_fd {
public:
    unique_fd() noexcept = default;                 // üres, nem birtokol
    explicit unique_fd(int fd) noexcept : fd_(fd) {} // átveszi a tulajdonjogot

    unique_fd(const unique_fd&) = delete;
    unique_fd& operator=(const unique_fd&) = delete;

    unique_fd(unique_fd&& other) noexcept
        : fd_(std::exchange(other.fd_, -1)) {}

    unique_fd& operator=(unique_fd&& other) noexcept {
        if (this != &other) {
            reset(other.release());
        }
        return *this;
    }

    ~unique_fd() { reset(); }

    [[nodiscard]] int get() const noexcept { return fd_; }
    [[nodiscard]] bool valid() const noexcept { return fd_ >= 0; }
    explicit operator bool() const noexcept { return valid(); }

    int release() noexcept { return std::exchange(fd_, -1); }

    void reset(int fd = -1) noexcept {
        if (fd_ >= 0) {
            (void)::close(fd_); // hiba nem továbbítható, ezért eldobjuk
        }
        fd_ = fd;
    }

    void swap(unique_fd& other) noexcept { std::swap(fd_, other.fd_); }

private:
    int fd_ = -1;
};

inline void swap(unique_fd& a, unique_fd& b) noexcept { a.swap(b); }

std::optional<unique_fd> open_readonly(const char* path) noexcept {
    const int fd = ::open(path, O_RDONLY | O_CLOEXEC);
    if (fd < 0) {
        return std::nullopt;
    }
    return unique_fd{fd};
}

21.2.3 Felszabadítás: destruktor és hibajelzési policy

A destruktor alapértelmezésben noexcept, és ez nem stílus kérdése: ha egy destruktorból kivétel távozik a stack unwinding közben, a program std::terminate-mel leáll. Ezért a destruktorban hívott close() hibáját nem lehet és nem is szabad kivételként továbbadni. A gyakorlati policy három dologból áll: (1) a hibát eldobjuk vagy naplózzuk, (2) a close()-t nem ismételjük meg, (3) a hibát ott kell kezelni, ahol még van értelme — vagyis a felszabadítás előtti utolsó írás/flush hívásnál.

A close() ismétlése azért veszélyes, mert a close() után a descriptor szám azonnal újra kiosztható; ha az EINTR hatására megismételt close(fd) egy közben megnyitott másik descriptorra fut, idegen erőforrást zárunk be. A helyes politika: egy kísérlet, az eredmény eldobása, esetleg stderr-re írt diagnosztika. Hasonló a helyzet munmap-nál és fclose-nál: ez utóbbi ráadásul a puffert is flush-olja, tehát a hiba adatvesztést is jelezhet — ezt a hívónak std::fflush + ferror kombinációval kell ellenőriznie, mielőtt a wrapper megsemmisül.

21.2.4 release, reset, swap és move szerződései

A négy művelet pontosan egymásra épül, és érdemes kimondani a feltételeket.

A release() visszaadja a nyers handlet, és a wrappert üres állapotba teszi. Szerződés: a hívó a visszatérés után tulajdonos, neki kell felszabadítania, és a wrapper további használata (a get() kivételével) értelmetlen. A release() nem zárja be az erőforrást — ezt sokan összekeverik a reset()-tel.

A reset(h) lezárja az aktuálisan birtokolt erőforrást, majd átveszi h-t. Feltétel: h vagy érvénytelen sentinel, vagy a hívó által átadott, másutt nem birtokolt handle; reset(fd_) önmagára nem megengedett, mert ilyenkor lezárnánk a descriptor, majd visszatennénk a lezárt értéket az invariáns alá.

A swap tagfüggvény és a mellette álló, azonos névtérben deklarált szabad swap együtt biztosítja, hogy az std::swap helyett ADL-lal a mi hatékony változatunk hívódjon (mindkettő noexcept).

A mozgató konstruktor és mozgató értékadás a birtoklás átadása: a forrás üres lesz. Az értékadásnál az ön-értékadást (a = std::move(a)) a this != &other ellenőrzés védi; enélkül a reset(a.release()) lezárná a saját descriptorunkat, majd -1-t tenne vissza. Másolás nincs, mert két tulajdonos ugyanarra a descriptorra dupla close-hoz vezetne.

21.2.5 Kidolgozott példa: FD és lock egy programban

A lock-wrapper ugyanazt a vázat követi, de két ponton eltér. Egyrészt a megszerzés (std::mutex::lock()) dobhat std::system_error-t, ezért a konstruktor nem noexcept — ha mégis az lenne, a hiba std::terminate-mé silányulna. Másrészt a lock „handlja” nem egy érték, hanem egy mutexre mutató pointer, ahol a nullptr jelöli, hogy nem birtokolunk zárat. A release() itt azt jelenti: „nem oldok fel, de nem is tartom tovább számon a zár felszabadítását” — ez a std::unique_lock::release() mintájára pontosan az a művelet, amivel egy külső mechanizmusnak adunk át felelősséget.

class mutex_guard {
public:
    // Nem noexcept: a std::mutex::lock() dobhat std::system_error-t.
    explicit mutex_guard(std::mutex& m) : m_(&m) { m_->lock(); }
    ~mutex_guard() { unlock(); }

    mutex_guard(const mutex_guard&) = delete;
    mutex_guard& operator=(const mutex_guard&) = delete;

    mutex_guard(mutex_guard&& other) noexcept
        : m_(std::exchange(other.m_, nullptr)) {}

    mutex_guard& operator=(mutex_guard&& other) noexcept {
        if (this != &other) {
            unlock();
            m_ = std::exchange(other.m_, nullptr);
        }
        return *this;
    }

    std::mutex* release() noexcept { return std::exchange(m_, nullptr); }

    void unlock() noexcept {
        if (m_ != nullptr) {
            m_->unlock();   // a szabvány szerint nem dob
            m_ = nullptr;
        }
    }

private:
    std::mutex* m_ = nullptr;
};

int main(int argc, char** argv) {
    if (argc < 2) {
        std::fprintf(stderr, "usage: %s FILE\n", argv[0]);
        return 2;
    }

    auto fd = open_readonly(argv[1]);
    if (!fd) {
        const int e = errno;   // az errno-t még a fprintf előtt mentsük el
        std::fprintf(stderr, "open(%s): %s\n", argv[1], std::strerror(e));
        return 1;
    }

    std::mutex m;
    {
        mutex_guard guard{m};
        char buf[64];
        const ssize_t n = ::read(fd->get(), buf, sizeof buf);
        if (n < 0) {
            const int e = errno;
            std::fprintf(stderr, "read: %s\n", std::strerror(e));
            return 1;   // a guard és a fd is felszabadul
        }
        std::printf("read %zd bytes\n", n);
    }                   // itt a zár felszabadul

    return 0;
}

Fordítás a laborprofilban rögzített GCC vagy Clang kiadással, C++20 módban:

g++ -std=c++20 -Wall -Wextra -pedantic -pthread -O2 -o demo demo.cpp
./demo ./demo.cpp

A példa POSIX/Linux környezetre készült (LP64, ssize_t = long, ezért a %zd), és az O_CLOEXEC elérhetősége a fordítónaplóban rögzített makrókörnyezettől függ (a g++ Linuxon jellemzően definiálja a _GNU_SOURCE-t). A kódot ez a dokumentum nem futtatta; a leírt viselkedés a POSIX és a C++20 szabályain alapuló elvárás, nem mért eredmény.

21.2.6 A scope exit és a stack unwinding előképe

A mechanizmus, ami miatt ez működik, a hatókör elhagyása. A destruktor lefut, ha a vezérlés normál úton kilép a blokkból, ha return-nel kilép, ha break/continue/goto visz ki a blokkból, és ha kivétel miatt megindul a stack unwinding. Ez utóbbi az, amiért a RAII kivételbiztos: a return 1; a fenti mutex_guard hatókörén belül pontosan ugyanúgy felszabadít, mintha egy goto cleanup címkére ugranánk — csak nem kell kézzel megírni és karbantartani a címkét.

Két korlátot érdemes rögzíteni. Egyrészt a std::exit() és a std::abort() nem futtatja le az automatikus tárolási idővel rendelkező objektumok destruktorait (a std::exit a statikusokat futtatja), tehát egy wrapper sem véd meg ott, ahol a folyamatot kilépésre kényszerítjük. Másrészt a C-ből örökölt longjmp, ha nem-triviális destruktorral rendelkező C++ objektumot kerül meg, nem definiált viselkedés — signal handlerekből ezért csak async-signal-safe műveleteket szabad végezni, RAII wrapper-t nem.

A C++20 alap nem tartalmaz szabványos scope_exit-et; a Library Fundamentals TS kínál ilyet, ezért ha általános „hatókör végén fusson ez a művelet” eszközre van szükség, saját, néhány soros wrappert írunk (lásd a feladatok között).

21.2.7 FD, mapping, FILE és lock: mi közös, mi eltérő

A közös váz mindenhol ugyanaz: megszerzés a konstruktorban, felszabadítás a destruktorban, move-only, explicit üres állapot. Az eltérések a sentinelben, a felszabadítás hibajelzésében és abban vannak, hogy a handle „elengedhető-e” felszabadítás nélkül.

Erőforrás Megszerzés Felszabadítás Érvénytelen sentinel Felszabadítás jelezhet hibát Megjegyzés
file descriptor open, socket, accept close -1 igen (EINTR, EIO), de nem ismételhető a 0 érvényes
memóriatérkép mmap munmap MAP_FAILED igen cím és hossz együtt birtokolt
FILE* fopen, fdopen fclose nullptr igen; puffert is flush-ol glibc-ben belső lock
std::mutex zár lock, try_lock unlock nincs érték; „nem birtokol” állapot a unlock() nem dob ugyanazt a mutexet kell feloldani

A lock annyiban speciális, hogy a „handle” önmagában nem azonosítja a felszabadítandó erőforrást: a mutexre van szükség hozzá. Ezért a release() itt nem erőforrás-átadás a szó FD-s értelmében, hanem felelősség-átadás; a wrapperből kikerült pointerrel a hívónak kell unlock-olnia. A FILE* esetében pedig a destruktor már „késő”: a flush hibáját a megsemmisítés előtt kell ellenőrizni, különben csendesen adatot veszítünk.

21.2.8 Tipikus hibák és javításuk

A leggyakoribb hiba a 0 sentinel használata -1 helyett; javítás: fd_ = -1 alapérték, és a valid() a >= 0 feltételt vizsgálja, nem a != 0-t. A második a másolás véletlen engedélyezése (pl. egy = default másoló konstruktor), ami dupla close-hoz vezet; javítás: a másoló műveletek = delete, és a mozgató műveletek noexcept-ok. A harmadik a close() újrapróbálása EINTR-re; javítás: egy kísérlet, az eredmény eldobása. A negyedik a noexcept rátétele egy lock-megszerző konstruktorra; javítás: a konstruktor hagyja, hogy a std::system_error kifelé menjen, és a hívó döntsön. Az ötödik a release() utáni használat: a get() -1-et ad, és a read(-1, ...) EBADF-et ad — ez nem hiba a wrapperben, hanem a szerződés megsértése a hívó oldalán. Végül a hatodik: egy observer int fd paramétert egy owner osztály tagjába menteni; ez a C projekt ownership-térképének elrontása, és a tünet csak jóval később, egy lezárt descriptor újrahasznosításakor jelentkezik.

21.2.9 Összefoglaló

A RAII wrapper lényege nem a destruktor, hanem a szerződés: pontosan egy birtokolt erőforrás, explicit üres állapot sentinel értékkel, move-only viselkedés, és a megszerzés hibájának értékként (vagy kivételként) való megjelenítése a konstruktoron kívül. A felszabadítás hibáját a destruktor nem tudja továbbadni, ezért azt a megsemmisítés előtti utolsó ellenőrizhető ponton kell kezelni. A release/reset/swap/mozgatás négy külön szerződés, amelyeket külön kell dokumentálni és tesztelni. Az FD, a mmap, a FILE* és a lock ugyanerre a vázra illeszkedik, de a sentinel, a hibajelzés és a „felszabadítás nélküli elengedés” lehetősége eltér.

21.2.10 Feladatok

1. feladat (alap). Egészítse ki a unique_fd osztályt egy make_pipe() factory függvénnyel, amely std::optional<std::pair<unique_fd, unique_fd>>-t ad vissza, és hiba esetén üres opcionálist. Fogalmazza meg a két descriptor tulajdonlási viszonyát és a hibaútját.

2. feladat (közepes). Írjon egy általános scope_exit wrappert, amely egy callable-t futtat a hatókör elhagyásakor, és támogatja a dismiss()-t. Cserélje ki vele a mutex_guard-ot egy olyan függvényben, ahol a zár felszabadítása előtt még hibát kell jelenteni. Hasonlítsa össze a két megoldás kivételbiztosságát.

3. feladat (diagnosztikai). Készítsen unique_file wrappert std::FILE*-hoz, és mutassa meg, miért nem elegendő a fclose() hibáját a destruktorban eldobni. Adjon egy olyan hívási mintát, ahol a flush hibája még a wrapper élettartama alatt kiderül.

21.2.11 Megoldások

21.2.11.1 1. megoldás

std::optional<std::pair<unique_fd, unique_fd>> make_pipe() noexcept {
    int fds[2];
    if (::pipe(fds) != 0) {
        return std::nullopt;
    }
    return std::pair<unique_fd, unique_fd>{unique_fd{fds[0]}, unique_fd{fds[1]}};
}

Indoklás: a ::pipe két descriptort ír ki; a sikeres hívás után a fds tömb mindkét eleme a hívó tulajdonába kerül, és a pair létrehozásával azonnal két külön unique_fd birtokolja őket. Ha a pipe hibázik, a tömb tartalma nem használható, ezért nem szabad „óvatosan” mégis wrapperezni. A noexcept azért tartható, mert a pipe, az optional és a pair konstrukció nem dob. Ellenőrzési feltétel: hibaágon a visszatérés std::nullopt, sikerágon a.get() >= 0 && b.get() >= 0, és a két descriptor különbözik. Linux-specifikus változat: pipe2(fds, O_CLOEXEC), ezt azonban a laborprofilban jelölni kell.

21.2.11.2 2. megoldás

#include <type_traits>
#include <utility>

template <class F>
class scope_exit {
public:
    explicit scope_exit(F f) noexcept(std::is_nothrow_move_constructible_v<F>)
        : f_(std::move(f)) {}

    scope_exit(const scope_exit&) = delete;
    scope_exit& operator=(const scope_exit&) = delete;

    ~scope_exit() {
        if (active_) {
            f_();
        }
    }

    void dismiss() noexcept { active_ = false; }

private:
    F f_;
    bool active_ = true;
};

Használat zárral:

std::mutex m;
{
    m.lock();
    scope_exit unlocker{[&m] { m.unlock(); }};
    // ... kritikus szakasz ...
    if (hiba) {
        return;          // az unlocker lefut
    }
    unlocker.dismiss();  // ha mégis mi oldunk fel
    m.unlock();
}

Indoklás: a mutex_guard típusa kifejezi a birtoklást, a scope_exit viszont csak egy műveletet köt a hatókörhöz; ez utóbbi akkor jó, ha a „felszabadítás” nem egy handle visszaadása, hanem egy oldalhatás. A kivételbiztosság szempontjából a scope_exit gyengébb: nem tiltja meg, hogy valaki kézzel is felszabadítson, ezért a dismiss() fegyelmezett használata a hívó felelőssége. Ellenőrzési feltétel: a dismiss() után a callable nem fut le, hibaágon viszont pontosan egyszer.

21.2.11.3 3. megoldás

#include <cstdio>
#include <utility>

class unique_file {
public:
    unique_file() noexcept = default;
    explicit unique_file(std::FILE* f) noexcept : f_(f) {}

    unique_file(const unique_file&) = delete;
    unique_file& operator=(const unique_file&) = delete;

    unique_file(unique_file&& o) noexcept : f_(std::exchange(o.f_, nullptr)) {}

    unique_file& operator=(unique_file&& o) noexcept {
        if (this != &o) {
            reset(o.release());
        }
        return *this;
    }

    ~unique_file() { reset(); }

    [[nodiscard]] std::FILE* get() const noexcept { return f_; }
    std::FILE* release() noexcept { return std::exchange(f_, nullptr); }

    void reset(std::FILE* f = nullptr) noexcept {
        if (f_ != nullptr) {
            (void)std::fclose(f_);  // a flush hibája itt elveszne
        }
        f_ = f;
    }

private:
    std::FILE* f_ = nullptr;
};

Használat, ahol a hiba még időben kiderül:

auto f = std::fopen("out.bin", "wb");
if (f == nullptr) { /* hiba */ }

unique_file uf{f};
if (std::fwrite(buf, 1, n, uf.get()) != n) { /* írási hiba */ }
if (std::fflush(uf.get()) != 0) { /* itt még látszik a hiba */ }
// uf destruktora innen már csak egy újabb fclose-t végez

Indoklás: a fclose() hívása flush-ol, és ennek hibája adatvesztést jelenthet; mivel a destruktor nem jelezhet, a helyes minta a tényleges utolsó írás után explicit fflush-t hívni, és ott kezelni a hibát. A reset() „eldobja” a visszatérési értéket, ami tudatos döntés, és a szerződésben dokumentálni kell. Ellenőrzési feltétel: a fflush hibáját a program a wrapper megsemmisülése előtt észleli, a release() után pedig a std::FILE* tulajdonosa a hívó, aki fclose-t hív.