Internet Info, s.r.o. Lupa Root Měšec Podnikatel DigiZone Slunečnice Vitalianew Bomba Navrcholu Weblogy Jagg Woko Dobrý web Computer.cz SK: MojeLinky
Root.czBlogyProgramátorské techniky nejen v C++

Alokátory a operator new v C++

Ondřej Novák, 4. 01. 2012, 19:36 v kategorii Nezařazené,

Každý programátor v C++ ví, že pomocí operátoru new můžeme zkonstruovat objekt na globální haldě. Někdy je však potřeba provést alokaci nového objektu jinde, než na globální haldě. Slovíčko někdy zde ale není úplně na místě, v praxi jsem si ověřil, že to přináší výhody, takže spíše by se sem hodilo slovíčko „často“.

To, kde se nakonec vytvoří prostor pro nový objekt je určeno speciálními objekty, které nazývám alokátory (tedy je to něco podobného jako std::allocator, ale mnohem obecnější, abstraktnější rovině). V článku představím rozhraní takového alokátoru. Nejprve bych ale zopakoval (pro některé) co všechno lze s operátorem new dělat.

Tento operátor provádí víceméně dvě operace.

  1. Alokuje blok paměti tak velký, aby se do něj nově konstruovaný objekt vešel
  2. Provádí konstrukci objektu voláním konstruktoru.

Už pomocí standardních knihoven lze jednu nebo druhou funkci potlačit. Můžeme požadovat pouze konstrukci objektu v již alokovaném bloku a můžeme požadovat pouze alokaci bloku bez konstrukce objektu.

  1. operator new(velikost) – kde velikost je požadovaná velikost v bytech
  2. new(adresa) Foo – konstruuje objekt … instanci třídy Foo na zadané adrese.

Můj soukromý průzkum ukazuje, že první vlastnost operátoru new spoustu mých profesních kolegů nezná. Často by v této situaci použili malloc(). Upozorňuji, že to není totéž, přestože ve výchozím stavu toto volání nakonec ten malloc() zavolá.

Druhý bod představuje variantu použití new zvanou placement new. Zde se použije adresa již kdesi v paměti vyhrazeného bloku, kam bude objekt zkonstruován. Je to vlastně jediný legální způsob, jak nad nějakým kusem paměti zavolat konstruktor a tím z toho kusu paměti vytvořit něco smysluplného. K této variantě neexistuje operator delete, a zkonstruovaný objekt musí být zlikvidovat ručním zavoláním destruktoru.

Pro použití s alokátory využijeme placement new který obecně připouští použití libovolného množství dodatečných argumentů libovolného typu. Samozřejmě každá varianta musí mít vlastní prototyp. Operátor můžeme deklarovat jako globální nebo členskou funkci.

Operator new a třídy

Pracovat s globálním new je trochu obtíž, protože se může dostat do konfliktu. Vlastní verzi přetíženého new nelze umístit do namespace, takže bude vždy globálním a představovat riziko konfliktu s jinou definicí v jiné knihovně. Dá se to obejít, ale nechci se tím teď zabývat.

Mnohem raději používám přetížení operátoru new u tříd. Co to znamená? Pokud se někde konstruuje třída, která má deklarovaný členský operator new, pak se k alokaci volá právě tento operator namísto globálního. A pozor, platí to i pro všechny podtřídy(!) Žádné konflikty nehrozí a pro knihovní třídy mám možnost nabídnout rozšířené alokace bez velké námahy. Stačí, když všechny třídy podědí nějakou společnou, která standardní operátor new (a samozřejmě i patřičné delete) přetíží.

Takže malý příklad, nejprve si ale vytvoříme jednoduchý alokátor.

 class IRuntimeAlloc {
 public:
     virtual void *alloc(std::size_t objSize)  = 0;
     virtual void dealloc(void *ptr, std::size_t objSize)  = 0;
 };

