Race condition v SafeStream?
- phpfm
- Člen | 9
V SafeStream.php lze nalézt následující kód:
IMHO problém je v té mezeře mezi flock(LOCK_UN)
a
rename()
. V té době se k tomu starému nezamčenému souboru
může dostat jiný proces, získat LOCK_EX a začít s ním pracovat.
Následný rename()
našeho procesu potom dle situace může
proběhnout až poté, co ten jiný proces dokončil práci a provedl svůj
rename()
, čímž potenciálně starší verze souboru přepíše
nověji vygenerovanou. Podobná mezera je při otvírání souborů mezi
fopen()
a flock(LOCK_SH/EX)
, tam to může vést mimo
jiné k tomu, že vícero procesů bude současně pracovat nad tou samou
cestou.
Na druhou stranu díky tomu, že zapisování se provádí do dočasného
souboru s náhodně vygenerovaným jménem, který se na konci přesune pomocí
rename()
, nedochází k tomu, že by si procesy přepisovaly ta
samá data. Takže atomické to je, nicméně tohle „Isolation: No one can
start to read a file that is not yet fully written.“ neplatí, pokud
„file“ chápeme jako cestu k souboru. Jinými slovy je možné, aby vícero
procesů mělo současně otevřeno k zápisu
fopen("nette.safe://path/to/file")
a pracovali s ním, přičem
až všichni skončí, tak pod tou cestou zůstane jedna verze dat (nebo
žádná, pokud ten poslední proces skončí chybou a soubor smaže).
- David Grudl
- Nette Core | 8257
IMHO problém je v té mezeře mezi flock(LOCK_UN) a rename(). V té době se k tomu starému nezamčenému souboru může dostat jiný proces, získat LOCK_EX a začít s ním pracovat. Následný rename() našeho procesu potom dle situace může proběhnout až poté, co ten jiný proces dokončil práci a provedl svůj rename(), čímž potenciálně starší verze souboru přepíše nověji vygenerovanou.
Řekl bych, že rename() proběhne hned, navíc na Windows selže.
- phpfm
- Člen | 9
Nevím, co je myšleno tím „hned“, ale kombo
flock(); fclose(); fclose(); rename()
není jedna procesorová
instrukce chráněná lockem, takže nepochybně se během toho může udát
mnoho věcí. Jako například, že OS ten php proces přeruší a
pustí jiný.
Na windowsech jsem to nezkoušel, ale velmi pochybuji o tom, že ten
rename()
selže, nemá proč. Pokud zdrojový soubor existuje,
přístupová práva to umožňují, atd., tak je to validní operace.
Každopádně na linuxu neselže určitě.
- David Grudl
- Nette Core | 8257
Jasně, stát se to může, že závod vyhraje proces, který začal později.
Na Windows (resp. na NTFS) nejde mazat nebo přejmenovávat soubory, pokud jsou otevřené, na rozdíl od Linuxu, takže proto to selže.
- Polki
- Člen | 553
David Grudl napsal(a):
Na Windows (resp. na NTFS) nejde mazat nebo přejmenovávat soubory, pokud jsou otevřené, na rozdíl od Linuxu, takže proto to selže.
Což je imho úplně jedno, když 90% serverů jede na linuxech.
- David Grudl
- Nette Core | 8257
Nepíšu tu přece, že na Windows to funguje korektně nebo že servery jedou na Windows. Jen doplňuju, že přepsání starším obsahem je velmi nepravděpodobné
- David Grudl
- Nette Core | 8257
mít tam tiché a náhodně se projevující bugy asi není ideální…
To samozřejmě není. Myslíš bugem to, že race condition vyhraje starší obsah? Na Windows nevím jak to vyřešit, na Linuxu by asi šlo fclose() posunout za rename(). Nebo nějaké jiné bugy? Jak se stane, že se vytvoří a čte prázdný soubor?
Hele je to víc než deset let starý kód, já si houby pamatuju o co tam jde. Jestli jsou tam chyby, budu rád za opravu, jestli je to komplikované, tak tu knihovnu klidně zruším.
- David Grudl
- Nette Core | 8257
Já to chápal tak, že @phpfm vadí, že potenciálně starší verze souboru přepíše nověji vygenerovanou. Tedy ten kdo získal jako první zámek při otevírání souboru nemusí být ten, kdo jako první přejmenuje.
Což je fakt, byť velmi málo pravděpodobný, ale já to jako problém nevidím, prostě při souběžném běhu někdo vyhraje a je mi jedno kdo. Kdyby to problém byl, šlo by to pro Linux řešit voláním rename() dřív.
- phpfm
- Člen | 9
OK, dáme si názornou ukázku. Pokud ani
fopen("nette.safe://...", "a")
ani následné zápisy nevyhodí
chybu, měly by zapsaná data v souboru být. Následující kód spustí
procesy, které současně do stejného souboru přidávají řádky.
Ten stream_close()
asi nebyl nejlepší příklad, protože on
je problém už při otvírání. Ve skutečnosti pokaždé, když na sebe
narazí N procesů nad jednou cestou, tak z N verzí zapsaných souborů
zůstane jen jedna náhodná, všechny ostatní se ztratí. Jde o to, že cesta
je jen ukazatel na soubor s daty a fopen() vrátí ten ukazatel, který tam
zrovna je. Když později rename()
nebo unlink()
+
fopen(..., "c")
změní, na jaká data cesta ukazuje, tak se o tom
ti, kteří získali předchozí ukazatel, nedozví.
Jinými slovy proces P otveře cestu s daty S1, získá zámek a dá se do práce. Přijde dalších N procesů, otevřou cestu, získají ukazatel na ten samý S1 a zablokují se na zámku. Proces P dokončí práci, uvolní zámek nad S1 a provede rename(), takže nyní cesta ukazuje na soubor S2. Uvolnění zámku nad S1 probudilo jeden z čekajících procesů, který se dá do práce, ovšem nad původními daty S1. O tom, že cesta teď ukazuje na S2 od procesu P, neví. Pracující proces se dopracuje k výsledku S3, uvolní zámek nad S1 a udělá rename(), takže cesta ukazuje na jeho data S3 a S2 jsou fuč. Uvolnění zámku probudí další čekající proces, který ovšem opět drží ukazatel na S1. O S2 ani o S3 neví a dá se vesele do práce…
S tímhle názorná ukázka funguje o něco lépe.
(Všechny ukázky byly testovány jen a pouze pod linuxem.)
Editoval phpfm (2. 1. 2022 16:51)
- David Grudl
- Nette Core | 8257
Rozumím, vychází mi z toho buď pro režim ‚a‘ nepoužívat dočasný soubor, protože při uvolnění zámku následující vlákno pracuje s původním obsahem, nebo při získání zámku detekovat změnu (asi porovnat inode number u fstat() a stat()) a pokud je, otevřít soubor znovu.
- David Grudl
- Nette Core | 8257
Si říkám, jestli to vůbec platí v případě dočasných souborů. Pokud dojde místo na disku, tak se soubor skutečně nevytvoří, ale že aplikace v půlce zápisu selhala se stream_close() nedozví.
- David Grudl
- Nette Core | 8257
Ano, to se kontroluje. Ale že aplikace zapsala co opravdu chtěla a nespadla, nezjistím.
Bez použití dočasného souboru můžu ve stream_close zkrátit soubor na původní délku.
- David Grudl
- Nette Core | 8257
Ano, to tady píšu celou dobu. A bez použití dočasného souboru se stejného efektu dá dosáhnout tím, že v stream_close soubor zkrátím na původní délku.
- David Grudl
- Nette Core | 8257
To používání dočasných souborů jsem tam dával před 11 lety a nevím
už přesně proč (tehdy bylo PHP 5.2, časté fatal errory, a fopen neuměl
nebo neměl zdokumentovaný režim c
, tedy vytvořit bez
smazání). Zkusil jsem to revertnout https://github.com/…feStream.php
- phpfm
- Člen | 9
David Grudl napsal(a):
Rozumím, vychází mi z toho buď pro režim ‚a‘ nepoužívat dočasný soubor, protože při uvolnění zámku následující vlákno pracuje s původním obsahem, nebo při získání zámku detekovat změnu (asi porovnat inode number u fstat() a stat()) a pokud je, otevřít soubor znovu.
Tohle se týká všech režimů čtení+zápis a jak bylo řečeno, bez dočasného souboru tam nebude atomicita. Porovnávat inody by šlo také. Ta detekce změny a znovu otevření má ještě takový drobný detail, že se tím překope FIFO fronta uspaných čekajících a může docházet k předbíhání. Když se sníží počet EvenSaferStream::Attempt, tak lock() na tom občas selže. Možná by se mohlo zjistit, jestli na zámku nevisí někdo další, ale zatím jsem to nezkoušel.
Ono záleží na tom, co přesně by SafeStream měl dělat a co od toho lidi očekávají. Motivační příklad v dokumentaci řeší situaci, kdy si dva procesy čtou/přepisují neúplná data. Pokud jde jen o tohle a nezáleží na pořadí, v jakém se změny projeví, tak to zamykání je zbytečné a postačí dočasný soubor + rename() na závěr. Ale netuším k čemu a jestli vůbec to lidi používají, protože třeba ten append sežere data pokaždé, když dojde k souběhu více procesů, a toho by si za deset let asi někdo všiml?
- Milo
- Nette Core | 1283
Ve stream_truncate() zbyl tempHandle, ale to jen pro info.
Aha. Já myslel, že ta vychytávka, je právě ten temp file. Že když se kdykoliv mezi otevřením a zavřením souboru něco podělá, tak původní soubor zůstane nedotčen, a že právě rename() vyvolaný při close zajistí celkovou konzistenci souboru.
- David Grudl
- Nette Core | 8257
Že když se kdykoliv mezi otevřením a zavřením souboru něco podělá, tak původní soubor zůstane nedotčen
Jde o to co přesně myslíš tím „něco se podělá“. rename() se nezavolá pouze tehdy, pokud fwrite() selhal, což znamená, že došlo místo na disku (nenapadá mě jiný důvod). A nebo se PHP neukončí korektně, tedy nějaký segfault, ale to se v podstatě nestává.
- David Grudl
- Nette Core | 8257
Na atomicitu rezignuji, protože mi dochází, že ji nelze rozlišit na low-level úrovni. Ověřím úspěšnost fwrite(), ale jen aplikace ví, kolik těch fwrite() mělo proběhnout.
Tahle třída vznikla primárně kvůli izolaci, aby nezapisovalo více vláken zároveň nebo někdo nečetl nedopsaná data. Dočasné soubory řeší situaci, kdy někdo získá zámek ke čtení dřív než pro zápis a přečte prázdný soubor, ale přidávají mnohem víc komplikací, takže bych je asi opustil.
- phpfm
- Člen | 9
Na to je standardní pattern dočasný soubor + rename(). Výhoda je, že při vytváření nové verze souboru se neblokují čtenáři a selhání zápisu se elegantně řeší samo. Akorát že tady PHP zavolá stream_close() sám od sebe vždy, takže ta druhá výhoda odpadá. Možná bych u SafeStream umožnil pouze čistý zápis nebo čisté čtení a u ostatních režimů vyhodil chybu, protože stejně nedělají, co se od nich asi čeká. A když už, tak kolem tmp+rename() napsat další třídu, která to udělá korektně a bude umožňovat explicitní commit/rollback.
Editoval phpfm (3. 1. 2022 19:22)