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

23. 1. 2012 20:51 (aktualizováno) Ondřej Novák

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.

Sdílet