Nehledejte tam nic světoborného. Název IRuntimeAlloc je možná trochu nesmyslný, ale vznikl historicky s potřebou mít alokátor, který nezná velikost alokovaného objektu během překladu, ale až za běhu (runtime). Druhým důvodem je použití pozdní vazby, také v runtime, protože většina mých alokátorů jsou šablony bez pozdních vazeb. Zvolil jsem tenhle příklad proto, že je nejjednoduší. Funkce alloc a dealloc kopírují prototypy funkcí new a delete, takže asi nebude těžké volat je přímo z jejich implementace. Až tedy na jeden detail, a to přítomnost velikost objektu u dealloc. Mám vyzkoušeno, že je dobré, když tuto informaci alokátoru dodáme z vnějšku, protože alokátor tak ušetří spoustu místa ukládáním tohoto čísla. Věřte nebo ne, existuje způsob, jak tuto velikost získat bez nutnosti si jí ukládat poblíž alokovaného bloku. Dodá nám jí překladač.

 class DynObject{
public:
   void *operator new(std::size_t sz);
   void *operator new(std::size_t sz, IRuntimeAlloc &alloc)
   void operator delete(void *ptr, std::size_t sz);
 };

Teď by tedy mělo postačit všech knihovní třídy nechat dědit třídu DynObject a mohu vesele používat jak standardni new Foo; tak new(alloc) Foo; kde alloc je reference na objekt implementující rozhraní IRuntimeAlloc.

Povšimněte si operátoru delete. Opět můj soukromý průzkum zjistil, že není mnoho kolegů, kteří by si vzpomněli, že existuje tato varianta. Většina si vzpomněla jen na delete(void *ptr). Výše uvedenou variantu lze použít pouze u tříd; nebude fungovat u globálního delete. Zato bude správně fungovat i u všech potomků. A to není vše, pokud tuto třídu podědíme v jiné vrstvě hierarchie, než základní třídou (base), bude operator správně volán i v případě, že objekt bude destruován přes ukazatel na základní třídu ... tedy za předpokladu, že třída má virtuální destruktor!!!! (což je u drtivé většině tříd povinnost)

Výhodou varianty delete s uvedenou velikosti je, že si nemusíme velikost objektu nikde pamatovat. Aby to nebylo tak skvělé, máme tu i jednu nevýhodu. Všimněte si, že operátor delete neví nic o použitém alokátoru a přesto je nutné, aby deallokace proběhla přes alokátor, a nikoliv přes standardní delete. I když tedy ušetříme místo pro uložení velikosti objektu, musíme navíc přidat odkaz na alokátor, tak, aby delete vědělo, kdo je za deallokaci zodpovědný. V paměti se pak alokuje o něco větší blok (typicky o velikost ukazatele)

Prostor pro objekt allocptr

Pokud některého čtenáře napadlo, že by bylo možné použít něco jako „placement delete“ a allokátor mu dodat jako parametr, tak na to honem rychle zapomeňte. Nic takového nejde.

Výjimky … a bug v normě.

Nic člověka nepotrápí víc, než výjimky a obzvlášť u přetížených new a delete. Pokud během konstrukce objektu dojde k výjimce, je třeba přidělené místo opět uvolnit. A tady C++ volí jinou cestu, nedojde k volání standardního delete, ale k jakési obdobě „placement delete“. V normě C++ je uvedeno, že prototyp takové funkce je totožný s new až tedy na to, že jde o delete a prvním parametrem je ukazatel na alokovaný blok. Zde je několik příkladů:

Funkce pro alokaci Funkce pro dealokaci při výjimce
operator new(size_t sz) operator delete(void *ptr)
nebo operator delete(void *ptr, std::size_t sz)
operator new(size_t sz, void *ptr2) operator delete(void *ptr, void *ptr2)
operator new(size_t sz, IRuntimeAllocator &a) operator delete(void *ptr, IRuntimeAllocator &a)

Pokud tam takové funkce zapomeneme uvést, dojde při výjimce k leaku (k neuvolnění alokované paměti). Což není zrovna chytré. Takže je tam přídáme.

 class DynObject {
 public:
    void *operator new(std::size_t sz);
    void *operator new(std::size_t sz, IRuntimeAlloc &alloc); 
    void operator delete(void *ptr, std::size_t sz);
    void operator delete(void *ptr, IRuntimeAlloc &alloc); 
 };

