Java (20) - vlákna

Vlákna (threads) jsou v Javě velmi důležitá. Je to prakticky jediný způsob, jak používat mnohé blokující operace. Využití mají vlákna také pro činnosti prováděné na pozadí.

2.11.2005 07:00 | Lukáš Jelínek | přečteno 52724×

Úvod do vláken v Javě

Vlákna představují způsob, jak v rámci jednoho procesu provádět více činností paralelně (nebo - u jednoprocesorového stroje - pseudoparalelně). V rámci každého z vláken je vykonáván kód nezávisle na ostatních vláknech. Od procesů se vlákna liší tím, že spolu sdílejí data procesu, v němž běží.

Standardní knihovny Javy obsahují poměrně rozsáhlou podporu pro práci s vlákny. Než je ale člověk začne používat (což je samo o sobě velice jednoduché), je dobré znát pár věcí, které jsou s používáním vláken v Javě nerozlučně spjaty.

Kvůli platformové nezávislosti Javy není přesně definováno, jak se vlákna budou konkrétně chovat. Existují ale v zásadě dva druhy vláken:

Native threads

Nativní vlákna mají takové vlastnosti, jaké jim poskytuje jejich implementace v OS. Typicky lze využít ryze paralelní běh (na více procesorech současně) a preemptivní přidělování a odnímání procesoru jednotlivým vláknům. O tato vlákna se programátor prakticky nemusí starat, protože starosti leží na operačním systému.

Green threads

"Zelená" vlákna běží jen na jediném procesoru (jde tedy o pseudoparalelní běh) a tento procesor nelze vláknu zvenku odebrat. Proto se programátor musí postarat, aby žádné vlákno neběželo příliš dlouho (aby se dostalo na ta ostatní). Lze to realizovat různými cestami, brzy se k tomu dostaneme.

Je dobrým zvykem navrhovat programy tak, aby vyhověly oběma modelům - a to přesto, že se s green threads už příliš nepočítá.

Rozhraní Runnable a třída Thread

Naprostým základem práce s vlákny v Javě je rozhraní Runnable. Je v balíku java.lang a obsahuje jedinou metodu run(). Právě tato metoda obsahuje kód, který se bude v rámci vlákna vykonávat. Dále tu máme třídu Thread, která toto rozhraní implementuje, a hlavně, obsahuje infrastrukturu pro řízení běhu vlákna.

Potřebujeme-li použít vlákno, lze postupovat dvěma cestami:

Z hlediska výkonného kódu je v podstatě jedno, kterou cestu použijeme - na implementaci metody run() se to většinou neprojeví. Liší se jen přípravné práce. V zásadě se dá říct, že v jednodušších případech je lepší rozšířit třídu Thread, a v těch složitějších použít druhý způsob.

Vytvoření a spuštění vlákna

Vlákna s bohatou činností vytváříme jako klasické pojmenované třídy, pro jednoduché operace si vystačíme s anonymními třídami. Nyní se podívejme na dva příklady. První z nich ukazuje, jak vytvořit vlákno na základě třídy Thread:

Thread t = new Thread() {
    public void run() {
        // tady bude nějaký kód
    }
};

t.start();

Takto můžeme v okamžiku potřeby snadno vytvořit vlákno v místě, kde ho použijeme. Vlákno se spustí zavoláním metody start(). Druhý příklad ukáže, jak vytvořit vlákno na základě rozhraní Runnable. Přestože by šlo použít anonymní třídu, zde bude použita třída pojmenovaná:

class ThreadUser {

    static class MyRunnable implements Runnable {
        public void run() {
            // tady bude nějaký kód
        }
    }

    public static void main(String args[]) {
        Thread t = new Thread(new MyRunnable());
        t.start();
        ...
    }   
}

Výsledek bude tentýž jako v předchozím případě. Rozdíl je v tom, že zde vytvoříme instanci (nijak neupravené) třídy Thread samostatně a předáme jí instanci třídy implementující rozhraní Runnable. Když se pak instanci Thread zavolá metoda start(), způsobí to, že se v rámci vlákna začne vykonávat metoda run() v "asociovaném" objektu MyThread (implementující Runnable).

Životní fáze vlákna

