37.2 pmr, memory_resource és region lifetime

Az allokátor-modell Allocator model és allocator_traits (37.1) alfejezetben rögzített alapja az, hogy a konténer típusa hordozza az allokátor típusát, így a tároló viselkedése már fordítási időben eldől. Ez kiváló a statikus, fordítási idejű döntésekhez, de kellemetlen ott, ahol ugyanaz a tárolótípus futásidőben más-más memóriastratégiával kell dolgozzon: egy kérés élettartamára szóló aréna, egy hosszú életű pool vagy a globális heap. A C++17 óta rendelkezésre álló polymorphic allocator (std::pmr) pontosan ezt a rést tölti ki: a tároló típusa változatlan marad, a tényleges memóriastratégiát egy futásidejű memory_resource objektum hordozza. Ez a C aréna mintájának (Arena, pool és region lifetime (13.4)) típusbiztos, destruktorhelyes megfelelője, de nem azonos vele: a C++ régió felszabadítása nem hív destruktort, és a konténerek élettartama külön szerződést igényel. Az alábbiakban ezt a szerződést bontjuk ki, majd egy per-request tárolón mutatjuk meg a helyes és a tipikus hibás mintákat. A mérés és a benchmark módszertana a következő alfejezet témája; itt nem adunk univerzális benchmarkot és nem ígérünk zero-cost viselkedést.

37.2.1 A memory_resource szerződés és a polymorphic_allocator

A std::pmr::memory_resource absztrakt interfész, amelynek három művelete van: allocate(bytes, alignment), deallocate(p, bytes, alignment) és is_equal(other). A nyilvános allocate/deallocate nem virtuális; a tényleges munkát a protected do_allocate, do_deallocate és do_is_equal végzi. Ez a nem virtuális interfész (NVI) minta azt jelenti, hogy a resource viselkedése örökléssel specializálható, a hívó oldalon viszont stabil, nem virtuális függvényhívás marad. Az allokációs szerződés feltételei: a deallocate pointere ugyanabból az allocate hívásból származzon, ugyanazzal a mérettel és igazítással; a resource objektumnak élnie kell a deallocate pillanatában; és a visszaadott pointernek teljesítenie kell a kért igazítást. A bytes és alignment páros a szerződés része, nem opcionális metaadat: a legtöbb implementáció a deallocate-ban ezekből dolgozik, és a méret elhagyása vagy megváltoztatása nem definiált viselkedés.

A std::pmr::polymorphic_allocator<T> egy allokátor-típus, amely egy memory_resource* mutatót tárol. Fontos pontosítás: nem „stateless” allokátor, hiszen van állapota, de ez az állapot nem jelenik meg a konténer típusában. A std::pmr::vector<T> valójában std::vector<T, std::pmr::polymorphic_allocator<T>>, és ugyanez igaz a pmr::string, pmr::map, pmr::unordered_map és a többi pmr-típusra. Két polymorphic_allocator akkor egyenlő, ha ugyanarra a resource-ra mutatnak, vagy ha a resource do_is_equal-je igazat ad. Ezen az egyenlőségen múlik a konténer mozgatása és cseréje: a polymorphic_allocator nem propagálja magát copy assignment, move assignment vagy swap során, ezért ha két konténer allokátora nem egyenlő, a move assignment elemenkénti mozgatássá válik, nem pedig a puffer eltulajdonításává. Ez a költségmodell egyik fontos tétele, amelyet a mérésnél figyelembe kell venni.

