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.
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.
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.
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_scopeA 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 |
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.
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?
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!
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());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.
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.
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.
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.