Vidíte tam nějakou zradu? Já ano, a dost zásadní. Jak jsem napsal výše, náš alokátor vyžaduje velikost alokovaného bloku, aby jej mohl dealokovat. Je fajn, že nám to překladač spočítá, pokud jde o standardní delete, ale v případě dealokace během výjimky nemáme šanci velikost zjistit !!! To je dost zásadní problém. Z tohoto pohledu to vypadá, že jde o pořádný průšvih v normě C++, velikost uvedená u delete je vlastně  k ničemu, protože si ji beztak musím uložit někde poblíž alokovaného bloku. Hledal jsem tedy způsob, jak to obejit a nakonec se mi to podařilo obejít využím pomocného objektu, který bude existovat pouze během alokace paměti a konstrukce nového objektu. Tento objekt vznikne na zásobníku a aby jej programátor nemusel znát, využijeme implicitní volání konstruktoru. Nějak takto:

 class DynObjectAllocHelper {
 public:
    DynObjectAllocHelper (IRuntimeAlloc &alloc):alloc(alloc),sz(0) {}
    IRuntimeAlloc & ref() const {return alloc;}
    void storeSize(std::size_t sz) const {this->sz = sz;}
    std::size_t getSize() const {return sz;}
 protected:
    IRuntimeAlloc & alloc;
    mutable std::size_t sz;
 };

Prototyp třídy DynObject náležitě upravíme:

 class DynObject {
 public:
    void *operator new(std::size_t sz);
    void *operator new(std::size_t sz,const DynObjectAllocHelper &alloc);
    void operator delete(void *ptr, std::size_t sz);
    void operator delete(void *ptr);
    void operator delete(void *ptr,const DynObjectAllocHelper &alloc); 
 };

Použití const reference způsobí, že instance třídy DynObjectAllocHelper se bude konstruovat v zásobníku volajícího a teprve pak se použije pro volání new případně delete při výjimce. Protože konstruktor třídy není deklarován jako explicit a má jeden parametr, není jej třeba uvádět, stačí do new dodat referenci na allokátor a překladač sám zajistí implicitní konverzi a konstrukci pomocného objektu. Metoda storeSize ukládá do pomocného objektu velikost předanou funkcí new tak, aby si jí funkce delete mohla vyzvednou, pokud dojde k výjimce (má k dispozici referenci na objekt DynObjectAllocHelper předanou parametrem). Protože je objekt s kvalifikátorem const, musíme proměnnou pro velikost deklarovat jako mutable. Čistotu kódu v tom nehledejte, jedná se vlastně o hack. Pomocný objekt je destruován automaticky, jakmile je konstrukce alokovaného objektu dokončena, nebo pokud je dokončen úklid při výjimce.

Využití

Jak už bylo na začátku naznačeno, smyslem bylo zavést možnost alokovat objekty pomocí jiných alokátorů. Uvedu pár alokátorů z mé knihovny pro inspiraci

AllocInBuffer – alokátor, kterému předávám adresu a velikost paměti, ve které si může alokovat. Jedná se o nejjednodušší alokátor, který lze použít jen na alokaci jednoho objektu. Je to obdoba placement new s tím rozdílem, že podporuje i delete, takže na alokovaný objekt lze použít chytrý ukazatel.

char buffer[256];
AllocInBuffer bufalloc(buffer,sizeof(buffer));
MyObject *x = new(bufalloc) MyObject;
//...
delete x;

TempAlloc – alokátor optimalizovaný pro krátkodobé alokace, které uspokojuje se složitosti O(1). Nevýhodou je, že nevhodné použití vede na výrazné plýtvání paměti.

Varianta, kdy o umístění rozhoduje volaný

MyObject *foo() {
   //funkce foo() předává výsledek výpočtu a
   //k alokaci použije alokátor pro krátkodobé alokace
   return new(TempAlloc::getInstance()) MyObject;
}
void bar() {
   MyObject *x = foo();
   //... zpracuji x
   // funkce bar() zpracuje výsledek a paměť uvolní
   // nemusí tušit, že objekt je alokován v dočasné paměti
   delete x;
}

Varianta, kdy o umístění rozhoduje volající

