Atomicita operací

Školení, která pořádám

Při psaní webových aplikací je nutné mít na paměti, že posloupnost operací obecně není atomická – během jejího provádění se může ke slovu dostat jiný proces webového serveru a provést si svoji posloupnost nebo její část. U málo vytížených aplikací k tomu dojde málokdy, u více vytížených to naopak vede k velice špatně reprodukovatelným a opravitelným chybám typu: „Čas od času se stane, že nějaká funkce nefunguje, a při novém načtení je všechno zase v pořádku.“

Při psaní kódu je proto potřeba přemýšlet o tom, co se stane, pokud se mezi dvěma operacemi dostane ke slovu jiný proces. Pokud je posloupnost operací AB, může být posloupnost u dvou procesů A1B1A2B2, A1A2B1B2 nebo A1A2B2B1. O problému jsem se zmiňoval už při ověřování unikátnosti uživatelského jména nebo ukládání dat do souborů, dnes problém ukážu na jednoduchém cachování stránek.

Cachování navrhneme tak, že odkazy budeme směřovat na soubory s koncovkou .html a když tyto soubory nebudou existovat, tak je pomocí direktivy ErrorDocument vygenerujeme skriptem:

<?php
header("HTTP/1.1 200 OK");
ob_start();

// běžný výpis stránky

$fp = fopen(basename($_SERVER["REQUEST_URI"]), "w");
fwrite($fp, ob_get_flush());
fclose($fp);
?>

Na první pohled je vidět, že problém je mezi funkcemi fopen a fwrite – pokud mezi provedením těchto dvou operací požádá o stránku jiný proces, bude už soubor existovat, takže se obslužný skript nespustí, ale zároveň bude prázdný, takže se stránka nezobrazí.

K vyřešení tohoto problému svádí použít funkci file_put_contents dostupnou v PHP 5, tím se ale problém jen přesune o úroveň níž. Funkce je totiž také implementována jako neatomická posloupnost operací a i když ji zavoláme s příznakem LOCK_EX, může dojít k přečtení souboru mezi jeho vytvořením a zamknutím. Řešením by bylo vytvořit a zamykat si jiný soubor, o něco elegantnější je ale myslím následující řešení:

<?php
$tempnam = tempnam($_ENV["TEMP"], "html");
file_put_contents($tempnam, ob_get_flush());
if (!@rename($tempnam, basename($_SERVER["REQUEST_URI"]))) {
    unlink($tempnam);
}
?>

Pokud byl soubor před dokončením skriptu už vytvořen jiným procesem, vrátí rename false a vypíše chybovou hlášku (se kterou počítáme a proto ji operátorem @ potlačíme) a dočasný soubor smažeme. Pokud by generování souboru trvalo dlouho a my bychom chtěli snížit (nikoliv odstranit) riziko jeho zbytečného zapisování, je možné kód obalit testováním existence výsledného souboru funkcí file_exists. Dlužno podotknout, že funkce rename je atomická pouze na stejném diskovém oddílu.

Jakub Vrána, Výuka, 23.1.2006, diskuse: 34 (nové: 0)

Diskuse

lukas:

Diky, tohle jsem vzdycky chtel vedet. Takze to v PHP elegantne udelat nejde a flock je k nicemu, protoze bere jako parametr handle otevreneho souboru (a ke kolizi muze dojit mezi otevrenim a uzamcenim)...

johno:

Kolízia to síce bude, ale nechápem kde s tým máte problém. Stačí jednoduchý spinlock popísaný http://sk2.php.net/flock dole v komentároch.

<?php
       $fp
= fopen($logFileName, 'a');
       $canWrite = false;
       //Waiting until file will be locked for writing
       while (!$canWrite) {
         $canWrite = flock($fp, LOCK_EX);
         //Sleep for 0 - 2000 miliseconds, to avoid colision
         $miliSeconds = rand(0, 20); //1 u = 100 miliseconds
         usleep(round($miliSeconds*100000));
       }
       //file was locked so now we can store information
       fwrite($fp, $toSave);
       fclose($fp);
?>

johno:

Aha, už som na to došiel. No tak potom najskôr semafór, alebo potom ako dostaneme lock tak pri prvom načítaní súboru overiť či nie je prázdny.

vojta:

Za aktivní čekání bys dostal u zkoušky z operačních systémů nedostatečnou :).

johno:

Takto by to nešlo? http://johno.jsmf.net/knowhow/SafeCache/SafeCache.phps

lukas:

Tam je ten problem, ze se zavola fopen a nasledne flock. Mezi temito dvemi operacemi (alespon myslim) do toho muze vstoupit jina instance skriptu a ten soubor otevrit (treba pro cteni). Pokud puvodni fopen byl otevreni souboru pro zapis, tak pravdepodobne prijdete o data v tom souboru.