Vlákno od svého vytvoření (myšleno konstruktorem třídy Thread) do finalizace prochází řadou fází - do některých z nich se může, ale také nemusí dostat. Jedná se o tyto fáze:

  1. Vytvořené, nespuštěné - vlákno bylo vytvořeno jako instance objektu, a dosud nebylo spuštěno (nevykonává se kód). Některá nastavení vlákna lze provést jen v tomto stavu.
  2. Spuštěné, běžící - vlákno běží, procesor vykonává jeho kód.
  3. Spuštěné, čekající - vlákno může běžet, ale čeká na přidělení procesoru.
  4. Spuštěné, uspané nebo zablokované - vlákno bylo uspáno metodou sleep() nebo zablokováno voláním wait(), join() či jiným způsobem (např. v blokující operaci).
  5. Ukončené - vlákno doběhlo (vyskočilo z metody run()) a pouze přečkává, než bude (po ztrátě referencí) odstraněno jako instance kteréhokoli objektu.

Uvedené stavy jsou chápány z hlediska logického, nikoli implementačního (vnitřně se např. rozlišuje stav blokovaného vlákna a vlákna čekajícího na nějakou událost). Existují ještě další fáze, ale do nich se vlákno může dostat pouze zavoláním některé ze zavržených (deprecated) metod. Proto je lepší se o těchto stavech vůbec nezmiňovat, případné zájemce odkazuji na dokumentaci.

Operace s vláknem

Normální vlákno, jak jsme ho vytvořili v příkladech, běží vedle hlavního vlákna (toho, které běží od začátku programu), a program skončí až v momentě, kdy dokončí běh všechna taková vlákna. Někdy je ale třeba, aby běh programu závisel pouze na jediném vlákně nebo omezené skupině, a zbylá vlákna na to vliv neměla. K tomu slouží tzv. démoni. Démona vytvoříme z normálního vlákna (resp. to jde i obráceně) metodou setDaemon(). Musí se to ale udělat před spuštěním vlákna, jinak se dočkáme výjimky IllegalThreadStateException.

Vláknům můžeme nastavovat priority. Nově vytvořené má výchozí prioritu (střední), můžeme nastavit větší nebo menší hodnotu. Interpretace záleží na konkrétní implementaci vláken. U native threads je převedena na prioritu vlákna v operačním systému, kdežto u green threads má vlákno s vyšší prioritou vždy absolutní přednost před vláknem s prioritou nižší (pozor na to!). Priorita se nastavuje voláním setPriority() v rozsahu od MIN_PRIORITY do MAX_PRIORITY; lze to provést před spuštěním vlákna i za běhu.

Pro snazší práci (hlavně při ladění) si lze vlákna pojmenovávat. Jméno se určí buď v konstruktoru, nebo později metodou setName(). Jména vláken nemusí být unikátní.

Nesobecká vlákna

Jak jsem se již zmínil, správně napsaný multithreadový program by neměl spoléhat na konkrétní implementaci vláken. S tím souvisí důležitá podmínka, aby vlákna tzv. "nebyla sobecká" - jinými slovy, aby si neusurpovala procesor tak, že tím ostatním vláknům brání v běhu.

Je proto nutné zajistit, aby se každé vlákno dostatečně často vzdávalo procesoru. K tomu dochází v těchto případech:

Uspání vlákna je spolehlivé, ale ne vždy ho potřebujeme. Spoléhání na blokující operace je ošemetné, protože často k zablokování dojít nemusí a vlákno poběží dál. Naproti tomu zavolání yield() vynutí nové naplánování vlákna, a proto funguje zcela spolehlivě (pozor ale na priority!).

Uvedený způsob má ale jednu nevýhodu - vhodně umístit volání yield() totiž v řadě případů vůbec není triviální, a špatné rozmístění může mít podobný efekt, jako kdyby se to neudělalo vůbec. Proto existuje ještě jedna cesta - vytvoření speciální "plánovacího" vlákna. Toto vlákno bude mít maximální prioritu, většinu času bude uspáno, jen občas se probudí a zase hned usne. Tím dojde ale k naplánování jiného ze zbývajících vláken, takže to má ve výsledku podobný efekt, jako kdyby se vlákna plánovala nativně. Důležité je ale zvolit vhodnou granularitu (délku maximálního časového kvanta) - pro většinu případů lze použít hodnoty 5-50 ms.