A pmr-tárolók létrehozásakor a resource pointert a konténer konstruktorának adjuk át. A std::pmr::vector<std::pmr::string> v(&res); azt jelenti, hogy a vektor saját puffere a res-ből származik. Ez azonban önmagában nem jelenti azt, hogy a benne lévő pmr::string elemek is a res-ből allokálnak. A pmr::string allokátortípusa polymorphic_allocator<char>, a vektoré polymorphic_allocator<std::pmr::string>; a kettő különbözik, ezért a vektor construct-ja nem adja át automatikusan a saját allokátorát a string konstruktorának. Ha az elemeket v.emplace_back("szöveg") formában hozzuk létre, azok a get_default_resource()-on foglalnak, és a vektor resource-ának felszabadítása nem érinti a stringek puffereit. Ez a nested allocation tipikus csapdája. A helyes minta az, hogy az elemnek explicit átadjuk a resource-ot: v.emplace_back(sv.data(), sv.size(), &res); vagy m.try_emplace(kulcs, "érték", &res);. Utóbbi a std::pmr::map<int, std::pmr::string> esetében a pair piecewise konstruktorán keresztül éppen a stringnek adja át a resource-ot. Az allocator-aware construction tehát nem varázslat: a beágyazott tároló allokátorát a keletkezés pillanatában kell megadni, különben a beágyazott objektum a default resource-ra esik vissza.

37.2.2 Region lifetime: monotonic, pool és release

A std::pmr::monotonic_buffer_resource egy régió: minden allokáció egy növekvő pufferből történik, a deallocate pedig nem tesz semmit. A release() visszaadja az upstream resource-nak a teljes addig felhasznált területet, és a resource újra üres állapotból használható. A release() után azonban minden korábban kiadott pointer, iterátor, string_view, span és referencia érvénytelenné válik. Az upstream resource élettartama legalább akkora kell legyen, mint a monotonic resource-é; ha külső puffert adunk át a konstruktorban, a puffer élettartama is legalább ekkora kell legyen. A monotonic resource nem szálbiztos: egy szálon belüli, egyszeri összeállításra és eldobásra való, nem hosszú életű, több szál által osztott allokátorként.

Az std::pmr::unsynchronized_pool_resource és a std::pmr::synchronized_pool_resource ezzel szemben méretosztályok szerinti poolokból szolgál ki, és a deallocate visszateszi a blokkot a megfelelő poolba. A release() itt is visszaadja az upstream-nek a poolok által birtokolt memóriát, de csak akkor biztonságos, ha addigra minden belőle allokált objektum megszűnt. Az „unsynchronized” változat nem szálbiztos, a „synchronized” változat belső zárakkal szolgál ki; utóbbi nagyobb overheadtel jár, és a release() párhuzamos használatának megengedettsége implementációfüggő, ezért a konkrét implementáció dokumentációját kell ellenőrizni. A választás indoka: ismétlődő, hasonló méretű, rövid életű allokációkra a pool; egyetlen kérés élettartamára, ahol a felszabadítás egyszerre történik, a monotonic; a kettő kombinációja pedig az, amikor a monotonic a poolt használja upstreamként.

A régió élettartamának központi szabálya: a resource élettartama nagyobb vagy egyenlő a belőle allokált összes objektum élettartamánál. Ez a C++-ban szigorúbb, mint a C arénában, mert a konténer destruktora nem csak felszabadít, hanem destruktorokat is hív, és a deallocate-ot a resource-on keresztül végzi. Ha a resource előbb pusztul el, mint a konténer, a konténer destruktora egy már nem létező objektum virtuális függvényét hívja meg, ami nem definiált viselkedés. Ugyanez igaz a release()-re is: a release() nem hív destruktort, ezért a benne lévő nem triviális destruktorú elemeket előbb destroy-olni kell. A pmr::vector::clear() meghívja az elemek destruktorait, de a vektor kapacitása megmarad, és a puffer memóriája a resource-hoz tartozik; a release() után a vektor belső pointerei danglingek, ezért a vektort a release után nem szabad újra használni. A helyes minta az, hogy a release()-et csak a konténer megszűnése után, vagy a konténer újrakonstruálásával együtt hívjuk. A borrowed view-kra ugyanez vonatkozik: a string_view, span, const char* és iterátorok a release pillanatában érvénytelenné válnak, még akkor is, ha a mögöttes karakterek látszólag változatlanok.