Na druhou stranu musim rict, ze pravdepodobnost, ze mezi dvema prikazy, ktere nasleduji tesne za sebou, do toho vstoupi dalsi skript je velmi mala (a mozna nulova, pokud je PHP inteligentni a zpracuje dve po sobe jdouci fopen, flock atomicky). Behem 2 mesicu, co to mam na dvou webech s navstevnosti 500 UIP kazdy, se mi nestalo, ze by mi z ankety zmizela data, zatimco predtim (nez jsem zacal pouzivat fopen-flock-fputs-fclose) mizela kazdy tyden.

johno:

Ten problém tam je, ale riešim to tak, že keď je súbor prázdny tak sa to pokladá ako keby bola cache prázdna a začína sa logika na generovanie stránky a jej následné uloženie do cache.

ikona Jakub Vrána OpenID:

Pravděpodobnost vzrůstá s počtem procesů pracujících na té samé úloze, o tom v článku píšu. S inteligencí PHP to nijak nesouvisí - o tom, který proces bude zrovna na tahu, rozhoduje operační systém - ten si může kdykoliv vzpomenout, že dá příležitost jinému procesu.

ikona Jakub Vrána OpenID:

V neatomicitě operací u tohoto skriptu problém není - pokud je soubor mezi vytvořením a zamčením přečten, vrátí funkce null stejně jako v případě, že soubor ještě neexistuje. Ve skriptu jsou ale dva jiné problémy - u fread() se používá druhý parametr ve významu počet načtených bajtů a ne maximální počet načtených bajtů a smyčka <?php while (!$hasLock) { } ?> vytíží procesor na 100 %, slušností je vždy alespoň chvilku počkat (jak uvádí třeba johno).

johno:

To s tým fread() som nepochopil. V manuále sa píše, že

string fread ( resource handle, int length )

fread() reads up to length bytes from the file pointer referenced by handle. Reading stops when up to length bytes have been read, EOF (end of file) is reached,...

A usleep() som tam pridal. Už by to malo byť v poriadku.

ikona Jakub Vrána OpenID:

Viz třeba http://php.vrana.cz/nacitani-souboru.php#d-1789. Network streams se tohoto případu netýkají, ale např. otevřením vlastního wrapperu (který s cache nemusí nijak souviset) se limit pro načítání nastaví na 8 KiB. Druhý parametr funkce fread() byl navržen pro omezení maxima a ne pro stanovení přesného počtu.

johno:

Aha, toto som netušil. Vďaka. Opravil som to na cyklus. Už by to malo byť v pohode.

hvge:

Hmm. A co ked bude ten temp fyzicky na inom disku (particii)? V tom pripade sa rename() zvrhne na move suboru z jednej particie do druhej, takze zas az take elegantne riesenie mi to nepride...

ikona Jakub Vrána OpenID:

V tom případě je možné dočasný soubor vytvářet např. ve stejném adresáři jako cílový soubor (nebo v adresáři k tomu vyhrazeném na stejném oddílu).

ikona Radek Hulán:

Osobně toto řeším tak, že se vytváří temporary page, a po dokončení se přejmenuje, což zabere podstatně kratší dobu než fwrite().. fwrite() na celou page mohou být při extrémně zatíženém serveru i sekundy, rename() milisekundy..

johno:

A ďalšie inštancie zatiaľ robia čo? Generujú si vlastnú temporary page alebo čakajú na toho prvého? Nejako do toho nevidím. Môžeš mi objasniť čo tým získaš?

ikona Jakub Vrána OpenID:

Ano, to je správný postup popsaný i v článku.

johno:

No práve ako správne sa mi to vôbec nezdá. Predstav si takúto situáciu:

Hľadaná stránka nie je v cache. Začne sa teda generovať stránka do toho temporary súboru. Povedzme, že to trvá sekundu a počas tohto času príde 10 daľších návštevníkov (inštancií), v cache stále nič nie je. Takže každá táto inštancia si začne generovať to isté? Nebolo by lepšie keby počkali?

V mojom riešení sa toto stane len ak príde ďalšia inštancia medzi fopen a flock a to je sakra menšia pravdepodobnosť ako počas generovania celej stránky, kde sa vytvára spojenie s DB, loadujú templaty a ktovie čo ešte.

Neviem prečo sa tak hrozne bránite tomu spinlocku.

ikona Jakub Vrána OpenID:

Řešení s rename() má tu výhodu, že bude fungovat vždy. Třeba pomaleji, ale bez chyb. Dá se samozřejmě upravit tak, aby víc procesů nedělalo totéž, v článku to je i naznačeno, ale zisk není příliš významný - klíčové je ušetřit třeba 10.000 provedení stránky, jestli se provede 1x nebo 10x velkou roli nehraje.

johno:

A kedy nebude fungovať to moje riešenie?

Filip Krejčí:

Taky myslim ze udelat si semafor at uz spilockem nebo lockem jineho souboru je efektivnejsi.

U mene narocnejsich aplikaci to je jedno, ale u zatizenejsich bych to urcite resil lockem jineho souboru.

goodie:

