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 53166×
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:
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.
"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á.
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:
Thread
(s nezbytným předefinováním metody run()
)Runnable
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.
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
).
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:
sleep()
nebo zablokováno voláním wait()
, join()
či jiným způsobem (např. v blokující
operaci).
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.
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í.
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:
read()
, write()
, accept()
), synchronizační
operace (wait()
, join()
)sleep()
yield()
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.
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.
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á.
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.
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.
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í 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) { ... }
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.
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.
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á.