37.2.3 Kidolgozott példa: per-request tároló

Az alábbi példa egy kérés élettartamára szóló tárolót mutat be. A RequestScope a resource-ot és a konténert tartalmazza; a deklarációs sorrend szándékos, mert a tagok fordított sorrendben pusztulnak el, így a konténer előbb szűnik meg, mint a resource. A add tagfüggvény a stringnek explicit átadja a scope resource-át, elkerülve a nested allocation csapdáját.

#include <cstddef>
#include <cstdio>
#include <memory_resource>
#include <string>
#include <string_view>
#include <vector>

class RequestScope {
public:
    explicit RequestScope(std::size_t buffer_bytes = 4096)
        : resource_(buffer_bytes), strings_(&resource_) {}

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

    void add(std::string_view sv) {
        // A vektor uses-allocator konstrukcióval maga adja át a resource-át
        // a pmr::string-nek; ha itt is átadnánk, kétszer kerülne a listába.
        strings_.emplace_back(sv.data(), sv.size());
    }

    const std::pmr::vector<std::pmr::string>& strings() const noexcept {
        return strings_;
    }

    std::pmr::memory_resource* resource() noexcept { return &resource_; }

private:
    // Szándékos sorrend: a resource előbb jön létre, és utoljára pusztul el.
    std::pmr::monotonic_buffer_resource resource_;
    std::pmr::vector<std::pmr::string> strings_;
};

int main() {
    RequestScope scope;
    scope.add("GET /health HTTP/1.1");
    scope.add("Host: example.invalid");
    for (const auto& line : scope.strings()) {
        // Minden string a scope resource-ából allokál.
        if (line.get_allocator().resource() != scope.resource()) return 1;
        std::printf("%s\n", line.c_str());
    }
}

A példa önállóan fordítható a C++17-es <memory_resource> fejlécet támogató implementáción. A laborprofilban rögzített fordítóval és standard library-vel ellenőrizni kell a tényleges támogatást; a könyv alapértelmezett C++20 profillal dolgozik, de a pmr nem C++20-as újdonság. Fordítási példa:

g++ -std=c++20 -O2 -Wall -Wextra -pedantic-errors request_scope.cpp -o request_scope

A std::pmr::vector<std::pmr::string> uses-allocator konstrukciót végez ([C++20] [allocator.uses.construction]): mivel a std::pmr::string allokátort használó típus, a vektor az új elem konstruktorának argumentumlistáját maga egészíti ki a saját polymorphic_allocator-ával. Ezért az emplace_back(sv.data(), sv.size()) elég, és a string a scope resource-ából allokál. Ha a hívó is átadná a resource-ot (emplace_back(sv.data(), sv.size(), &resource_)), az allokátor kétszer kerülne az argumentumlistába, és a fordítás construction with an allocator must be possible if uses_allocator is true hibával leállna. Egy közönséges std::string elem ezzel szemben nem allokátortudatos: a vektor nem adja át neki a resource-ot, és a karakterei a globális heapre kerülnének.

Buildjegyzőkönyv (lab-gcc profil). A program figyelmeztetés nélkül fordult, a kimenete a két hozzáadott sor, és minden string get_allocator().resource()-a a scope resource-a. A javítás előtti, kétszer átadott allokátoros változat a fenti static assertion failed hibával nem fordult. A program nem futtatja a resource release()-ét; a scope megszűnésekor a strings_ destruktora lefut, majd a resource_ destruktora következik. Ez a természetes, biztonságos minta. Ha a kérés végén explicit „reset” kell, a release() önmagában nem elég: a konténert is újra kell inicializálni, például úgy, hogy a RequestScope-ot eldobjuk és újat hozunk létre, vagy a konténert egy std::optional-ban tartjuk, és a release() után emplace-szel újrakonstruáljuk. A strings_.clear(); resource_.release(); sorrend csak akkor lenne helyes, ha a vektort a release után nem használjuk többé, és a vektor destruktora előbb fut le, mint a resource-é; a gyakorlatban ez a minta a legtöbb esetben rejtett dangling pointert hagy maga után.

