Co se vlastně myslí pod atomickými operacemi nebo rozumí pod pojmem „thread-safe“? Začněme jednoduchým příkladem:
$original = str_repeat('LaTrine', 10000);
$counter = 1000;
while ($counter--) {
// write
file_put_contents('soubor', $original);
// read
$content = file_get_contents('soubor');
// compare
if ($original !== $content)
die('ERROR');
}
Dokola zapisujeme a následně čteme stále tentýž řetězec. Může se
zdát, že volání die('ERROR')
nemůže nikdy nastat. Opak je
pravdou. Schválně si zkuste tento skript spustit ve dvou oknech zároveň.
Error se dostaví prakticky okamžitě.
Proč tomu tak je, nedávno vysvětloval Jakub Vrána. Uvedený kód není bezpečný (safe), pokud se v jednu chvíli provádí vícekrát (tedy ve více vláknech = threads). Což na internetu není nic neobvyklého, často se v tentýž okamžik pokusí více lidí připojit k jednomu webu. Takže psaní thread-safe aplikací je velmi důležité.
Je třeba zajistit, aby se funkce file_get_contents
& spol.
vykonávaly atomicky. Pro Nette jsem napsal třídu, obsahující atomické
alternativy těchto funkcí. Do stavu ERROR se s nimi nikdy nedostanete.
Uvedený příklad stačí upravit takto:
while ($counter--) {
// write
NSafeFile::write('soubor', $original);
// read
$content = NSafeFile::read('soubor');
// compare
if ($original !== $content)
die('ERROR');
}
Musím říci, že najít ten správný postup byl docela oříšek. Své by o tom mohl vykládat třeba Johno, který SafeCache předělával snad pětkrát, přesto poslední verze má stále chyby. …a nebo taky já, který těch předělávek má na krku ještě víc.
Metoda flock()
Funkce flock, která je k řešení těchto situací určená. Potíž je v tom, že má tolik háčků, až je skoro nepoužitelná. Zamknout není možné soubor před jeho otevřením či vytvořením, stejně tak smazat jej lze až po uzavření (platí pro platformu Windows). A tím pádem veškerá atomicita jde do háje.
Metoda rename()
Existuje další způsob, spočívající v přejmenovávání dočasně vytvořeného souboru – zmiňuje jej Jakub Vrána. Zdál se mi ideální, je však nepoužitelný, pokud chceme přepisovat obsah souboru – pak nelze zajistit atomicitu.
Metoda lock-files
Dále je tu princip založený na vytváření lock-files. Tedy dočasných souborů nulové délky či adresářů, jejichž přítomnost značí přítomnost zámku. Tato metoda je v podstatě nejjednodušší. Zvyšuje však počet souborových operací a celý proces se tak značně zpomalí. Dále je potřeba vyřešit problém s (ne)odstraněním lock-files, například po pádu Apache apod.
Řešení NSafeFile
Nakonec se mi (snad) podařilo vytvořit novou a spolehlivou verzi
kombinující první dva postupy. Vyhnul jsem se lock-files, výsledek je tedy
znatelně rychlejší. Popis všech „triků“ najdete ve
zdrojovém kódu.
Aktualizace: NSafeFile už je historií, stáhněte si nástupce Nette\IO\SafeStream.
Komentáře
JersyWoo #1
A kdy projekt SODOR zveřejníš, jak jsi sliboval?
roman #2
Slušné!!!!
hvge #3
Jo toto je vecny problem. Ako (a to sa nebojim povedat) expert na thread-safe aplikacie mam heslo „nevyrieseny hazard sa urcite raz prejavi“. Ked sa to skombinuje s murphyho zakonom, tak sa prejavi v tu najhorsiu moznu chvilu :)
V tvojom rieseni pouzivas externy subor na lockovanie, co je v php asi jedine mozne a dobre riesenie, akurat presne tomu sa myslim johno chcel vyvarovat :)
Inak podobne na tom su aj pristupy do databazy, pokial su tabulky v db navrhnute zle, skor ci neskor moze nastat nekonzistencia dat.
raver #4
Neodporúčam testovať v Opere (9.0). Aj napriek tomu, že v niekoľkých taboch/oknách naraz otvoríte rovnakú URL, Opera počká kým sa načíta práve jedenkrát a rovnakú stránku zobrazí vo všetkých oknách.
Čo sa týka samotného testovania, stress1 u mňa zbehne bez chýb.
hvge #5
Este k tomu spinlocku. Podla mna ked das sleep na 1 az 30ms, tak sa v operacnom systeme prepne kontext (na iny thread/proces). Scheduler si thready radi do „nejakej“ fronty a toto poradie meni iba pri roznych prioritach (co v pripade php asi nehra ulohu). Preto si myslim, ze staci obycajny sleep na konstantnu hodnotu, ktory vlastne len sposobi to prepnutie kontextu.
#4 ravere, podobne to robi aj mozilla. Uz som sa par krat tiez napalil :)
cita #6
#4 ravere, stačí přidat do GET nějaký nesmyslný parameter a opera to bere jako několik samostatně načítaných instancí.
David Grudl #7
Řešení jsem mírne upgradoval, byly tam dvě chybičky. Teď už mi to připadá skutečně neprůstřelné.
Ještě bych zdůraznil pár podstatných předností tohoto řešení:
#5 hvge, Nad tím jsem přemýšlel, jestli použít konstatnu nebo random v rozsahu a nakonec jsem dospěl k tomu, že random to může teoreticky zrychlit. Ale tohle je celkem prkotina 🙂
Honza V. #8
WOW, tak tohle je zásadní přínos pro PHP! Díky
Ivo #9
Aka je licencia?
medden #10
No neviem, asi to nemáš ešte celkom vychytané, skúsil som si ten stress2 a vraj NOT PASSED…
medden #11
#10 meddene, Ach jo, sypem si popol na hlavu, neviem čítať 😉
David Grudl #12
Aktualizace: Tak jsem nakonec úplně vyštípal
flock()
, kód se ještě více zjednodušil a výsledek je přenositelnější. Předpokládám že další změny už nenastanou. Je to prostě dokonalé 🙂#9 Ivo, Můžeš používat bez omezení, jen je třeba ponechat původní copyright.
Aleš Janda #13
Já jsem to kdysi dělal tak, že jsem zkusil vytvořit adresář ZANEPRAZDNENO. Když se podařil vytvořit, tak hurá, zámek je můj, provedl jsem kritickou sekci a pak jsem adresář smazal.
Když se vytvořit nepodařil, čekal jsem ve smyčce asi 5 sekund a po sekundě zkoušel.
Chodilo to výborně, ALE: občas se stávalo, že se ten adresář nesmazal. Jestli se ta kritická sekce nějak zacyklila, vypršel timeout nebo se zrovna resetnul Apache… fakt nevím. Nestávalo se to často, ale přece.
Proto jsem dal omezení, že pokud 5 sekund čeká na zámek a pořád nic, tak tam prostě vstoupí a hotovo. Ale to je samozřejmě řešení na nic.
Dneska to dělám pouze přes databázi. Jednak je to mnohem čistší, rychlejší, ale hlavně je atomicita zaručena z povahy databáze.
David Grudl #14
#13 Aleši Jando, ano, s tímto je třeba také počítat. Že vlivem nečekaných okolností dojde k narušení konzistence dat. Mě se to snad podařilo vyřešit takto:
Místo adresáře vytvářím soubor, jehož handle si během kritické sekce držím. Zámek pak nepředstavuje existence/neexistence souboru, ale to, jestli soubor existuje i po pokusu o smazání. Otevřený soubor totiž nelze smazat.poznámka: to byl špatný postup, už lockfiles nepoužívámmka #15
#14 Davide Grudle,
Tohle mozna stoji za upresneni. Na UNIXech (z uzivatelskeho pohledu) smazat jde. Nazev souboru je jen polozka v adreari, ktera ukazuje na nejaky inode (proto taky jeden soubor muze existovat pod vice nazvy, pri pouziti hardlinku)
Unlink jen „odpoji“ nazev souboru od daneho inodu. (a uzivatel si muze myslet, ze soubor smazal)
Pokud ale uz ma nejaky proces ten soubor otevreny, muze jej dal pouzivat i po unlinku, ale nikdo uz se ten soubor nevidi a teprve po uzavreni souboru jadro (ovladac filesystemu) prohlasi data toho souboru za volne misto.
Tohle me napise „16 chars written“:
mka #16
tohle na linuxu taky projde, vypise „16 chars written“ a ve writabledir/unlock je text „test2“:
spaze #17
Z textu (kua, jsem udělal překlep texy) jsem pochopil, že více vláken = více přístupů na web. Jestli jsem to pochopil správně, tak to není pravda, více přístupů na web může probíhat i bez vláken, s více procesy. Záleží na tom, jak pracuje webserver, jestli na každý požadavek vytvoří nový proces, nebo jen vlákno apod. čili thread-safe znamená, že je v Apache možné použít třeba mpm_worker místo mpm_prefork. https://httpd.apache.org/…2.0/mpm.html.
PHP, alespoň na unixu, resp. některý moduly PHP nejsou thread-safe vůbec.
David Grudl #18
#15 mko, díky, to je hodně podnětný postřeh. Bohužel se tím bortí jeden z principů tohoto řešení. Nejvíc mě překvapuje, že jsem samozřejmě dělal testy na windows i linuxu a vyšel mi opak, tedy že unlink nesmaže otevřený soubor.
Musím tedy kód ještě předělat…
llook #19
V PHP jsem to nezkoušel, protože ho mám zrovna trochu rozvrtaný (právě běží
make
). Ale schválně jsem jeden nicnedělající skript:pustil s odkazem na soubor:
./skript < ./soubor
a na druhym terminálu jsem ten soubor v pohodě smazal.S chybovou hláškou „soubor nelze smazat, protože je zrovinka spuštěný nebo otevřený“ jsem se zatím setkal jen na Windows. Jedině, že by si to nějak PHP hlídalo v rámci svého procesu (a tím by bylo ze hry CLI/CGI ☹ ).
Jak často může nastat smetí? Pokud by to bylo málokdy (ignore_user_abort…), dalo by se testovat stáří zámku.
Třeba pět sekund starý zámek je s největší pravděpodobností neplatný a pokud by to znamenalo krátké zdržení pro méně než promile návštěvníků, dalo by se s tím žít, ne?
David Grudl #20
#19 llooku, ad mazání souborů: ano, byla to moje hloupost. Našel jsem jiné řešení, dokonce výrazně rychlejší a jednodušší.
hvge #21
#20 Davide Grudle, Tak si sa predsa len vratil k flock ? 🙂
Jakub Vrána #22
Vygeneruje
microtime
v různých procesech spolehlivě různou hodnotu? Nedalo by se použíttempnam
?David Grudl #23
#22 Jakube Vráno, to je právě otázka. Tempnam mi nezaručuje, že soubor bude vytvořen na stejné partition. Proto používám microtime. Ale díky za tip, přidal jsem tam pojistku proti dvoum současně zapisujícím procesům.
Majkls #24
Funguje to, jestliže to je vláknový server, takže s preforkem by se to mělo chovat korektně (opravte mě, jestli se pletu). Pokud to dělá i s preforkem, tak už to není problém TS, ale zamykání mezi procesy, což by mělo být už vyřešeno na úrovni glibc a podobných knihoven. Někde byl pěkný odkaz, proč nepoužívat vláknové PHP… tady: http://marc.theaimsgroup.com/?…
Nicméně abych nedemotivoval – psát TS aplikaci je přínosné, už jen proto, že TS aplikace berou míň paměti, jsou rychlejší, atd atd…
Jakub Vrána #25
#23 Davide Grudle, Jak to, že
tempnam
nezaručuje vytvoření souboru na stejné partition? Pokud adresář existuje a dá se do něj zapisovat, tak ho tam vytvoří.David Grudl #26
#25 Jakube Vráno, ano. A pokud adresář neexistuje, nebo do něj nelze zapisovat, potřebuju vrátit false případně chybovou hlášku, zatímco vytvořit jej někde jinde se mi nehodí.
llook #27
Taky by možná stálo za to zprovoznit https://nette.org/en/license, když se na to v docbloku odkazuješ.
Jakub Vrána #28
#26 Davide Grudle,
tempnam
vrátí jméno vytvořeného souboru, takže se dá zjistit, kde byl vytvořen, a případně ho zase smazat. Ale pokud řešení smicrotime
funguje, tak je asi lepší. Čekal jsem, že pojistka zmiňovaná v #23 David Grudl bude zohledňovat číslo procesu (což by nefungovalo u více vláken), ale ty jsi zvolil jiné řešení. Nejsem si jist, že je úplně neprůstřelné (vycházím z kóduNSafeStream
01):P1 a P2 se rozhodnou vytvořit stejnojmenný dočasný soubor, což se jim povede. P1 ho zamkne, P2 čeká. Když ho P1 ve
stream_close
zavře, tak do něj může začít zapisovat P2. A teď v závislosti na tom, jestli jde přejmenovat zamčený soubor a jestli do odstraněného souboru jde zapisovat (v závislosti na platformě), se stane každopádně něco nehezkého.Možná bych přeci jen použil
tempnam
a tím se těmhle problémům vyhnul.David Grudl #29
#28 Jakube Vráno, Nepovede se jim vytvořit stejný dočasný soubor. Pojistka je v podobě režimu ‚x‘, ve kterém se soubor vytváří.
Jakub Vrána #30
#29 Davide Grudle, Já tam tedy vidím
$this->acquireLock($tmp, $mode.$flag, LOCK_EX);
a ne'x'.$flag
.David Grudl #31
#30 Jakube Vráno, ten $mode == ‚x‘ byl ošetřen podmínku case – nicméně máš zrovna staženou verzi, kde je překlep, sorry. To získávání exkluzivity u temporary files jsem pak ostranil jako bezpředmětné.
Jakub Vrána #32
#31 Davide Grudle, Vidím, že jsi to ve verzi NSafeStream 01 potichu opravil. Doufám, že pak bude ještě nějaká finální verze, protože řada lidí teď věří tomu, že původní 01 je OK.
David Grudl #33
#32 Jakube Vráno, nikoliv potichu, jen z názvu archívu mi vypadlo písmenko (v hlavičce třídy je verze správně).
Mimochodem, přidal jsem právě do archívu NSafeStream pro PHP4. Byl to oříšek, stále to nefungovalo ideálně. Než jsem zjistil, že usleep() v PHP4 pod Windows nefunguje…
Tento článek byl uzavřen. Už není možné k němu přidávat komentáře.