Synchronizace přístupu

Multithreading přináší mnoho výhod, ale také určité nevýhody. Jednou z nich je nutnost synchronizovat přístup k datům tak, aby byla zaručena jejich integrita a konzistence. Obecně je lepší synchronizovat spíš více než méně, protože nadbytečná sychronizace pouze zpomaluje, kdežto nedostatečná vážně narušuje funkci programu. Vždy je ovšem potřeba dát si pozor, aby se vlákna nemohla vzájemně zablokovat (deadlock). Proto je nutné snažit se (již ve fázi návrhu), aby synchronizovaných míst bylo co nejméně.

Pro synchronizaci máme opět více možností:

synchronized metoda

Metoda může být deklarována s modifikátorem synchonized. To znamená, že v okamžiku vstupu do metody se objekt zamkne a při opuštění odemkne. Zavolá-li metodu jiné vlákno, musí čekat, než ji opustí vlákno, které ji zavolalo dřív.

class MyClass {
    private int x = 0;
    private int y = 0;
    
    public synchronized void setData(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

Metoda setData() v příkladu pracuje tak, že je během modifikace dat vyloučen přístup z jiného vlákna. Je zde totiž žádoucí, aby se proměnné x a y měnily vždy současně, proto nelze připustit, aby si někdo přečetl jejich hodnoty v okamžiku, kdy je jedna změněna a druhá nikoli.

synchronized blok

Předchozí řešení je velice jednoduché a elegantní, ale jednak může zamykat na zbytečně dlouhou dobu (což by se muselo řešit rozdělením na více metod), a za druhé vyžaduje, aby byla příslušná třída již takto implementována. Máme-li třídu, která (typicky z výkonnostních důvodů) nezamyká objekt při jeho modifikaci, nejjednodušší řešení je použít blok s deklarací synchronized a uvedením objektu, který se má zamknout.

java.awt.Point p = new java.awt.Point(10, 20);

...

synchronized (p) {
    p.setLocation(5, 50);
}

Metoda setLocation() není synchronizovaná, proto je nutno (při přístupu z více vláken) synchronizovat zvenku. Funkce uvedeného kódu je zřejmá.

Poznámka: S třídou Point se běžně nepracuje tak, aby k ní mohlo přistupovat více vláken (proto je z výkonnostních důvodů bez synchronizace). Proč tomu tak je, si řekneme později, v úvodu do javovské grafiky.

Synchronizační wrappery

V kapitole o kolekcích jsme se setkali s tzv. synchronizačními wrappery. Použijí se v případě, kdy máme kolekce bez synchronizace přístupu. Wrapper navenek zapouzdří příslušný objekt, aniž by se změnilo jeho rozhraní, a o synchronizaci se postará.

Čekání na konec jiného vlákna

Zavoláme-li nějakému jinému vláknu metodu join(), aktuální vlákno se zastaví a bude čekat na skončení běhu onoho vlákna. Lze využít i verze s časovým limitem - pak se bude čekat maximálně po zvolenou dobu.

Čekání na objektu

Každý objekt (jakýkoli potomek třídy Object) má sadu metod wait(). Zavolání této metody způsobí, že se aktuální vlákno zastaví do doby, než bude uvolněno zavoláním metody notify() nebo notifyAll() tomuto objektu. První metoda uvolní právě jedno vlákno, druhá všechna vlákna. Vlákno, které bude některou z těchto metod volat, si objekt musí nejprve zamknout - buď v rámci synchronized metody, nebo v synchronized bloku. Čekání lze opět omezit časovým limitem.

Modifikátor volatile

Pracuje-li s jednou proměnnou více vláken, obecně není zaručeno, že každé z vláken uvidí správnou hodnotu, přestože je modifikační operace atomická. Je to proto, že se přístup k datům optimalizuje a vlákna používají lokální kopie, jejichž obsah se nemusí včas promítnout do původní proměnné. Pokud se proměnná deklaruje s modifikátorem volatile, každá změna je okamžitě viditelná pro všechna vlákna a další synchronizace již není nutná.

Přerušení vlákna

Přerušení je událost, kterou je nutno zvlášť ošetřit. Lze ji přirovnat k příchodu signálu do procesu. Pokud vlákno běží, je pouze nastaven příznak, že došlo k přerušení (lze zjistit zavoláním isInterrupted() nebo interrupted(); pozor - metoda interrupted() příznak resetuje!). Vlákno se přerušuje voláním interrupt().

Pokud vlákno čekalo (uspáno nebo v blokující operaci), je navíc vyvolána výjimka InterruptedException a vlákno se rozběhne. Tato výjimka je synchronní, její ošetření je tedy u daných operací povinné. Přerušit čekající vlákno lze např. v okamžiku, kdy se má ukončit program a vlákno by po sobě mělo uklidit. V minulé kapitole bychom takto třeba uzavřeli otevřený socket:

try {
    ServerSocket ss = new ServerSocket(22222);
    
    try {
        while (!quit) {
            final Socket sock = ss.accept();
            Thread t = new Thread() {
                public void run() {
                    try {
                        InputStream is = sock.getInputStream();
                        OutputStream os = sock.getOutputStream();
                         
                        ...
                        
                        sock.close();
                    } catch (IOException e) {
                        ...
                    }
                }
            };
            t.setDaemon(true);
            t.start();
        }
    } catch (InterruptedException e) {  // zde se zachytí přerušení
        ss.close();                     // uzavření socketu
    }
} catch (Exception e) {
    ...
}

Skupiny vláken

V rozsáhlejších programech často pracujeme s mnoha vlákny, která se dají rozdělit do různých logických skupin. V jedné mohou být třeba vlákna obsluhující síťové požadavky, ve druhé vlákna pro zpracování dat atd. Jejich správu si můžeme usnadnit využitím třídy ThreadGroup.

Skupiny, tvořené instancemi ThreadGroup, jsou hierarchicky (stromově) organizovány. Lze tak vlákna ovládat na různých úrovních podle toho, jak právě potřebujeme. Každá skupina může být, podobně jako samotné vlákno, libovolně pojmenována.

V rámci skupin lze např. určovat vláknům maximální prioritu nebo vlákna hromadně přerušovat. Do skupiny lze vlákno přidat jen v okamžiku jeho vytváření (skupina se předá jako parametr konstruktoru), později již změna není možná.

ThreadGroup tg = new ThreadGroup("network server threads");
tg.setDaemon(true);

Runnable r = new Runnable() {
    public void run() {
        ...
    }
};

Thread t1 = new Thread(tg, r);
Thread t2 = new Thread(tg, r);

V příkladě se vytvoří skupina vláken - tako skupina bude démon, tzn. bude automaticky zrušena v okamžiku, kdy doběhne poslední vlákno. Při vytváření vláken jim (kromě instance implementující Runnable) předáme i tuto skupinu, čímž se vlákna stanou jejími členy.

Lokální data vláken

Někdy je vhodné, aby každé vlákno pracoval se specifickými daty, a přesto naprosto stejným způsobem jako jiná vlákna. K tomu slouží třída ThreadLocal, která slouží jako "mikrokontejner" (pojímá jednu hodnotu) pro tato data. Instanci tohoto objektu může každé vlákno nastavit nějakou hodnotu - a je zaručeno, že při požadavku na hodnotu vlákno obdrží vždy právě tu svoji. Pokud si vlákno nic nenastaví, dostane hodnotu null, ledaže by byla (v potomkovi ThreadLocal) předefinována metoda initialValue().

Před JDK 1.5 bylo nutné hlídat si typ dat, a podle potřeby přetypovávat. Od JDK 1.5 (Java 5.0) má ThreadLocal generický charakter, a lze proto provádět automaticky typovou kontrolu.

Další pohled pod kapotu

Jsme na konci poměrně dlouhé kapitoly o vláknech. Přichází vhodná chvíle k dalšímu pohledu pod kapotu - tentokrát na datové typy. Podíváme se na přetypovávání, kontrole typů, zjišťování informací o typech atd. Právě tato část Javy patří k těm nejvíce propracovaným, podle mého názoru je to oblast velice zajímavá.

Online verze článku: http://www.linuxsoft.cz/article.php?id_article=1006