A beágyazott allokáció helyes kezelésére álljon itt egy jelölt részlet egy pmr::map esetében. A try_emplace a pair piecewise konstruktorán keresztül a mappelt stringnek is uses-allocator konstrukcióval adja át a map resource-át; a hívónak ezért nem kell (és nem is szabad) külön átadnia. A lab-gcc profilban egy main-nel kiegészítve a részlet fordul, és a m.at(1).get_allocator().resource() == &res igaz:

// Részlet, nem önálló program: a res élettartama nagyobb a mapénál.
std::pmr::monotonic_buffer_resource res;
std::pmr::map<int, std::pmr::string> m(&res);
m.try_emplace(1, "alma");   // a string a res-ből allokál (uses-allocator)

A három stratégia összevetése:

Szempont C aréna (Arena, pool és region lifetime (13.4)) monotonic_buffer_resource unsynchronized_pool_resource
Visszafoglalás arena_reset release() regionálisan deallocate blokkonként
Destruktor nincs nincs (a konténer destruktora külön) nincs (a konténer destruktora külön)
Szálbiztonság nem nem nem (a synchronized igen)
Allokációs költség bump pointer bump pointer + virtuális hívás pool-keresés + virtuális hívás
Fragmentáció nincs nincs méretosztályon belül korlátozott
Élettartam régió régió a pool élettartama

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

A leggyakoribb hiba a tagok sorrendje: ha a pmr::vector előbb van deklarálva, mint a resource, akkor a destruktorok sorrendje miatt a resource előbb szűnik meg, és a vektor destruktora dangling resource-ra hív. A javítás a resource előre deklarálása, vagy a resource és a konténer egyetlen owner osztályba zárása. A második hiba a release() utáni használat: a string_view-k, iterátorok és a konténer belső pointerei érvénytelenné válnak; a release előtt a fogyasztóknak be kell fejezniük az olvasást. A harmadik hiba a nested allocation figyelmen kívül hagyása: a pmr::vector<pmr::string> elemei alapértelmezésben a default resource-on allokálnak, így a scope resource-ának felszabadítása nem szabadítja fel az elemek puffereit, és a regionális költségmodell nem teljesül. A negyedik hiba a monotonic_buffer_resource megosztása több szál között; erre a synchronized_pool_resource vagy külső szinkronizáció való. Végül az ötödik: a release() és a többi művelet egyidejű hívása a pool resource esetében; ezt a konkrét implementáció dokumentációja szerint kell ellenőrizni, és a legtöbb esetben külső szinkronizációt igényel.

37.2.5 Feladatok

  1. Magyarázó feladat. Hasonlítsa össze a C aréna arena_reset műveletét a monotonic_buffer_resource::release() műveletével! Miért nem elég a release(), ha a tárolóban nem triviális destruktorú elemek vannak, és mi a helyes műveletsor?

  2. Kódolási feladat. Módosítsa a RequestScope példát úgy, hogy unsynchronized_pool_resource-t használjon a monotonic helyett, és a reset() a konténer clear()-je után hívja a resource release()-ét! Magyarázza el, miért veszélyes ez a vektorral, és javítsa úgy, hogy a konténert is újrakonstruálja, vagy mondjon le a reset()-ről! Adja meg a buildparancsot!

  3. Diagnosztikai feladat. Adott az alábbi vázlat. Soroljon fel legalább három élettartam- vagy ownership-hibát, és javítsa a kódot!

std::pmr::vector<std::pmr::string> v;
std::pmr::monotonic_buffer_resource res;
v.emplace_back("alma");
res.release();
for (const auto& s : v) std::puts(s.c_str());

