Když na prostředí záleží aneb soubory a více vláken

Java programátoři jsou zvyklí, že jsou odstíněni od operačního systému na kterém jejich aplikace běží. Toto odstínění není ale stoprocentní. Jednou z vyjímek jsou operace se souborovým systémem.
Chtěl bych se s vámi poděli o poznatky, které jsem získal při psaní projektu m2-proxy. Je to jednoduchý program, který jsem si napsal ve volném čase, abych se odreagoval od svého zaměstnání – práci která se programování bohužel jen vzdáleně podobá. m2-proxy je v jednoduchosti program, který umí lokálně cachovat vzdálenou repository pro Maven (pokud jste používali maven-proxy, tak je to něco podobného, určeného pro Maven 2). Jde o web aplikaci, která se chová následovně:

  1. Přijde požadavek. Podívám se jestli už mám požadovaný zdroj lokálně uložený
  2. Pokud ano a nevypršela jeho platnost, vrátím ho
  3. V opačném případě ho stáhnu z internetu, uložím si ho a vrátím

V zjednodušené podobě může kód vypadat následovně (bez kontroly platnosti souboru a zpracování vyjímek, také předstíráme, že umíme pracovat jenom s jedním zdrojem – souborem):

01 public InputStream downloadFile() throws IOException
02   {
03     //java.io.File store = ... file on the disc
04     //file can not be read, probably was not stored yet
05     if (!store.canRead())
06     {
07       //get the source from the internet
08       InputStream source1 = getSourceInputStream();
09       //store it
10       out1 = new FileOutputStream(store);
11       copyStreams(source1, out1);
12       //closing should be in the finally block
13       source1.close();
14       out1.close();
15     }
16     if (store.canRead())
17     {
18       //return stream from the disc
19       return new FileInputStream(store);
20     }
21     else
22     {
23       //safety net, returning source from the internet
24       return getSourceInputStream();
25     }
26   } 

05 – store je typu java.io.File, zastupuje lokálně uloženou verzi zdroje. Zjišťujeme, je-li zdroj už na disku a můžeme ho číst
08 – Pokud zdroj není na disku, získáme zdrojový InputStream z internetu
11 – Zkopírujeme zdroj na disk
16 – Zjistíme je-li zdroj na disku
19 – Pokud ano vrátím ho
24 – Pokud se něco pokazilo, vrátím originál z internetu

Na první pohled je vše vyřešeno, zvídavějšího čtenáře napadne, co se stane, když ten samý soubor chce ve stejný okamžik více žadatelů. Může nastat několik zajímavých situací, z nichž některé si rozebereme. Ukážeme si zdrojové kódy JUnit testů, které nám pomohou ověřit, je-li náš návrh správný.

Současné čtení a zápis

Zamysleme se nad tím, co se stane, když jedno vlákno zapisuje data do souboru a druhé ho v ten samý okamžik chce číst. Tzn. když nastane něco takového:

01   public void testAfterSomethingWritten() throws IOException
02   {
03     //thread 1
04     assertFalse(store.canRead());
05     //get the source from the internet
06     InputStream source1 = getSourceInputStream();
07     out1 = new FileOutputStream(store);
08     //store just half of the content
09     copyStreams(LENGTH/2, source1, out1);
10     //thread 2
11     assertTrue(store.canRead());
12     //open local store
13     InputStream in2 = new FileInputStream(store);
14     //compare whether the data in the store are ok
15     int bytesRead = compareStreams(in2, getSourceInputStream());
16     assertEquals(LENGTH/2, bytesRead);
17     
18     //thread 1 can continue in writing
19     copyStreams(LENGTH-LENGTH/2, source1, out1);
20   } 

Máme tu situaci, kdy jedno vlákno zapsalo polovinu souboru, v tom okamžiku bylo jeho vykonávání přerušeno (řádek 10) a druhé vlákno začne soubor číst. Čtení simulujeme na řádku 15, čteme in2 a porovnáváme ho se zdrojovým proudem. Když narazíme na konec proudu, vrátíme počet načtených bytů. Můžete si tipnout jak předchozí kód dopadne.

a) Dojde k chybě při porovnání (na řádku 11 File.canRead() vrátí false)
b) Dojde k vyjímce při vytvoření proudu (řádek 13)
c) Dojde k vyjímce při čtení proudu (řádek 15)
d) Proběhne bez chyby