MyObject *foo(IRuntimeAlloc &alloc) {
   //funkce foo() předává výsledek výpočtu a
   //k alokaci použije dodaný alokátor
   return new(alloc) MyObject;
}
void bar() {
   //volající ví, že vytvořený objekt bude mít velmi krátkou životnost
   MyObject *x = foo(TempAlloc::getInstance());
   //... zpracuji x
   delete x;
}

ClusterAlloc – alokátor objektů stejné velikosti, snižuje fragmentaci paměti a dosahuje konstantní složitosti a dobré lokality dat (ukládají se do clusterů)

FastAlloc – dealokované bloky udržuje ve spojovém seznamu a při alokaci dokáže velice rychle vydat takový volný blok bez nutnosti volat globální alokátor. Je dobré, pokud každé vlákno má vlastní instanci, zvyšuje to propustnost alokací.

V článku nehledejte implementaci třídy DynObject, k té bych se dostal příště, bude tam pár dobrých nápadů které mohou ve výsledku urychlit alokace a dealokace pomocí allokátorů.

Komentáře (6)

  1. 6. 01. 2012, 09:52 Marek napsal:

    Pěkný článek, mám pár dotazů: jak máte implementováno TempAlloc::getInstance() - předpokládám, že jde o singleton. S přihlédnutím k tomu že "double checked locking" v C++ nefunguje (http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf a podobné). Druhý dotaz: jak na alocátory a STL? Tam není podle mě dost dobře možné využít třeba Váš BufferedAlloc, protože kontejnery STL přijímají typ nikoli instanci a nejsou nuceny si držet instanci alokátoru a tu si při kopírování předávat. Offtopic: Hele, tady nefunguje MojeID.
    Marek.

  2. 6. 01. 2012, 14:14 Ondřej Novák napsal:

    @1 Zdravím. Ve skutečnosti nemám nikde singletony, tedy pouze jen objekty, které mohou býti singletony, nebo jsou na to vhodné. To getInstance() je funkce, která přes mnou napsaný systém alokace singletonů prostě vytvoří a následně zaregistruje globální instanci objektu, která je pak dostupná přes stejné volání (samozřejmě je to MT safe). Nicméně instancí téže třídy lze vytvořit více, není tam to omezení jako u pravých singletonů.

    Alokátory v STL jsou fakt zmršené, právě že si nedrží instanci, musí to člověk obcházet třeba přes globální proměnné, nebo lépe přes thread proměnné. Já mám ještě v plánu kromě druhého dílu k tomuto článku pak povídání o továrnách (obecných), které se hodí třeba na alokaci uzlů ve stromě, nebo ve spojových seznamech, a dále tam mám třídu představující alokovaný blok, což se zase hodi pro všelijaké vektory, a obecně kontejnery pracující se souvislým úsekem paměti s možností jeho relokace.

    Proto jsem se dostal tak daleko, že ve svých knihovnách mám i vlastní implementace vektorů, vyhledávacích stromů, listů, front, zásobníků, a tak dále, jenom proto, že v nich mohu používat své alokátory :-)

  3. 7. 01. 2012, 19:59 Jenda napsal:

    Zajimavy zapisek. @2, ten "vami napsany system alokace singletonu" teda vytvari neco jako service oriented architecture (inversion of control)?

  4. 9. 01. 2012, 12:22 Ondřej Novák napsal:

    @3 nevím jak to bylo myšleno, ale asi jsem to špatně popsal. Spíš jde o to, že řeším po svém problém, kdy inicializace statických proměnných uvnitř funkce ... kdy se inicializuje prvním průchodem ... není MT Safe. Postupně z toho vznikla šablona, která dostane parametrem typ, ze kterého má udělat singleton. A tato šablona implementuje metodu getInstance(), která vrací instanci parametru, jenž se inicializuje při prvním zavolání a při každém další už pouze vrací její referenci. A to všechno MT Safe za pomocí spinlocku.

  1. 2 Trackback(s)

  2. Led 4, 2012: Alokátory a operator new v C++ - 1. zprávy
  3. Led 5, 2012: Linux Blog » Blog Archive » Alokátory a operator new v C++

Přidej komentář