37.2.6 Megoldások

  1. A C aréna arena_reset-je csak a bump pointert állítja vissza, és nem hív destruktort, mert a C aréna nem tud a benne lévő objektumok típusáról. A release() ugyanezt teszi a monotonic resource szintjén: a nyers memóriát adja vissza az upstream-nek, de a benne lévő C++ objektumok destruktorait nem hívja meg. Ha a tárolóban nem triviális destruktorú elemek vannak, a release() előtt azokat destroy-olni kell; a pmr::vector esetében ez a clear(), amely az elemek destruktorait hívja, de a vektor kapacitását és belső pointereit nem állítja vissza. A helyes sorrend ezért: a fogyasztók befejezik az olvasást, a konténert megszüntetjük (vagy újrakonstruáljuk), és csak ezután hívjuk a release()-ét. Az ellenőrzési feltétel: a release() után semmilyen korábbi pointer, string_view, span vagy iterátor nem használható, és a konténer destruktora már lefutott.

  2. A poolos változatban a reset() így nézne ki: strings_.clear(); resource_.release();. Ez azért veszélyes, mert a clear() után a vektor kapacitása és belső puffere megmarad, de a puffer a resource-ból származik; a release() után a vektor belső pointerei danglingek, és a vektor további használata nem definiált viselkedés. A helyes javítás vagy az, hogy a reset() a konténert is újrakonstruálja (például std::optional-ban tartott konténer emplace-jével), vagy az, hogy a reset() helyett a teljes RequestScope-ot eldobjuk és újat hozunk létre. A pool választásának indoka a visszatérő, hasonló méretű allokációk kezelése; a monotonic akkor jobb, ha a kérés végén úgyis mindent eldobunk. A buildparancs a példához hasonlóan g++ -std=c++20 -O2 -Wall -Wextra -pedantic-errors. Ellenőrzési feltétel: a release() után a konténerre semmilyen művelet nem történik, és a destruktorok a release előtt lefutnak.

  3. A vázlat hibái: (a) a v allokátora a default resource-ra mutat, mert a vektort allokátor nélkül hoztuk létre, így a res release-e nem érinti a vektor pufferét; (b) a res a v után van deklarálva, ezért előbb pusztul el, mint a vektor, ami a vektor destruktorában dangling resource-ra hívna; (c) az emplace_back("alma") a stringet a default resource-on hozza létre, nem a res-en, így a nested allocation nem a kívánt régióba kerül; (d) a res.release() után a v elemeit olvassuk, ami a vektor pufferére és a stringekre nézve is dangling pointert jelent. Javított változat: a resource-ot előbb deklaráljuk, a vektort explicit allokátorral hozzuk létre, a stringet explicit resource-szal konstruáljuk, és a release()-et csak a vektor megszűnése után hívjuk (vagy a vektort a release előtt destroy-oljuk). Ellenőrzési feltétel: a release() előtt minden nem triviális destruktor lefut, és a release után semmilyen a régióra mutató hivatkozás nem él.

37.2.7 Összefoglaló

A std::pmr a futásidejű allokációs stratégiát választja el a tároló típusától: a memory_resource a szerződés, a polymorphic_allocator a híd, a pmr-tárolók pedig a típusstabil felhasználók. A monotonic_buffer_resource a C aréna típusbiztos megfelelője, de a release() nem hív destruktort, ezért a C++-ban a konténerek és elemek élettartamát külön kell kezelni. A pool resource a visszatérő, hasonló méretű allokációkra való; a synchronized változat szálbiztos, az unsynchronized nem. A nested allocation csak akkor marad a régión belül, ha a beágyazott tároló allokátorát a keletkezéskor explicit megadjuk. A régió élettartama mindig nagyobb vagy egyenlő a belőle allokált objektumok élettartamánál, és a release() pillanata minden korábbi pointer, view és iterátor érvényességének végét jelenti.