Nebudu vás moc napínat, d je správně. Předchozí kód proběhne bez chyby! To ovšem znamená, že námi navrhovaný postup je špatně! K žádnému automatickému vzájemnému vyloučení tu nedochází. Při souběžném běhu více vláken, může jedno vrátit jenom začátek požadovaného souboru. Jak z toho ven? Co takhle zkusit soubor explicitně zamknout.

Zamykání souboru
Od verze 1.4 podporuje Java zamykání souborů. Můžeme ho zkusit použít, jenom musíme dát pozor na operační systém, protože tato funkce je silně závislá na souborovém systému. Pokud chci zamknout soubor, zavolám

FileLock lock1 = out1.getChannel().lock();//aquire exclusive lock

čímž soubor zamknu. Pokud se kdokoliv jiný pokusí obdržet na něj zámek stejným způsobem, musí čekat dokud nezavolám lock1.release(). Pokud chci z proudu jen číst, mohu si vyžádat sdílený zámek. Takovýto zámek může držet zároveň více procesů, tzn. více procesů může zároveň číst. Sdílený zámek mohu obdržet například takto

FileLock readLock = in1.getChannel().tryLock(0L, Long.MAX_VALUE, true);

tryLock se od lock liší jenom tím, že neblokuje, pokud nemůže zámek obdržet. Místo toho se z volání metody vrátí null.

Pozměněný kód pro uložení může vypadat následovně

01 public InputStream downloadFile() throws IOException
02   {
03     //file can not be read, probably is not on the disc
04     if (!store.canRead())
05     {
06       InputStream source1 = getSourceInputStream();
07       out1 = new FileOutputStream(store);
08       FileLock lock1 = out1.getChannel().lock();//aquire exclusive lock
09       copyStreams(source1, out1);
10       lock1.release();
11       source1.close();
12       out1.close();
13     }
14     if (store.canRead())
15     {
16       
17       FileInputStream in1 = new FileInputStream(store);
18       // try to aquire shared lock
19       FileLock readLock = in1.getChannel().tryLock(0L, Long.MAX_VALUE, true);
20       if (readLock!=null)
21       {
22         return in1;
23       }
24     }
25     return getSourceInputStream();
26   } 

Zde jsme se rozhodli, že pokud nemůžeme obdržet zámek pro čtení na řádku 19, vrátíme původní proud z internetu. Kdybychom na řádku 19 použili lock, vykonání by čekalo, dokud se zámek neuvolní.

Pro otestování chování můžeme napsat následující kód

01 public void testReadLock() throws IOException
02   {
03     //thread 1
04     out1 = new FileOutputStream(store);
05     lock1 = out1.getChannel().lock();
06     assertNotNull(lock1);
07     //thread 2
08     assertTrue(store.canRead());
09     FileInputStream in2 = new FileInputStream(store);
10     FileLock lock2 = in2.getChannel().tryLock(0,Long.MAX_VALUE, true);
11     assertNull(lock2);
12   }

První vlákno tu vytvoří soubor a zamkne ho. V tom vstoupí do hry druhé vlákno a začne soubor číst. Podívá se zda může soubor číst, vytvoří vstupní proud a pokusí se obdržet zámek. Vidíte, že na řádku 11 předpokládáme, že se to nepodaří.

Pokud předcházející kód spustíme, bude nám ve Windows (Windows XP Home Edition) opravdu na řádku 10 bude vráceno null. Ale v Linuxu (RadHat s extfs3) klidně zámek získáme a ze souboru můžeme normálně číst! Jsme tedy tam kde jsme byli. Jak to? Co se to děje? K čemu ty zámky tedy jsou? Takto se můžeme ptát, můžeme i nadávat, že je Linux k ničemu, ale chyba je jako obvykle mezi klávesnicí a židlí. Pokud se podíváme do JavaDocu k tříde FileLock dočteme se doslova toto

„File locks are held on behalf of the entire Java virtual machine. They are not suitable for controlling access to a file by multiple threads within the same virtual machine.“

Zamykání souborů tedy také nemůžeme použít (i když na Windows by to fungovalo).

Dočasné soubory

