Root.czBlogyProgramátorské techniky nejen v C++

SEH v Linuxu (C++)

Ondřej Novák, 5. 09. 2012, 00:58 v kategorii Nezařazené,

Pokud programujete ve Windows, tak zkratka SEH vám asi není úplně neznámá. SEH znamená "Structured Exception Handling", jedná se o nadstavbu nad C/C++ a připomíná to klasické C++ výjimky. Začátek bloku se označuje klíčovým slovem __try a blok provedený při výjimce se nazývá __except. SEH disponuje ještě klíčovým slovem __finally

__try {
 //... chráněný blok
} __except( <expression> ) {
 //... handler
}

SEH je dostupný i v čistém C. Pokud tedy pod Windows zatoužíte po výjimkách, ale pracujete v čistém C, pak SEH je pro vás jediná možnost. V C++ máme k dispozici klasické výjimky, takže SEH až tak často nevyužijeme.

Na něco se však SEH hodí. Standardní C++ výjimky totiž nedokáží odchytnout výjimky generované operačním systémem. Odchytávají výjimky vzniklé výhradně vyhozením objektu příkazem throw. Pokud tedy potřebujeme odchytit výjimku generovanou operačním systémem (nebo přímo procesorem), nezbývá, než SEH použít. Takovou velmi typickou výjimkou odchytávanou pomocí SEH je C0000005 Access Violation, což je Windows obdoba SIGSEGV. Zejména při čtení hodnot z neověřených ukazatelů se SEH využije opravdu často.

SEH v Linuxu

Zatoužil jsem mít něco podobného v Linuxu. Opět se budeme držet C++. Implementace v C není tak "hezká" a čouhá z ní implementační detaily (jistě, budeme si hrát se setjmp a longjmp)

Kód pro tento článek jsem připravil zde: http://pastebin.com/edMU2NWQ

Nejprve ukážu použití na jednoduchém příkladě:

int main() {
    LinuxSEH::init();
    char *err = 0;
    __seh_try {
            *err = 'A';
    } __seh_except(signum) {
            std::cout << "signal: " << signum << " handled A" << std::endl;
    }
}

Zavedl jsem (pomocí maker) dvě nová klíčová slova __seh_try a __seh_except (__try je bohužel v GCC již obsazeno a nejde o SEH)

__seh_try uvozuje chráněný blok, kde v případě, že vyletí signal SIGSEGV (případně SIGBUS nebo SIGILL)  dojde k vyvolání handleru uvedeného za __seh_except. Druhé klíčové slovo vyžaduje jméno proměnné, do které se uloží číslo signálu, který způsobil vyvolání handleru. Na rozdíl od klasického signal-handleru v Linuxu, je blok za __seh_except naprosto bezpečný a lze v něm používat i synchronní operace. Dokonce z něj lze vyhodit i C++ vyjímku, což z linuxového signal-handleru nelze. V příkladu vidíme, že handler se zavolá při pokusu zapsat na adresu 0 a spořádaně vypíše chybovou hlášku.

Jak je to implementované?