Ja jsem todle resil i v pripade ukladani dat do DB (kdy mezitim, se treba stahuji z netu) tak vytvorim TEMPorary DB table kam je naflakam (mezitim ale jeste udelam lock soubor aby se me jich nedelalo milion... od jinych procesu) naplnim ji datama a pak presunu na zivou tabulku) z ktere se generuji pohledy pro ostatni. Samozrejme to nebude nikdy vysoce zatizene protoze, pak by bylo lepsi stahovat data externe v pozadi s urcitou pravidelnosti..

Ondrej Ivanic:

jedina atomicka operacia je mkdir :)
jediny spolahlivy pokus o lock je cez mkdir.

<?php
if(mkdir('/tmp/my-app-lock') === true) {
  // ok robme co musime..

  // unlock
  unlink('/tmp/my-app-lock');
} else {
  // haha, dakto ma uz asi lock...
}

Prdlořeznictví Krkovička, n. p.:

A co když jeden proces vytvoří adresář a pak z nějakého důvodu kiksne ještě předtím než ho stačí po sobě smazat?
To si pak od té chvíle všechny ostatní procesy budou myslet, že je furt 'obsazeno'.

johno:

Tak som som spolu s kolegom na sitepoint.com došiel na naozaj elegantné riešenie. Fakt to stojí za to.

Magické kombinácie:

Čítanie: fopen s flagom 'r', získame shared lock, čítame dáta, uvoľníme lock, fclose.

Zapisovanie fopen s flagom 'a' !!, získame exclusive lock, ftruncate($fp, 0) !!, zapíšeme dáta, uvoľníme lock, fclose.

Implementácia je opravená a funkčná. http://johno.jsmf.net/knowhow/SafeCache/

ikona dgx:

Třída pro automické operace nad soubory:

http://www.dgx.cz/trine/item/atomicke-operace-se-soubory

Jan Renner:

"Pokud je posloupnost operací AB, může být posloupnost u dvou procesů A1B1A2B2, A1A2B1B2 nebo A1A2B2B1..."

A1A2B2B1? Jako třetí varianta může nastat A1B1B2A2.

ikona Jakub Vrána OpenID:

B2 nemůže být před A2.

Jan Renner:

Jasně, špatně čtu.

Juro Hajdúch:

Tak pred týždňom som s tým mal prvý krát v živote problém aj ja a riešil som to jednoducho vytvorením dočasného súboru a funguje mi to. Riešenie p. Vrány mi ale pripadá elegantnejšie a tak som to prerobil a funguje to tiež bez problémov. Moje riešenie:

<?PHP
if(is_file("docasny_subor.txt")){
echo
"Chyba ...";} // radím vrátiť sa späť tlačítkom back v prehliadači klienta, aby sa zachovali položky formuláru
else{
$vytvor = fopen("docasny_subor.txt","w"); fclose($vytvor);
// nasleduje samotný skript
unlink ("docasny_subor.txt");
}
?>

BTW: Pred ošetrením skriptu som synchromizoval tri požiadavky na daný skript s predimenzovanými dátami/textom takmer 1 MB (generovanie stránky cca 0,35 s) a už pri druhom pokuse došlo ku kolízii - z desiatich pokusov bolo 7 kolíznych. Po ošetrení ku kolízii vôbec nedošlo.

Inak výborné stránky - človek sa má stále čo učiť a ja amatér obzvlášť.   :o)

Aleš Janda:

Tenhle příklad ale zrovna nemůže fungovat ;-)

Mezi voláními funkcí is_file() a fopen() totiž může přijít další proces, který taky zavolá is_file(), ale protože předchozí skript dosud soubor nevytvořil, získají zámek 2 skripty najednou..

amater:

Pravdepodobnost straty dat medzi otvorenim suboru na zapis a jeho uzamknutim je este vecsia ako ako pri otvarani a nasledovnom zapise bez uzamknutia.
Ja som tento problem riesil tak ze pred otvorenim suboru na zapis "w" si vytvorim subor ".bak":
if(filesize($filename)!="0"){copy($filename,$filename.'.bak');}

ikona dgx:

Otevírej pomocí a+ a až bude soubor uzamknutý, smažeš jej pomocí ftruncate. See http://www.dgx.cz/trine/item/atomicke-operace-jeste-jednou

majo:

Ak sa rename() na windowse vykona, vrati bool(true) bez ohladu nato ci subor uz existuje alebo nie, ak ano, jednoducho jeho obsah prepise... pozor nato!

Diskuse je zrušena z důvodu spamu.

avatar © 2005-2024 Jakub Vrána. Publikované texty můžete přetiskovat pouze se svolením autora. Ukázky kódu smíte používat s uvedením autora a URL tohoto webu bez dalších omezení Creative Commons. Můžeme si tykat. Skripty předpokládají nastavení: magic_quotes_gpc=Off, magic_quotes_runtime=Off, error_reporting=E_ALL & ~E_NOTICE a očekávají předchozí zavolání mysql_set_charset. Skripty by měly být funkční v PHP >= 4.3 a PHP >= 5.0.