Nakonec jsem se přiklonil k použití dočasných souborů. Při stahování budu data ukládat do dočasného souboru, který po dokončení stahování přesunu na správné místo. Předpokládám, že přejmenování souboru snad proběhne atomicky a že se nikomu nepodaří soubor číst v napůl přejmenovaném stavu (i když v JavaDocu píší, že tomu tak být nemusí!). Kód pro stahování může tedy vypadat následovně

01 public InputStream downloadFile() throws IOException
02   {
03     //file can not be read, probably is not on the disc
04     if (!store.canRead())
05     {
06       InputStream source1 = getSourceInputStream();
07       File tmpFile = File.createTempFile("filetest","tmp");
08       out1 = new FileOutputStream(tmpFile);
09       copyStreams(source1, out1);
10       source1.close();
11       out1.close();
12       tmpFile.renameTo(store);
13     }
14     if (store.canRead())
15     {
16       return new FileInputStream(store);
17     }
18     else
19     {
20       return getSourceInputStream();
21     }
22   } 

Původní kód jsme rozšířili o
07 - Vytvoření dočasného souboru
12 - Přejmenování souboru

Vypadá to nadějně, zkusme to otestovat. Aby to nebylo tak jednoduché, vložíme na začátku do souboru nějakou hodnotu a tu se budeme snažit přepsat. Nasimulujeme si tak situaci, kdy je stávající soubor nahrazen novou verzí

01 public void testOverwrite() throws IOException
02   {
03     //let's pretend that there already exists some file
04     FileOutputStream out = new FileOutputStream(store);
05     copyStreams(getOldSourceInputStream(), out);
06     out.close();
07     
08     //thread 1
09     File tmpFile = File.createTempFile("filetest","tmp");
10     out1 = new FileOutputStream(tmpFile);
11     copyStreams(getSourceInputStream(), out1);
12     out1.close();
13     tmpFile.renameTo(store);
14     
15     assertTrue(store.canRead());
16     InputStream in2 = new FileInputStream(store);
17     int bytesRead = compareStreams(in2, getSourceInputStream());
18     assertEquals(LENGTH, bytesRead);
19     in2.close();
20   } 

Na Linuxu to běží skvěle, pustíme-li kód na Windows, porovnání proudů na řádku 17 selže. V souboru zůstal starý obsah. Zde se nám vymstilo, že nekontrolujeme návratovou hodnotu metody renameTo. Na Windows nám vrací false, aby naznačila, že se operace nepodařila. Jednoduše nám odmítá přejmenovat soubor, pokud již nějaký se stejným jménem existuje. Existuje jednoduché řešení. Stávající soubor musíme napřed smazat (řádek 13 v následujícím výpise).

01 public void testDelete() throws IOException
02   {
03     //let's pretend that there already exists some file
04     FileOutputStream out = new FileOutputStream(store);
05     copyStreams(getOldSourceInputStream(), out);
06     out.close();
07     
08     //thread 1
09     File tmpFile = File.createTempFile("filetest","tmp");
10     out1 = new FileOutputStream(tmpFile);
11     copyStreams(getSourceInputStream(), out1);
12     out1.close();
13     boolean deleted = store.delete();
14     assertTrue(deleted);
15     tmpFile.renameTo(store);
16     
17     assertTrue(store.canRead());
18     InputStream in2 = new FileInputStream(store);
19     int bytesRead = compareStreams(in2, getSourceInputStream());
20     assertEquals(LENGTH, bytesRead);
21     in2.close();
22   }

Protože jsem hrozný šťoura, zeptám se vás jestli náhodou nemůže selhat mazání. Kromě podivných případů, kdy je soubor na zamčeném médiu, může nastat situace, že soubor někdo čte v okamžiku kdy se ho snažíme smazat. Nasimulovat si to můžeme následovně