Když se podíváte do kódu (http://pastebin.com/edMU2NWQ), tak najdete dvě makra a  jednu třídu.  Ty makra vypadají trochu divně:

#define __seh_try for (LinuxSEH __sehContext;__sehContext.enter();) if (__sehContext.catchRes(sigsetjmp(__sehContext,1)))

__seh_try využívá příkazu for k deklaraci kontextu, který platí uvnitř bloku for. Metoda enter() zajistí, že cyklus proběhne právě jednou. Za příkazem for je klasický if, který volá sigsetjmp. Tato funkce vrací nulu, pokud je zavolaná poprvé, a jinak vrací hodnotu nastavenou funkcí siglongjmp ze signal handleru. Výsledek odchytne metodou catchRes a uloží se do kontexu. Za klíčovým slovem zpravidla následují složené závorky zahajující celý chráněný blok

#define __seh_except(x) else if (int x = __sehContext.except())

__seh_except využívá konstrukci else if k deklaraci proměnné, do které se uloží odchycené číslo signálu. Zároveň se volá funkce except(), která provede vyčištění kontextu po odchycení signálu. Protože funkce nikdy nevrátí nulu, je podmínka vždycky pravdivá.

Řetězení.

Aby iluze SEH byla dokonalá, je třeba umožnit bloky řetězit. Toho se dociluje vytvářením spojového seznamu uložených kontextů. Přitom si je třeba uvědomit, že chráněná část je pouze ta v bloku __seh_try. Pokud dojde k výjimce v bloku __seh_except, musí se vyvolat nadřazený handler. Viz příklad:

int main() {

	LinuxSEH::init();
	char *err = 0;
	__seh_try {
		__seh_try {
			*err = 'A';
		} __seh_except(signum) {
			std::cout << "signal: " << signum << " handled A" << std::endl;
			*err = 'B';
		}
	} __seh_except(signum) {
		std::cout << "signal: " << signum << " handled B" << std::endl;
	}
}

Tady se záměrně vyvolává SIGSEGV v prvním handleru. Jeho zpracování pak přebírá druhý handler. Tuhle funkcionalitu zajišťuje funkce except() v kontextu, která v případě, že je zavolána, odebere svůj kontext z vrcholu řetězu a nechá tam kontext předchozí. Pokud k výjimce nedojde, o úklid se stará destruktor kontextu.

Pokud dojde k SIGSEGV mimo jakýkoliv try-except, program normálně spadne jako když žádnou obsluhu na tento signál nemáme.

Multithreading

Linux zajišťuje kontext signal handleru jen u signálu SIGSEGV, SIGBUS a SIGILL. Ostatní signály se mohou zpracovat v libovolném vlákně. Aby bylo možné určit, do kterého vlákna v rámci handleru pomocí longjmp skočit, používá se deklarace __thread u globální proměnné určující vrchol zásobníku zřetězených kontextů. Každé vlákno má tedy vlastní řetěz. Proto je celá implementace thread-safe (dokud se nesnažíme odchytávat jiné signály).

Nevýhody

Najdou se. Zejména v C++ je třeba dát pozor na to, že při přerušení try bloku se nevyvolají destruktory  objektů vytvořených v zásobníku. Tohle ale nedělá ani SEH ve Windows, takže Windowsáka by to nemělo překvapit. Proto SEH nasazujeme tam, kde máme již objekty vytvořené, nebo je můžeme explicitně zničit v handleru. Další nevýhodu vidím v tom, že zpracování klíčového slova __seh_try je komplikované (ukládání stavu zásobníků, atd) a tedy nás bude velice zdržovat, zvlášť pokud blokem chráníme například jedinou operaci čtení z neznámého ukazatele. Windows SEH je o dost rychlejší, protože všechno řeší až jakmile nastane výjimka. Pouze před vstupem do __try si označí jedním ukazatelem stav zásobník a ten si uloží do TLS.

Tato implementace neřeší náhradu klíčového slova __finaly. S tím už si poradíte.

Výhody

Kromě toho, že to má nahradit Windows SEH v Linuxu, tak to za vás řeší obligátní situace "a tady potřebuji odchytit signál - kruci, vůbec se mi do toho nechce tam teď zavádět globální signal-handlery," zvlášť pokud je vaše prostředí přísně objektové. Samozřejmě, že odchytávání signálů pomocí této SEH emulace je přehlednější z hlediska hodnocení přehlednosti výsledného zdrojového kódu (chráněná část a handler jsou u sebe)

 

Jak zakázat explicitní fsync (v Ubuntu)

Ondřej Novák, 9. 06. 2012, 23:43 v kategorii Nezařazené,

Sync, fsync, fdatasync, díváte se na jednu trojici z mnoha zel, které na linuxu občas potkávám. Tyto příkazy obecně mají zaručovat programu, že všechny data která do souboru zapsal, budou fyzicky zapsána na disk. A teď by se mohlo diskutovat o tom, jestli vůbec má uživatelská aplikace (čili program běžící v userspace) právo takto přímo manipulovat s HW daného počítače. Ano, všechny tři příkazy nařizují operačnímu systému, aby syncnul cache s diskem, zpravidla aby vysypal obsah dirty stránek v cache na disk a to okamžitě, bez ohledu na to, co zrovna operační system dělá.

Nebudete mi věřit, ale už jsem se setkal s filesystemem, který častý fsync dokonale položí. Ne, nezhroutí se, ale stane se nepoužitelným. Je to btrfs, který používám zejména kvůli schopnosti vytvářet subvolumy a také kvůli kompresi, což se zejména pro SSD disky zasouvané do USBčka (flash disky), celkem hodí. Ony ty flashky nejsou nějak extra rychlé, ale se zapnutou kompresí se s tím dá celkem bez problému žít.

Nechci otvírat diskuzi, zda fsync ano, nebo ne. Já si myslím, že ne, že aplikace nemá co kecat do nastavení operačního systému a jeho politiky práce s diskem. Diskové cache jsou pro aplikaci transparentní a to i ve vztahu s jinými aplikacemi, takže i bez explicitního syncování by měl být obsah souborů koherentní.

Jak tedy vypnout fsync na linuxu? A jde to vůbec? Nebudu k tomu potřebovat nějaké speciální dovednosti, jako například schopnost kompilovat jádro? Naštěstí to není tak těžké. Na níže uvedený postup jsem narazil, když jsem hledal důvod, proč dpkg je na btrfs tak pomalé. Postupně jsem vygooglil několik návodů, všechny založené na knihovně, kterou budeme načítat pomocí LD_PRELOAD. Knihovnu si lze jednoduše vyrobit.

libnosync.c

#include <unistd.h>

void sync (void) {
}

int syncfs (int fd) {
 return 0;
}

int fsync (int fd) {
 return 0;
}

int fdatasync (int fd) {
 return 0;
}

Kód přeložíme příkazem:

gcc -O2 -s -Wall -fPIC -shared -o libnosync.so libnosync.c

A jako root si jej nakopírujeme například do /usr/local/lib/libnosync.so

Aplikace, kterým chceme zakázat používání fsync a spol, budeme spouštět přes nastavenou proměnnou LD_PRELOAD

LD_PRELOAD=/usr/local/lib/libnosync.so apt-get install ubuntu-desktop

(mimochodem, bez libnosync se ubunu-desktop na btrfs instaluje klidně i několik hodin, po zapnutí libnosync to zvládne za několik minut).

Zkusil jsem s tím experimentovat a takto jsem si pustil Firefox. A vida, najednou je Firefox svižný i na flashce, a vůbec se mi nestává, že by na několik desítek sekund tuhnul (s vysokým I/O) při přecházení mezi stránkami, nebo pouštění videa. Po menší upravě (zvětšení) limitů dirty_pages a spol. (viz /proc/sys/vm/...), už ani nepoznám, že jedu přes flashku.

Šlo by tuhle knihovnu vnutit každému spuštěnému programu tak, abych se explicitního syncování zbavil nadobro...?

Teď stop! Určitě někoho napadla otázka, zda takový zásah nebude mít vliv na stabilitu celého systému. Je nutné si uvědomit, že vypnutí explicitního syncování pomocí knihovny nebylo syncování zrušeno nadobro. Pořád má operační systém možnost syncovat při uzavírání souborů, při otevření souboru se zapnutým O_SYNC a samozřejmě, že explicitně syncovat může i jádro (umount, atd). Takže si nemyslím, že by to mohlo ohrozit stabilitu systému. Maximálně tak stabilitu některých aplikací - i když, takový rozbitý dpkg není žádný terno.

Napadla mě spousta šíleností, například rvát LD_PRELOAD do .bashrc, dále do .xinitrc... do /etc/profile nebo /etc/environment. Všude to nefungovalo úplně dobře, zpravidla se zrychlilo jen něco, ale například dpkg spouštěný přes synaptic spuštěný přes sudo dál jel svou šnečí rychlosti. Nakonec jsem v man ld.so objevil zmínku o souboru /etc/ld.so.preload. Nyní již máte dostatek indicií, abyste přišli na to, co s tím dál.

Tenhle soubor ve standardní instalaci nenajdete. Ale přes strace zjistíte, že se hledá. Pokud jej vytvoříte a vložíte do něj cestu na knihovnu "so", bude se poctivě načítat pro každý spuštěný proces. To si lze také ověřit přes strace.

Náhrada chybějícího finally v C++11

Ondřej Novák, 6. 06. 2012, 14:10 v kategorii Nezařazené,

Znám dost kolegů programátorů (a internetové diskuze jsou jimi plné), kteří nelibě nesou neexistence klíčového slova finally v bloku try-catch.

Blok uvozený klíčovým slovem finally se provadí vždy, bez ohledu, zda blok try skončil nebo neskončil výjimkou. Takový blok najdeme v Javě a hodně se využívá.

Argumentem, proč "ne" finally v C++ je ten, že všechny proměnné deklarované uvnitř bloku try by měly dodržovat RAII idiom. Teoreticky je tedy blok finally zbytečný, praxe však bývá poněkud odlišná. Leckdy totiž převod algoritmů z ne-RAII do RAII znamená víc práce, než je výsledný přínos. Navíc se nám může stát, že vytvoříme wrappery wrapperu, což zrovna na přehlednosti nepřidá.

Mnohem lépe se to řeší v C++11 díky lamba funkcím. Nejlépe to asi vysvětlí příklad.

template<typename Fn>
class Finally {
public:
 Finally(Fn fn):fn(fn) {}
 ~Finally() {fn();}

 Finally& operator=(const Finally &other); //* - pozn. na konci
 Finally(const Finally &other) ;  //* - pozn. na konci
protected:
 Fn fn;
};

template<typename Fn>
Finally<Fn> finally(Fn fn) {
   return Finally<Fn>(fn);
}

void finallyTest() {
 FILE *k = fopen("test.txt","w");
 auto close_k = finally([k]{ fclose(k); });

 fprintf(k,"Hello World\n");
 //close_k se nyní vykoná, soubor se uzavře
}

záznam z strace:

open("test.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
fstat(3, {st_mode=S_IFREG|0664, st_size=0, ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f0cb6368000
write(3, "Hello World\n", 12)           = 12
close(3)                                = 0
munmap(0x7f0cb6368000, 4096)            = 0

Pro jistotu ještě přidám popis. Šablona Finally popisuje třídu s destruktorem, který volá funktor dodaný konstruktorem. Instance třídy si pamatuje funktor po dobu svého života. Tím je zaručeno, že se funktor vyvolá při destrukci této instance. Funkce finally() se používá ke snadné konstrukci instance. K uložení výsledku použijeme proměnnou deklarovanou jako auto, to aby nebylo potřeba zjišťovat typ výsledné konstrukce. Jako parametr funkce finally použijeme lambda funkci která se postará o uzavření zdroje, v tomto případě souboru. Schválně jsem volil Céčkovský FILE, který je klasickým ne-RAII "objektem".

Tímto způsobem lze zajistit uzavření všech ne-RAII objektů a to bez ohledu jakým způsobem se opustí vykonávání funkce, takže to funguje jak na běžné returny uprostřed kódu, tak na výjimky.

Má to nějaké nevýhody? Jistě se nějaké najdou. Asi si všimnete, že ukazatel na soubor je v zásobníku uložen dvakrát. Naštěstí se jedná jen o ukazatel, takže to nestojí mnoho místa. Podobně se lze chovat k Windows HANDLům nebo k linuxovým FDs. Cokoliv většího ale může být problémem, zvlášť, pokud se pohybujete v prostředí malých zásobníků (nějaké jednočipy a tak), nebo generujete velké množství rekurzí.

Asi bych přesto doporučil vždy dávat přednost čistému RAII řešení, než takto vylepšovat kód. Jak jsem však napsal na začátku, tam kde by wrapování bylo na úkor čitelnosti, tam bych to použil.

Důležitá poznámka *: Takto navržený kód využívá optimalizace předávání návratové hodnoty z funkce finally(). V překladačích, které jsem testoval s tím nebyly problémy (VC2010, GCC 4.6). V zásadě jde o to, že překladač nemusí použití pro předávání výsledku z funkce finally() kopírovací konstruktor a operátor přiřazení. Tím nedojde ke spuštění destruktoru při opouštění funkce finally(). Nicméně to není podle normy. Proto jsem taky deklaroval kopírovací konstruktor a operátor přiřazení do třídy Finally, ale bez těla. Pokud je vypneme, nebo přesuneme do private sekce, bude se překladač bránit a kód nepřeloží. Takto se kód přeloží avšak je linker nebude hledat, protože se obě funkce nepoužijí. (viz Vracíme z funkce objekty) Pakliže vám linker ony dvě funkce hledá, pak je nutné třídu Finally přepsat s využitím move konstruktoru (&&) a se schopností deaktivovat destruktor po přesunu instance z funkce ven. Tím to ale trochu ztrácí na eleganci. Osobně bych to řešil podmíněným překladem podle typu překladače.

Skrytá úskalí vícenásobné dědičnosti v C++

Ondřej Novák, 6. 05. 2012, 23:34 v kategorii Nezařazené,

Upozorňuji dopředu, že nehodlám zde probírat to, co všichni programátoři v C++ určitě znají a co si mohou přečíst na milionech stránkách, které nabídne google. Myslím tím zejména ony problémy s diamantovým děděním a jak správně pracovat s virtuální dědičností. Při práci s generikou, kdy se hojně používají "prázdné třídy" časem narazíme na další komplikaci, které i zkušeného programátora překvapí a možná mu trošku zkomplikují život.

Prázdná třída

V generickém programování (to jsou takové ty programy, ve kterých se hojně vyskytuje klíčové slovo template) postupem času asi každý programátor nalezne zalíbení ve vytváření prázdných tříd. To jsou třídy, které obsahují deklarace, nebo definice metod, avšak neobsahují žádné proměnné, ani virtuální metody. Objekt takové třídy by ve skutečnosti neobsadil žádnou paměť, protože jeho instance vlastně nic neobsahuje. Velmi často používáme prázdné třídy k vytváření alokátorů nebo porovnávačů

class CmpItem {
public:
    bool operator()(const Item &a, const Item &b) const;
};
typedef std::set<Item, CmpItem> MujSet;

Kdo se s tím ještě nesetkal, toho překvapí, že dotaz na sizeof(CmpItem) vrátí hodnotu 1, ačkoliv třída je prázdná a neobsahuje jedinou proměnnou. Je to přitom záměr. Představte si, že by taková třída propadla zkrz parametr šablony do nějakého pole. Pokud by velikost třídy byla nastavena na nulu, všechny hodnoty v poli by měly stejnou adresu a pole by také mělo velikost 0 bajtů. Převod ukazatele na index pak nutně vede na dělení nulou. Tyto důvody a dalších N podobných komplikací (například, že dvě různé instance stejné třídy nesmějí sdílet stejnou adresu v paměti) vedly autory norem k pravidlu, že instance každé třídy musí alokovat vždy minimálně 1 bajt paměti. Stejně se například chová malloc(0) ... alokuje 1 bajt a vrátí jeho adresu.

Prázdná třída a dědění

Pokud použijeme prázdnou třídu jako členskou proměnnou, zabere 1 bajt v rozvržení instance třídy. Pokud je taková proměnná umístěna mezi vícebajtové proměnné, které je třeba zarovnat na dělitelnou adresu, pak překladač za takovou proměnnou umístí povinný padding. Na 32bitové platformě to znamená, nikoliv 1 bajt, ale 4 bajty! Dost vyplýtvaného místa na prázdnou třídu nemyslíte?

Tomuto plýtvání lze zabránit tak, že namísto proměnné budeme prázdnou třídu dědit.

class A {};
class B {
    A a;
    int x;
};
 class C: public A {
   int x;
};

GCC vám řekne toto:

sizeof(A): 1
sizeof(B): 8
sizeof(C): 4

Račte si to vyzkoušet:  (codepad). Příklad krásně demonstruje to co jsem napsal. Proměnná a má skutečně velikost 1 a protože x musí být zarovnáno, je za proměnnou a doplněn padding. Instance třídy pak zabere 8 bajtů. Pokud však třídu A podědíme, zmenšíme velikost instance na poloviční velikost. Za třídu A si lze dosadit třídu dodanou šablonou, například výše uvedený příklad CmpItem, který může (ale nemusí) být deklarován prázdný (například při řazení indexů v databázi bude obsahovat odkaz na kontejner dat, jelikož index obsahuje pouze indexy ... což jsou vlastně jen čísla, která bez odkazu na data nelze řadit).

Vícenásobná dědičnost.

Pokud máme možnost do šablony dodat jen jednu třídu, která může být (běžně) prázdná  (například CmpItem), lze to obejít děděním. Co když těch tříd máme víc? Můžeme použít vícenásobné dědění? Zdálo by se logické, že to bude fungovat obdobně.

Ve skutečnosti to je problém, protože to nezafunguje tak, jak bychom si představovali. Navíc se implementace liší podle platformy a překladače. K demonstraci jsem si připravil následující příklad:

#include <iostream>

class A1 {};

class A: public A1 {};

class B1 {};

class B: public A1, public B1 {};

class C1 {};

class C: public A1, public B1, public C1 {};

class D1 {};

class D: public A1, public B1, public C1, public D1 {};

class E1: public A1, public B1 {};

class E: public E1 {};

class F1: public A1, public B1 {};

class F: public E1, public F1 {};

class G1: public A {};

class G: public E1, public G1 {};

class H: public C, public D {};

class I: public H, public G {};

int main(int argc, char* argv[])
{
 std::cout << "sizeof(A): " << sizeof(A) << std::endl;
 std::cout << "sizeof(B): " << sizeof(B) << std::endl;
 std::cout << "sizeof(C): " << sizeof(C) << std::endl;
 std::cout << "sizeof(D): " << sizeof(D) << std::endl;
 std::cout << "sizeof(E): " << sizeof(E) << std::endl;
 std::cout << "sizeof(F): " << sizeof(F) << std::endl;
 std::cout << "sizeof(G): " << sizeof(G) << std::endl;
 std::cout << "sizeof(H): " << sizeof(H) << std::endl;
 std::cout << "sizeof(I): " << sizeof(I) << std::endl;
}

Výsledky, které vrací překladač od Microsoftu, Visual C++ 2008:

sizeof(A): 1
sizeof(B): 1
sizeof(C): 2
sizeof(D): 3
sizeof(E): 1
sizeof(F): 3
sizeof(G): 2
sizeof(H): 6
sizeof(I): 9

Jiná čísla vrací GCC

sizeof(A): 1
sizeof(B): 1
sizeof(C): 1
sizeof(D): 1
sizeof(E): 1
sizeof(F): 2
sizeof(G): 2
sizeof(H): 2
sizeof(I): 4

Rozebereme si nejprve výsledek od Microsoftu. Vzhledem k tomu, že jsem nikde nenašel oficiální popis, budu vycházet z mého pozorování. Podle všeho Visual C++ započítává každou další třídu, ze které se dědí, jako členskou proměnnou o jednom bajtě. To je krásně vidět na výsledcích pro třídy A - D. Zatímco třída A má velikost 1 "z povinnosti", velikost třídy B je opravdu 1 bajt, protože pro dědění z B1 je tento bajt alokován. Přitom jde evidentně o zbytečnou operaci, protože obě podtřídy budou mít ukazatel stejné hodnoty. V případě třídy C je nutné započítat už dva bajty za dvě třídy navíc. Podobně je na tom třída D. Z další analýzy se zdá, že každá třída navíc navyšuje počet alokovaných adres pro prázdnou třídu. Aby jsme si udělali přehled, je nutné provést tranzitivní uzávěr celé hierarchie a označit si třídy, které jsou ve vícenásobné dědičnosti navíc. Rozepíšeme si třídu I:

I,H,G,C,D,E1,G1,A1,B1,C1,A1,B1,C1,D1,A1,B1,A,A1

Tučně jsem označil třídy, které se nachází na druhém a dalším místě v deklaraci vícenásobné dědičnosti. Vidíte, že jich je devět, a z toho vyplývá devět alokovaných bajtů pro celou třídu I

Proč tohle Visual C++ dělá opravdu netuším. Ale podle mne se to možná vyjasní, pokud se podíváme na výsledek GCC. Podle mého názoru se Visual C++ snaží zabránit situaci, kdy některé třídy, které se v rozložené hierarchii opakují, by mohly sdílet stejnou adresu, přestože jde o různé instance... a jak víme, různé instance nesmějí mít stejnou adresu, viz první kapitola. Nicméně způsob, jakým se to řeší, není úplně nejšťastnější.

To GCC není vícenásobnou dědičností nijak rozhozeno a i pro třídu D vrací velikost 1.  Teprve třída F, ve které se dvakrát vyskytují třídy A1 a B1 alokuje dvě adresy. Opět se podíváme na třídu I:

I,H,G,C,D,E1,G1,A1,B1,C1,A1,B1,C1,D1,A1,B1,A,A1

Tady jsem podtrhl třídu A1, tučně označil třídu B1 a kurzívou třídu C1. Vidíte, že po rozbalení třídy I zde máme 4x třídu A1. Protože se nejedná o virtuální dědění, žádný diamant se zde nekoná, a třída A1 je zde zastoupena čtyřmi instancemi, kde každá instance by měla ležet na jiné adrese. Zajímavé je, že GCCčku není proti srsti, že stejnou adresu jako A1 bude pravděpodobně sdílet i B1. Jde vlastně o objekt A1,B1,C1, který dohromady může alokovat pouze jednu adresu. Jiný objekt A1, B1 okupuje druhou adresu, a tak dále. Rozložení podtříd do adres můžeme zobrazit třeba takto.

+0 I,G,G1,A,A1
+1 E1,A1,B1
+2 H,D,A1,B1,C1,D1
+3 C,A1,B1,C1

Neověřoval jsem to přesně, ale cílem je, aby se žádná třída neopakovala na jednom řádku. Samozřejmě, že celá problematika bude složitější v tom, že nestačí jen dodržet pravidlo nesdílení adresy pro stejné podtřídy, ale zároveň každá potřída musí fungovat samostatně, takže počet alokovaných adres může být i vyšší. Například, třída G alokuje 2 adresy, tedy musí alokovat offset 0 a offset 1. Třída H také alokuje 2 adresy, zbývají pro ně offset 2 a offset 3.  Ostatní třídy alokují jednu adresu a tak se musí naskládat ke svým nadtřídám na každém řádku. Celé to pak tvoří třídu I, která začíná na offsetu 0 a zabírá 4 bajty.

Linuxovou verzi si můžete opět vyzkoušet na codepadu

Závěr

No na závěr bych napsal toto: Nepřehánět to s vícenásobnou dědičnosti. Pokud jsem si doposud šablony skládat přes vícenásobnou dědičnost do jedné souhrnné šablony, tak jsem si neuvědomoval tento problém a žil jsem v domění, že to překladač nějak dobře zoptimalizuje. Nedělo se tak. A tak se jednoho krásného dne stalo, že jsem objevil podivný padding u třídy ConstStrA, která by se dala zkráceně zapsat takto:

class ConstStrA {
public:
      const char *str;
      unsigned int length;
};

A ačkoliv z tohoto pohledu má mít třída pouhých 8 bajtů, ve skutečnosti má bajtů 16, protože není takto jednoduše deklarovaná, ale jedná se o kombinaci několik šablon (například lze změnit char za jiný typ, přes další šablonu se tam importuje rozhrani pro práci s řetězci). Výsledná třída má před první proměnnou 4 bajtový padding, a stejný padding se nachází na konci. To vše vytvořilo vícenásobné dědění prázdných tříd různě po cestě navěšených (a aby to nebylo všechno, při předávání instance třídy se všechny bajty, včetně nepoužívaného paddingu, poctivě kopírují na zásobník, Visual C++)

  • Pro představu složitosti, fragment třídy ConstStrA (ConstString<T>) jsem umístil na pasteBin: http://pastebin.com/i3gLRZrr. Už tady najdete důvod toho druhého zbytečného paddingu. A to nechtějte vidět, jak vypadá třída ArrayRef<> a kolik podtříd je v ní schováno.

Je hlavně podivné, jak vícenásobnou dědičnost řeší Microsoft Visual C++. Předpokládám, že v normě C++ není jasně definován layout tříd, které používají vícenásobnou dědičnost v souvislosti s prázdnými třídami. I z toho důvodu musí člověk být opatrný, pakliže chce prázdné třídy používat jako nástroj generického programování. Čas od času se mu může důsledek použitého návrhu šablony zásadně promítnout do výsledného kódu.

Tvoříme „vláčky“ nejen v C++

Ondřej Novák, 25. 01. 2012, 01:00 v kategorii Nezařazené,

Dnes bych se chtěl ve svém krátkém blogpostu podělit o programovací techniku, kterou nazývám "vláček". Myslím, že to nebude nic neznámého, je to věc, která se dá uplatnit nejen C++, ale většinou u všech objektových jazyků, kde je volání prováděno zkrze zápis objekt.metoda(), jako třeba Java. V některých jazycích najdeme vláčky v základu (smalltalk - říká se tomu "message chain").

Asi nejznámější vláček v C++ pozná každý, je to zápis do std::cout.

std::cout << "Součet " << a << " a " << b << " rovná se " << c << std::endl;

Vláček poznáme snadno, většinou je na začátku lokomotiva, díky níž je vláček vláčkem. Kdyby tam lokomotiva nebyla, překladač by nám vynadal. Pak následuje větší množství vagonků, které jsou pospojované stejným způsobem, a na konci někdy bývá speciální ukončující vagón, tedy něco, co vláček ukončuje a tím způsobí nějakou akci. Nemusí nutně vždycky, záleží na tom, zda chceme do vláčku zapojovat další vagonky. A nebo, pokud si objekt nepřeje vytvářet neuzavřené vláčky, spustí se akce na prvním středníku (zpravidla díky destruktoru u pomocného objektu vytvořeném lokomotivou, který tvoří spojení mezi vagony).

Vláčky lze psát i jinak, než s použitím přetížených operátorů. Můžeme použít normální funkce a zápis může být flexibilnější a čitelnější. Například v minulém blogpostu jsem psal o jedné třídě na spouštění procesů. Ukážu vám příklad jejího použití právě ve schopnosti tvořit vláčky.

Process proc("cmd.exe");
ProcessEnv env = Process::getEnv();
///.... zde celkem nevýznamný kód pracující s env.
integer retval = proc
    .setEnv(&env)
    .arg("/C")
    .arg(fname)
    .arg(localBase)
    .workDir(localBase)
    .stdOut(execOutput)
    .stdErr(execOutput)
    .exec();

Nenechte se zmást, že vláček je rozepsán na víc řádků. Opět je na začátku lokomotiva, zde proměnná proc, za lokomtivou jsou napojeny vagónky různých typů. Najdeme zde vagónek nesoucí environment, pak následují argumenty procesu, nastavení pracovní složky, přesměrování výstupu do souboru a tím pomyslným ukončujícím vozem je zde metoda exec(), která nakonec určuje, jak se s vláčkem bude zacházet. Když se podíváte na začátek, najdete tam, přiřazení do proměnné retval. I toto specifikuje metoda exec().

Ukážeme si ještě další vláčky, třeba vláček ve vláčku:

PFactory_t f = connection.createJSON();
PNode_t r = f->newArray();
r->add(f->newObject()
   ->add(f,"url",x.url)
   ->add(f,"title",x.title)
   ->add("lastVisit",f->newArray()
    ->add(f,x.lastVisit.wYear)
    ->add(f,x.lastVisit.wMonth)
    ->add(f,x.lastVisit.wDay)
    ->add(f,x.lastVisit.wHour)
    ->add(f,x.lastVisit.wMinute)
    ->add(f,x.lastVisit.wSecond)));

Výše uvedený příklad představuje pole objektů obsahující atributy url, title a lastVisit, přičemž poslední proměnná je pole. Výsledkem je objekt uložený v JSONu.

Vláčky se nám hodí tam, kde potřebujeme k objektům nastavovat mnoho atributů, případně atributy nelze jednoduše nastavit konstruktorem. Příkladem může být třeba funkce na otevření souboru: openFile("foo.txt").create().truncate().append().lockExclusive().chmod(0644);

Jak deklarovat vláčkový styl.

Deklarovat vláčkový styl je celkem snadné. Stačí, aby každá metoda, která má vystupovat jako vagónek, vracela referenci na objekt, který měnila (jednoduše na this). Pouze poslední "vagón" buď obsahuje void, nebo vrací něco jiného.

class Prcoess {
public:
  Process &arg(const char *arg);
  Process &arg(natural number, natural base=10);
  Process &arg(integer number, natural base=10);
  Process &arg(float number);
  Process &arg(double number);
  Process &stdOut(const SeqFileOutput &stream);
  Process &stdErr(const SeqFileOutput &stream);
  Process &stdIn(const SeqFileInput &stream);
  Process &setEnv(const ProcessEnv *env);
  Process &workDir(ConstStrW path);
  int exec();
  ...
  ...
};

Pakliže potřebujeme, aby se akce automaticky provedla na prvním středníku, musí lokomotiva vrátit namísto sebe sama pomocný objekt, který obsahuje deklarace vagonku. Vagonky si nadale přenášejí pouze referenci na pomocný objekt. Tento objekt má také destruktor, který ve svém těle vyvolá předem danou akci. 

Ještě jede příklad - bezpečný SQL dotaz

Vláčkový styl jsem použil i pro sestavování SQL dotazů:

MySQL::Query_t q(...);

q("REPLACE stats (`datum`,`counter`,`product_ver`,`partnerId`,`value`) "
  "VALUES (CURDATE() - INTERVAL %4 DAY,%1,%2,%3,%5)")
   .arg(iter->first.code)
   .arg(iter->first.versionId)
   .arg(iter->first.partnerId)
   .arg(iter->first.dateModif+age)
   .arg(iter->second.value)
   .exec();

Tady máme trochu komplikovanou lokomotivu, protože obsahuje vlastní SQL dotaz. Funkce arg jsou patřičně přetížené, takže řetězce například escapují tak, aby se zabránilo SQL-Injecting.

 PS: kdyby to někdo hledal, oficiálně se tomu říká Method Chaining

 

Temná strana linuxu – spouštění procesů

Ondřej Novák, 21. 01. 2012, 01:38 v kategorii Nezařazené,

Jednoho dlouhého zimního večera jsem se rozhodl, že si napíšu v C++ třídu, která bude multiplatformě řešit spouštění procesů třetích stran z aplikace. Slovem multiplatformní mám na myslí hlavně Linux a Windows verzi. Nakonec se to povedlo, ale bylo to náročné, skoro jsem se začínal obávat, že univerzální rozhraní není možné napsat... tak aby tam byly roury, přesměrování, čekání na proces, nebo jeho paralelní běh. Nechci teď řešit kolik knihoven a v kterých balících se tohle řeší. Já si prostě chtěl napsat vlastní v rámci již mé existující knihovny, tak aby byla konformní s existujícím API.

Nebudu teď řešit rozdíly mezi Linuxem a Windows, ale podělím se se zkušenostmi vývoje Linuxové verze a zejména popíšu jaké pasti, pastičky a bezpečnostní nástrahy běžného vývojáře čekají a na co všechno je třeba si dát pozor, co není z počátku vidět.

1. Něco na zahřátí - příkaz system()

Pro začátečníky a na jednorázovky úplně postačí, ale musí se počítat s tím, že to funguje jak v DOSu. Process se spustí a ten kdo jej spouštěl musí čekat. Pokud by někdo chtěl řešit souběžný běh dvou procesů (mého a cizího) přes příkaz fork a system, tak to nedělejte. Vyrobíte totiž dva procesy na místo jednoho. (příkaz system() si sám zavola fork).

2. Fork / exec - Linuxová specialita

Tohle Windowsák prostě nepochopí. V Linuxu neexistuje příkaz na vytvoření nového procesu. Windowsovská varianta CreateProcess, tady prostě není. Procesy se zde spouští tak jaksi přes hlavu. Jako kdyby sekretářka vytvářela wordowské dokumenty tak, že z jednoho dokumentu vyrobí kopii, kterou následně otevře ve Wordu, obsah smaže a napíše dokument nový.

Přesně takhle to ale funguje v Linuxu. Zajímavý ne? No kdyby to byl jediný problém, tak se to dá celkem vydržet.

3. Stovky variant execů

execl, execlp, execle, execv, execvp, execvpe - jen si vyberte, který vám vyhovuje, podle toho, jak se zadávají argumenty, nebo prostředí. No klid, nic nevybírejte, nebudete to potřebovat, jediný příkaz, který má smysl používat, je execve, protože ten jediný zvládne spustit i bashový skripty, tak jak jsme zvyklí ze shellu. A já jako programátor nechci řešit, zda na druhé straně je binárka, shell-skript, python, nebo nedejbože péhápko.

4. Když se exec nezdaří

Protože budeme spouštět proces přes fork / exec, je třeba zajistit ošetření situace, kdy se exec nezdaří. Nemusí se zdařit, třeba proto, že v příkazové řádce je uvedena chybně cesta na binárku nebo, že binárka není přeložena pro aktuální platformu nebo, že nemáme práva ji spouštět. Zvlášť pokud pouštíme proces, který poběží v pozadí a naše aplikace s ním bude spojena rourou.

Proces spouštíme přes fork. V tom případě se rodič ještě nedozví o tom, zda se v dítěti nezdařil execve. Pokud rodič chce pokračovat až po tom, co si ověřil, že dítě úspěšně spustilo proces, bude muset použít dosti komplikovaný způsob synchronizace ... a to mě zrovna teď nenapdá, jak by se to udělalo. Jakým způsobem předám výsledek volání funkce execve v tomto kódu?

int i = fork();
if (i < 0) chyba();
if (i == 0) {
    execve(....);
    _exit(errno);
}

Pokud se execve zdaří, dítě už nikdy nedostane šanci informovat rodiče o úspěchu. Proto taky nemusím testovat návratovou hodnotu. Ta bude vždycky -1. Dítě ukončuji předáním errno, což zrovna není nic moc, protože při čtení statusu v rodiči nepoznám, zda návratová hodnota pochází z procesu nebo z nepovedeného execve. K řešení tohoto problému se dostanu v dalších bodech.

Poznámka: Samozřejmě že na to mohu kašlat, a rodič se může chovat jako by se to zdařilo. On mu totiž hned přiletí SIGCHLD, takže žádná křeč. Ale napište knihovní funkci, která po svém návratu může volajícímu s jistotou oznámit "je to okaj, požadovaný proces byl spuštěn a nyní běží"... či případně vyhodit výjimku která obsahuje kód chyby nastavené při neúspěšném execve.

5. Paměťová náročnost forku

Ve firmě, ve které pracuji a která je plná linuxáků, jsem narazil při vývoji linuxového backendu na problém, kdy mi fork() vyhazoval chybu, že je nedostatek paměti. Přičemž jediný, co jsem měl v plánu provést je spuštění wgetu v rouře s unzipem a to celé přivést rourou do aplikace (abych nemusel importovat HTTP klienta a zlib knihovnu). Šel jsem tedy za odborníky z vedlejšího oddělení, aby mi poradili, kde může být zádrhel.

No problém nebyl ani ve wgetu, ani v unzipu, ale v tom, že tou dobou moje aplikace zabírala v paměti slušných 1.5GB na virtuálu, který měl limit 2GB a se systémem tam zbývalo něco přes 300MB volného místa. Tam by se wget a unzip vešel, ale rozhodně se tam nevejde dalších 1.5GB mé aplikace. Ale co to plácám, vždyť fork přece neprovádí duplikaci, pouze nasdílí paměťové stránky přes Copy-On-Write! Taky vám tohle neustálě někdo vštěpuje do hlavy? Ona to pravda je, ale do hry tady vstupuje další linuxová specialita a to overcommit (o tom někdy jindy). Sice fork neprovede plnou kopii, ale nový proces by mohl časem těch 1.5GB požadovat a to ta mašina nemá k dispozici. Řeší se to tedy tak, že se na rezervaci prostoru pro nový proces pohlíží jako na obyčejný malloc, na který se vztahují pravidla overcommitu. Protože stroj měl volných 300MB a fork potřeboval vyhradit 1.5GB, musel by být overcommit nastaven aspoň na 500% a to nebyl.

Tohle všechno jsem se dozvěděl od našich linuxáků s tím, že pokud to chci jinak, tak mám prostě smůlu, Linux to jinak neumí. Ať pošteluju overcommit, nebo si zvětším virtual (pro unzip a wget?). No už vidím, jak naše konzervativní adminy přesvědčuju, že kvůli mé knihovně mají o hodně zvýšit overcommit na produkčních strojích.

Závěr: Pokud vaše aplikace potřebuje spouštět externí procesy, nesmí zabrat víc paměti, než je zhruba stejné množství volné paměti. Může to být trochu víc, ale pak záleží na tom, jak je nastaven overcommit. Psát univerzální knihovnu tak, že se předpokládá nějaké nestandardní nastavení systému, je ale prasárna.

6. Záchrana v podobě vfork

No v manpages jsem objevil vfork(2). Rychlým pohledem na google a do diskuzí člověk získá dojem, že lidi si nejsou úplně jisti, jak tahle funkce pracuje. Já mám tuto zkušenost:

Jedná se o virtual fork. Tváří se to jako fork ale není to fork. Je to fakefork. Pokud někdo hledá funkci CreateProcess, tak ji právě našel. Funkce vfork vytvoří nové dítě ale v paměťovém prostoru rodiče. Rodič je zablokován .... (a teď kontrolní otázka, co když má rodič vlákna, he?) ... a místo něho běží dítě, a to do doby, než se dítě ukončí, nebo zavolá execve. Co je tady zajímavé, totiž, že dítě má plný přístup do paměťového prostoru rodiče, a to i pro zápis!

vfork řeší dvě bolístky

  1. Paměťovou náročnost forku ... vfork nemusí nic slibovat, protože se nerezevují stránky
  2. Předávání návratové hodnoty z execve

... a navíc, vfork je pravděpodobně rychlejší, protože nedochází ke zbytečnému duplikování stránkovacích tabulek, které jsou následně zahozeny.

Podívejme se, jak se bude řešit návratová hodnota z vforku:

static int execerr;

execerr = 0;
int i = vfork();
if (i < 0) chyba();
if (i == 0) {
    execve(....);
    execerr = errno;
    _exit(0);
}
if (execerr) throw UnableToExecuteProcessException(execerr);

Zjednodušeně řešeno, dítě vzniklé po vforku() má přístup k proměnné execerr, která je alokovaná staticky (pokud tam má člověk vlákna, je lepší, když je alokovaná na haldě). Proměnnou není možné dát do zásobníku, protože vfork provede duplikaci zásobníku a změna proměnné by se nepromítla do zásobníku rodiče. Pokud execve selže, nastaví se proměnné execerr a dítě skončí. Pokud neselže, zůstane tam hodnota 0.

Důležitým detailem je také to, že rodič je počas vykonávání dítěte zastaven. Proto není nutné řešit synchronizaci, v okamžiku, kdy je rodič spuštěn, se už může dozvědět výsledek. Pokud je v execerr nula, pak bylo spuštění procesu úspěšné. Pokud není nula, pak bylo neúspěšné.

Kdybych tohle přirovna k sekretářce, tak zatímco použití forku znamená, že sekretářka zkopíruje dokument a ten nový otevře ve Wordu a smaže ho aby napsala nový, u vforku to udělá tak, že ve Wordu otevře starý (dokument se interně zamkne) a následně sekretářka vyvolá příkaz Save As s novým názvem a až potom dokument smaže a začne psát nový. Mezi otevřením a přeuložením je krátký časový úsek, kdy s původním dokumentem nelze pracovat.

Aby to nebylo tak jednoduché, vfork není standardní. Navíc pořád mě dost děsí výstraha v manuálu, kde prý zápis do proměnné vytvořené v rodiči není definován. Tady je prostor pro budouci změny, aby třeba časem to dítě nevznikalo někde v hyperprostoru, bez možnosti komunikovat s rodičem.

7. Roury a souborové popisovače - bezpečnostní jáma.

Co je to proboha popisovač? No přece deskriptor. Miluju český jazyk :-)

Krásně se demonstruje na dvojici fork/exec vytváření rour. V rodičovi vytvořím rouru, forknu to, v dítěti jednu stranu zavřu a druhou zduplikuju na některý z popisovačů 0 až 2, podle potřeby. V rodičovi pak zavřu opačný konec a ten co mi zbude používám pro I/O s dítětem. Funkce execve má tu vlastnost, že zachová otevřené všechny souborové popisovače, čímž umožňuje realizovat právě onu nádherně vyvedenou pupeční šňůru mezi rodičem a dítětem.

(A to není všechno, fork popisovače duplikuje, takže si každý proces spravuje vlastní sadu popisovačů nezávisle na sobě. To vůbec nehledejte u Windows, kde se popisovače ... HANDLE... sdílí a to takovým způsobem, že když rodič popisovač zavře, automaticky se zavře v dítěti a další operace s ním vedou na chybu "neplatný popisovač! Druhým rozdílem je, že když v Linuxu skončí dítě, zavřou se všechny jeho popisovače a roury spadnou do stavu SIGPIPE. Ve Windows zůstanou handly otevřené a čteni z rour bude viset v deadlocku!)

Edit: Tvrzení ve výše uvedeném odstavci se nepodařilo ověřit.

Až sem je to růžová zahrada, Linux rulez! Jenže ouha. V aplikaci máte otevřeno několik souborů, pár rour, a nějaká ta síťová spojení, třeba takový ... ssh démon, který vytvoří dítě, spojí se s klientem a spustí bash. Jenže se nezduplikují jen ty popisovače, které chceme, ale všechny. Pokud nevíme nic o procesu, který naše aplikace spouští, koledujeme si o malér. Ten proces totiž může být nějaký malware, nebo jiná forma škodlivého zvířátka, které po svém spuštění udělá scan otevřených popisovačů. To není tak těžký, všechny mají IDčka někde kolem první stovky. Teoreticky se může dozvědět, jaké zdroje tyto popisovače představují. Mohou například manipulovat s ukazatelem pozice v souborech, které rodič čte a donutit rodiče udělat něco nepředvídatelného, nebo získat přístup k datům, ke kterým normálně nemusí mít práva.

A zatím co v růžovce může maximálně řádit doktor Mázl, tady může řádit někdo mnohem horší. Nejde o bezpečnostní díru, ale přímo o jámu. Co s tím?

Taky mě napadlo, že bych před spuštěním procesu provedl uzavření všech popisovačů kromě 0,1 a 2. Hmm, ale jak se dozvím, kolik jich je a jaké mají IDčka. Na jednom foru jsem se dozvěděl, že nějaká verze ssh uzavírá všechny popisovače od 3 do tuším 64 to spekulativně. Co kdyby tam něco bylo otevřeného. Taky řešení... Některé unixy oplývají funkci closefrom(3). Pokud ale chcete dát uživateli knihovny možnost vytvářet i další roury nad rámec prvních tří, pak neshledáte tuto funkci užitečnou.

Je to celkem nedávno, co v Linuxovém jádře zavedli příznak FD_CLOEXEC . Pokud tento příznak nastavíme přes fcntl, pak nám execve takový popisovač zavře. Takže je dobré si na to dát pozor a po vzniku jakéhokoliv popisovače (po otevření souboru, socketu, roury,...) ihned nastavit FD_CLOEXEC přes fcntl...

Dobrý nářez co? Zvedněte ruku, kdo tohle někdy řešil? Jo a to není všechno, pokud máte vlákna a zrovna tu smůlu, že mezi otevřením popisovače a nastavením příkazu jiné vlákno stihne udělat execve...? Postupně se objevují příkazy, které to umí v jednom kroku... třeba open(2) má příznak O_CLOEXEC. Nebo pipe2(2), nebo dup3(2). Mimochodem, dup nezduplikuje nastavení příznaku FD_CLOEXEC, takže výsledkem je opět popisovač, který lze ihned nasdílet do cizího procesu. A funkce dup3 nebyla k dispozici ještě na lennym, jak je to na sqeeze jsem nezkoušel.

8. Čekání na ukončení procesu

bool Process::join(const Timeout &tm);

Tak vypadá prototyp funkce která počká na ukončení dítěte. Lze nastavit i timeout. Timeout? Nezbláznil jsem se? Přece funkce waitpid žádný timeout nemá... ?

Mimochodem, Windows rulez: WaitForSingleObject(hProcess,timeout);

No nic, vypadá to nějak takhle:

natural tmms = 1;
int status;
pid_t e = waitpid(ctx.processPid,&status, WNOHANG);
while (e == 0) {
   if (tm.expired()) return false;
   Thread::sleep(tmms); //v milisekundách
   if (tmms < 500) tmms++;
   e = waitpid(ctx.processPid,&status, WNOHANG);
}

Nebudu ani vysvětlovat co je ta omáčka kolem, princip je asi jasný. Jasně, že by asi šlo použít signál, ale tam je obtíž s tím, že vám SIGCHLD přiletí při každém ukončeném dítěti (nikoliv jen při jednom konkrétním) a v aplikaci složené z vláken bude ještě problém zjistit do kterého vlákna ten signál přiletí a kterou zrovna činnost to přeruší. Takový handlíř tohoto signálu bude asi superglobální objekt a bude muset znát všechny existující instance třídy Process, aby mohl příslušnou třídu informovat, že její proces skončil. Pěkně se to komplikuje.

9. Čekání na proces spuštěný někým jiným?

... nechte si zajít chuť. To v Linuxu nejde. Jak vypadají init skripty?

  1. kill process
  2. běží proces?
  3. ano, počkej sekundu, nakresli tečku a goto 2.
  4. ne, ohlaš úspěch

Ve Windows? WaitForSingleObject(hProcess, timeout)

Už budu končit

Spouštění a základní správa procesů v linuxu není zrovna jednoduchá záležitost. Je to prostě krásná ukázka toho, jak jednoduchou věc dělat složitě. Ano, jistě, stojí za tím nějaký historický vývoj, snaha udržet kompatibilitu s posixem a s původními unixy. Ale to se na tom podepisuje na každém kroku. Je otázkou, zda je nutné neustále držet historické API a ohýbat ho na všechny strany podle posledních trendů, nebo vytvářet nová API a ta stará pouze nějak emulovat.

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ů.