01 public void testDeleteWhenReading() throws IOException
02   {
03     //let's pretend that there already exists some file
04     FileOutputStream out = new FileOutputStream(store);
05     copyStreams(getOldSourceInputStream(), out);
06     out.close();
07     
08     //reference source stream
09     InputStream reference1 = getOldSourceInputStream();
10     //thread 1 is reading the file
11     assertTrue(store.canRead());
12     FileInputStream in1 = new FileInputStream(store);
13     //reading and comparing the stream with reference
14     compareStreams(LENGTH/2, in1, reference1);
15     
16     //thread 2 is storing the file
17     File tmpFile = File.createTempFile("filetest","tmp");
18     out1 = new FileOutputStream(tmpFile);
19     copyStreams(getSourceInputStream(), out1);
20     out1.close();
21     //let's delete the existing file
22     boolean deleted = store.delete();
23     if (deleted)
24     {
25       System.out.println("The original file WAS deleted.");
26     }
27     else
28     {
29       System.out.println("The original file WAS NOT deleted.");
30       tmpFile.delete();
31       return;
32     }
33     
34     tmpFile.renameTo(store);
35     
36     // thread 1 reads  rest of the file
37     compareStreams(LENGTH-LENGTH/2, in1, reference1);
38     in1.close();
39     reference1.close();
40     
41     //thread 2 reads the file
42     assertTrue(store.canRead());
43     InputStream in2 = new FileInputStream(store);
44     int bytesRead = compareStreams(in2, getSourceInputStream());
45     assertEquals(LENGTH, bytesRead);
46     in2.close();
47   }

Zkoušíme tu situaci, kde první vlákno rozečte soubor, druhé vlákno se rozhodne, že soubor není platný a že je potřeba ho přepsat novou verzí (řádek 16). Pokus o smazání na řádku 22 může teoreticky skončit několika výsledky

a) Soubor bude smazán, čtoucí vlákno má smůlu
b) Soubor nepůjde smazat
c) Soubor se smaže, přesto ho bude moci čtoucí vlákno dočíst

Experimenty potvrdili, že dochází ke výsledkům b a c. Windows jednoduše odmítnou soubor smazat, to je pochopitelné chování, je na nás, abychom se s touto situací nějak vyrovnali. Linux (alespoň moje verze na extfs3) soubor smaže, ale čtoucí vlákno ho nějakým zázračným způsobem může dočíst. Dochází tedy k situaci, kdy dvě vlákna čtou současně jeden soubor, každé s jiným obsahem. Jelikož je ale tento obsah konzistentní, není to žádný problém, spíše naopak. Nemusíme řešit, co dělat, pokud soubor nejde smazat.

Ukázali jsme si, že i Java programátor se občas musí starat o to, na jakém systému jeho program poběží. Pocit, že na prostředí nezáleží že nás Java izoluje je v některých případech falešný. Práce ze soubory je jedním takovým případem. Při práci na vícevláknové aplikaci je dobré se zamyslet, co se může stát, když se nám dvě a více vláken střetne nad společným zdrojem. Ukázali jsme si, že

  1. U FileInputStreamu a FileOutputStreamu k žádnému implicitnímu zamykání nedochází, je možné do souboru zapisovat a zároveň z něho číst.
  2. Explicitní zámky na souborech slouží pouze k vyloučení nejavových procesů. V rámci jednoho virtuálního stroje mohou být neúčinné.
  3. Je nutné kontrolovat návratové hodnoty z metod java.io.File, jako renameTo a delete. Může se stát, že selžou (na různých OS různě).

Ukazuje se také, že je občas dobré si přečíst dokumentaci až do konce, před tím než se člověk vrhne po hlavě do implementace. I když na druhou stranu je docela zajímavé vyzkoušet si jak to funguje na vlastní kůži.

Chcete-li vidět verzi kódu, kterou jsem použil v projektu m2-proxy podívejte se do CVS projektu.

Zdrojové kódy příkladu jsou ke stažení zde.

One Response to “Když na prostředí záleží aneb soubory a více vláken”

  1. Maaartin Says:

    Pro synchronizaci v ramce jedny JVM bych nepouzival FileLock. Dalo by se zneuzit
    synchronized (file.toString().intern()) {...}
    ale to by opravdu bylo zneuziti String-u. Pokud to napadne jeste nekoho jinyho, bude problem. Ale staci udelat si vlastni pool nakych objektu.

    > Linux (alespoň moje verze na extfs3) soubor smaže, ale čtoucí vlákno ho nějakým zázračným způsobem může dočíst.

    To neni zazrak: Tam jen pochopili, ze smazani souboru je pouze jeho odstraneni z adresare. Na fyzicke existenci souboru to nic nemeni (smazne se az nebude potreba, neco jako GC zalozeny na reference counting).