Image
22.6.2016 0 Comments

Java pod lupou I. /17.časť

Späť na úvod >> Späť na programovanie >> Späť na seriál

Thready a synchronizácia

Napriek sľubu v závere predošlej časti sa balíku Swing ani ďalším balíkom v tomto seriáli nebudeme venovať. Pri zložitejších témach totiž vôbec nie je jednoduché efektívne zhrnúť obrovské množstvo informácií do jednej či dvoch častí seriálu. Ak sa to podarí, výsledok pripomína skôr referenčnú príručku a o tom seriál predsa nie je. Čitateľ chtivý podrobnejších informácií preto bude musieť hľadať aj inde, najlepšie na internete.

Multithreading

Sotva by sa dnes uživila programovacia platforma, ktorá by neumožňovala paralelné vykonávanie úloh. Podľa úrovne, na ktorej paralelizmus pozorujeme, môžeme hovoriť o multitaskingu alebo o multithreadingu. Prvý pojem označuje schopnosť súčasného behu viacerých procesov. Pod procesom si predstavme samostatne vykonávanú exekučnú jednotku, ktorá má pridelený adresový priestor, disponuje vnútorným stavom a môže vlastniť prostriedky OS. Skrátka, proces je „bežiaci program“. Jeden binárny program môže bežať súčasne vo viacerých kópiách – každá kópia je potom samostatným procesom. Multitasking sa dnes považuje za samozrejmosť.

Pri multithreadingu rozdelíme jeden proces na niekoľko menších podprocesov, tzv. threadov. Názov pochádza z angličtiny, kde znamená „vlákno“. O threadoch preto niekedy hovoríme ako o „exekučných vláknach“. Thready medzi sebou zdieľajú adresový priestor, môžu však obsahovať vlastné, privátne údaje. Dôvodom, prečo sa uchyľujeme k rozdrobeniu aplikácie na thready, je obyčajne snaha o zlepšenie používateľského ohlasu. V takom textovom editore sa jeden thread môže starať o editáciu textu, ďalší o obnovu obrazovky, kontrolu pravopisu či tlač na pozadí. Keby toto všetko mal robiť jeden thread, nebolo by možné pri tlačení upravovať text, priebežne kontrolovať pravopis a podobne. Existujú však aj typy úloh, pri ktorých je výhodné použiť thready z iného dôvodu – napríklad na zjednodušenie algoritmu alebo implementáciu zložitejšieho správania.

Ak máme dosiahnuť paralelný beh viacerých procesov či threadov na jednoprocesorovom systéme, musíme sa uchýliť k nejakému triku. Rýchlym prepínaním medzi jednotlivými threadmi môžeme vytvoriť ilúziu súčasného behu viacerých úloh. V operačnom systéme zvyčajne existuje samostatný modul, plánovač (scheduler), ktorý sa stará o prideľovanie procesora jednotlivým threadom na základe vhodnej stratégie. Ak je plánovač schopný po uplynutí vopred stanoveného časového kvanta thread pozastaviť a spustiť iný, hovoríme o preemptívnom plánovaní. (V ére šestnásťbitových Windows, ktoré neposkytovali preemptívny multitasking, sa musel každý bežiaci proces explicitne a dobrovoľne zriecť tohto svojho výsadného postavenia. Ak sa stalo, že sa program zasekol, spolu s ním išiel pod kytičky aj operačný systém.)

Niektoré thready sú pre beh systému dôležitejšie. Aby sa zabezpečila plynulosť ohlasu, majú thready priradenú celočíselnú hodnotu priority. Plánovač threadov preferuje pri prideľovaní procesora thready s vyššou prioritou. Thready sa môžu nachádzať v rôznych stavoch, najhrubšie rozdelenie je na thready bežiace (môže ich byť aj viac ako jeden, napríklad na multiprocesorových systémoch), thready pripravené na naplánovanie a thready „spiace“, ktoré čakajú na výskyt nejakej udalosti. Väčšina threadov je počas svojho života v tomto treťom stave, pretože čaká na vstup z klávesnice, na pohyb myši, na vykreslenie obrázka, na dodanie údajov z disku a podobne. Len thready, ktoré intenzívne počítajú, dokážu využiť svoje časové kvantum naplno.

Thread môže zmeniť svoj stav aj inak ako vypršaním prideleného času – môže sa dobrovoľne zriecť procesora alebo môže vyjadriť svoju potrebu čakať na splnenie nejakej podmienky. Spiaci thread môže byť zobudený systémom alebo iným threadom, ktorý sa postaral o splnenie podmienky.

Thready a Java

Java poskytuje dva spôsoby, ako vytvoriť thread. Prvým je odvodenie vlastnej podtriedy systémovej triedy java.lang.Thread. Jadrom threadu je metóda run() (treba ju, pochopiteľne, predefinovať), ktorá sa začne vykonávať po spustení threadu. To dosiahneme zavolaním metódy start(). Thread beží, kým je v metóde run() čo robiť. Len čo sa v metóde vyskytne príkaz return či nezachytená výnimka a aj keď tok riadenia dospeje ku koncovej zátvorke, thread končí a systém ho po čase zlikviduje. Ukážka vytvorenia a spustenia nového threadu:

class MyThread extends Thread
{
  // ...
}
 
MyThread mt = new MyThread();
mt.start();

V príklade vytvárame objekt MyThread, ktorý sme odvodili od triedy Thread, a voláme jeho metódu start().

Druhý spôsob spočíva v implementácii rozhrania Runnable, ktoré obsahuje metódu run(). Objekt implementujúci toto rozhranie potom môžeme zadať ako argument konštruktora na vytvorenie objektu typu Thread. Pri tomto spôsobe sa po zavolaní metódy start() začne vykonávať metóda run() objektu Runnable. Ukážka:

class MyThread implements Runnable
{
  // ...
}
 
MyThread mt = new MyThread();
Thread tt = new Thread(mt);
tt.start();

Ako vidno, tentoraz vytvárame dva objekty; jeden typu MyThread (to je ten, ktorý v metóde run() obsahuje kód nového threadu) a druhý typu Thread – ten funguje ako „obálka“ prvého objektu.

Pri vytváraní nového threadu môžeme voliteľne zadať jeho meno; to sa môže hodiť napríklad pri ladení projektu. Okrem toho môžeme threadu prideliť skupinu, do ktorej bude patriť (pozri ďalej).

Trieda Thread poskytuje niekoľko veľmi potrebných metód. Pomocou metódy yield() sa napríklad thread môže vzdať procesora. Zavolaním metódy sleep() thread na stanovený čas „zaspí“. Násilne prerušiť thread možno metódou interrupt(). Pomocou metódy join() môžeme prikázať aktuálnemu threadu, aby čakal na skončenie iného threadu. Metóda isAlive() indikuje, či daný thread žije, t. j. či bol spustený a dosiaľ beží. Názov a prioritu threadu zisťujeme a nastavujeme dvojicami (get|set)Name() a (get|set)Priority().

Javovský program sa končí buď explicitným volaním System.exit(), alebo v okamihu, keď svoj beh skončia všetky thready. Pomocou metódy setDaemon() môžeme vybrané thready premeniť na „démonov“ (a naopak). Systém pri rozhodovaní, či ukončiť program, na „démonické“ thready neberie ohľad. Metóda run() týchto threadov obyčajne obsahuje nekonečnú slučku. Na zistenie, či je vybraný thread démonom, použijeme metódu isDaemon().

Skupiny threadov

Thready možno zlučovať do skupín, ktoré tvoria stromovú štruktúru. Každá skupina okrem počiatočnej má pridelenú rodičovskú skupinu. Pri vytvorení nového threadu možno určiť, do ktorej skupiny bude thread zaradený.

Na reprezentáciu skupiny threadov je určená trieda ThreadGroup. Pri vytvorení jej možno prideliť meno a rodičovskú skupinu. Ak rodičovskú skupinu nezadáme, stane sa ňou automaticky skupina, do ktorej patrí aktuálny thread. Pomocou metódy setMaxPriority() nastavujeme maximálnu hodnotu priority, akú môžu mať thready patriace do skupiny. Z ďalších metód vyberáme setDaemon() na transformáciu všetkých threadov v skupine na démonov a naopak, interrupt() na prerušenie všetkých threadov v skupine, getName(), getParent(), getMaxPriority(), isDaemon(), isDestroyed() na zistenie atribútov skupiny či activeCount()/activeGroupCount() na zistenie počtu aktívnych threadov a aktívnych skupín patriacich do zadanej skupiny.

V súvislosti s threadmi spomeňme ešte triedu ThreadLocal. Táto trieda predstavuje zvláštny druh objektov, líšiacich sa od bežných premenných v tom, že každý thread, ktorý s nimi pracuje, má k dispozícii vlastnú, nezávisle inicializovanú kópiu. Objekty ThreadLocal zvyčajne bývajú privátnymi statickými zložkami tried, ktoré s threadmi nejakým spôsobom súvisia.

Na čítanie a zápis hodnoty premenných typu ThreadLocal použijeme metódy get()set(), ktoré pracujú s typom Object, takže uložená hodnota môže byť v podstate ľubovoľná. Počiatočná hodnota premenných je null; ak potrebujeme použiť inú inicializačnú hodnotu, musíme si odvodiť vlastnú triedu a predefinovať chránenú metódu initialValue().

Synchronizácia

Nevyhnutným dôsledkom zavedenia multitaskingu a multithreadingu je celkom nová trieda problémov, ktoré v jednopoužívateľských a jednoúlohových prostrediach prakticky neexistovali. Zoberme si ako príklad dva thready, ktoré pracujú s rovnakými údajmi. V prípade, že každý thread číta a zapisuje údaje bez ohľadu na ten druhý, ľahko môže dôjsť k narušeniu integrity údajov. Majme nasledujúci kód:

void run()
{
  for (int i = 0; i < 10; i++)
    x = x + 1;
}

Nech oba thready vykonávajú tento kód, ktorého účelom je desaťkrát inkrementovať premennú x, ktorá je prístupná obom threadom (napríklad je statickým členom tej triedy, do ktorej patrí metóda run()). Predpokladajme, že počiatočná hodnota x je rovná nule. Ak najprv spustíme jeden thread a po jeho skončení druhý, dostaneme výsledok 20. Nechajme teraz oba thready bežať naraz. V závislosti od okolností bude ležať konečný výsledok niekde v rozsahu od 10 po 20. Ako je to možné?

Jadrom problému je riadok x = x + 1 (zámerne nie je použitý operátor ++). Pri vyhodnocovaní tohto výrazu thread načíta obsah premennej x do dočasnej pamäte (registra), zvýši ho v nej o jednotku a uloží naspäť do hlavnej pamäte. Toto vyhodnotenie však nie je atomické (nedeliteľné) a v prípade, že threadu vyprší časové kvantum niekde „uprostred“, v premennej x zatiaľ zostane stará hodnota. Druhý thread si odteraz s premennou môže robiť, čo chce, jeho úpravy budú onedlho zabudnuté; len čo sa totiž k slovu opäť dostane prvý thread, dokončí vyhodnotenie výrazu a prepíše momentálnu hodnotu x v hlavnej pamäti hodnotou, ktorá odpočívala v pomocnom registri.

Poznámka: Je pochopiteľné, že pri praktickej realizácii k opísanému problému vôbec nemusí dôjsť. Použitá implementácia JVM môže vyhodnotenie výrazu vykonať atomicky alebo budú okolnosti natoľko priaznivé, že k žiadnej chybe nedôjde. To však vo všeobecnosti očakávať nemôžeme – vždy treba rátať s najhorším možným prípadom. Programátor, ktorý sa spolieha na náhodu, si nezaslúži nič iné, len svoje programy za trest používať.

Ak chce zvedavý čitateľ vidieť efekt vzájomného „lezenia do kapusty“ na vlastné oči, stačí inkriminovaný výraz rozpísať napríklad takto:

int x_tmp = x;
yield();
x = x_tmp + 1;

Uvedené tri riadky simulujú vyhodnotenie pôvodného výrazu s vynúteným preplánovaním na druhý thread medzi načítaním starej a uložením novej hodnoty x. Výsledná hodnota x bude pravdepodobne 10.

Tento príklad len jemne naznačuje celú sféru problémov, ktoré môžu vzniknúť pri súbežnej činnosti viacerých threadov a procesov. Riešenie spočíva v použití niektorej zo synchronizačných metód. Rozsah článku, bohužiaľ, nedovoľuje zaoberať sa týmito metódami podrobnejšie, pretože ide o veľmi rozsiahlu (ale o to zaujímavejšiu) tému. Pozrime sa preto len na možnosti, ktoré na vyriešenie synchronizačných problémov poskytuje Java.

Príkaz synchronized

Úsek kódu, ktorý by sa mal vykonávať atomicky, t. j. maximálne jedným threadom súčasne, nazveme v súlade s tradíciami kritickou sekciou. Vylúčiť súčasnú prítomnosť viacerých threadov v kritickej sekcii je jednoduché: nepovolíme do nej vstup v prípade, že sa tam nachádza iný thread. Je, samozrejme, nevyhnutné, aby thready pred vstupom do kritickej sekcie svoj úmysel dali najavo.

Existuje mnoho spôsobov, ako zabezpečiť toto tzv. vzájomné vylučovanie (mutual exclusion). Java používa zámky (locks) a z nich vychádzajúcu implementáciu monitorov (pozri ďalej). Zámok je metaobjekt, ktorý nie je priamo prístupný programátorovi a viaže sa vždy na existujúci objekt (to, mimochodom, znamená, že pomocou primitívnych typov zamykať nemožno). Pre každú kritickú sekciu v programe by mal existovať samostatný zámok.

Pred vstupom do kritickej sekcie sa thread pokúsi získať zámok nad vybraným objektom. Ak sa mu to podarí, zámok sa uzamkne a thread môže vykonávať kód kritickej oblasti. Ak sa mu to nepodarí, pretože zámok je už zamknutý, znamená to, že v kritickej oblasti je niekto iný. Thread bude preto pozastavený a opäť sa rozbehne až po odomknutí zámku. Nevstupuje však do kritickej sekcie automaticky, ale opakuje svoj pokus o získanie zámku. Medzi okamihom, keď thread prešiel zo spiaceho stavu do stavu pripravenosti, a okamihom, keď mu bol pridelený procesor, totiž mohol do kritickej sekcie vstúpiť ďalší thread (hoci aj ten istý, čo v nej bol predtým). Že to nie je spravodlivé? Nuž, v paralelnom prostredí si príliš nenavyberáme…

V zdrojovom kóde kritickú sekciu musíme „obaliť“ do príkazu synchronized. Ten má nasledujúcu syntax:

synchronized ( výraz )
{
  telo (kritická sekcia)
}

Výsledkom výrazu musí byť nenulová referencia na existujúci objekt. Pred začatím vykonávania tela príkazu sa thread pokúsi získať zámok nad týmto objektom. Ako zamykací objekt použijeme niektorý z objektov, s ktorými pracujeme v kritickej sekcii, alebo si vypomôžeme pomocným objektom:

Object lock = new Object();
synchronized (lock)
{
  ...
}

Sémantika zamykania objektov v Jave dovoľuje threadu, ktorý vlastní zámok nad nejakým objektom, získať tento zámok ešte raz. To je dôležité napríklad v takomto prípade:

synchronized (lock)
{
  synchronized (lock)
  {
    // ...
  }
}

Thread sa nezasekne na druhom príkaze synchronized, ale plynule prejde do kritickej sekcie.

Dôležitá poznámka: Je nevyhnutné zabezpečiť, aby sa k premenným a objektom, s ktorými pracujeme v kritickej sekcii, dalo v programe pristupovať len cez príkazy synchronized, ktoré navyše musia pracovať s rovnakým zamykacím objektom. V opačnom prípade synchronizácia stráca zmysel.

Monitory

V paralelnom programovaní pojem monitor opisuje údajovú metaštruktúru, ktorú by sme mohli pripodobniť k javovskej triede: monitor má vnútorný stav (údajové členy) a poskytuje operácie, ktoré možno nad monitorom vykonávať (metódy). Podstatným rozdielom oproti triedam je zabezpečenie vzájomného vylučovania pri prístupe k monitoru. Inak povedané: z metód monitora môže byť súčasne volaná najviac jedna.

Implementácia monitorov v Jave je logickým dôsledkom použitia mechanizmu zámkov. Ak sa zamyslíme nad tým, ako by sa dalo vzájomné vylučovanie pri prístupe k metódam triedy realizovať, rýchlo prídeme na najjednoduchšiu možnosť: obaliť kód metódy do príkazu synchronized a ako zamykací objekt použiť this. Ide to však ešte jednoduchšie. Ak pri deklarácii metódy použijeme ako jeden z modifikátorov kľúčové slovo synchronized, dosiahneme tým, že pri volaní metódy sa aktuálny thread najprv pokúsi získať zámok nad objektom, nad ktorým metódu volá. V prípade, že ide o statickú metódu, v rámci ktorej objekt this neexistuje, ako zamykací objekt sa použije objekt typu Class reprezentujúci danú triedu.

Ako jednoduchý príklad monitora si ukážme triedu, ktorá vyrieši náš predchádzajúci problém s paralelnou inkrementáciou premennej x:

class MutExVar
{
  private int value;
  public MutExVar(int _val)
  { value = _val; }
  public synchronized void set(int _val)
  { value = _val; }
  public synchronized int get()
  { return value; }
  public synchronized void inc()
  { value ++; }
}

Oba thready budú namiesto príkazu x = x + 1 v cykle volať metódu x.inc() (predpokladáme, že x je teraz inštanciou triedy MutExVar). Ďalšie synchronizované metódy set()get() slúžia na nastavenie a získanie uloženej hodnoty. Konštruktor z pochopiteľných dôvodov nemusí byť synchronizovaný.

Problém s deadlockom

Pri práci so zámkami sa treba vyvarovať situácie, ktorá sa bežne označuje anglickým termínom deadlock, po slovensky uviaznutie. Okolo problému deadlocku existuje celá teória, takže len stručne: stav deadlocku znamená, že jeden alebo viacero threadov neobmedzene dlho čaká na vstup do kritickej sekcie (a nikdy sa do nej nedostane).

Ako môže k deadlocku dôjsť? Predstavme si dva thready, ktoré na vstup do kritickej sekcie potrebujú získať dva zámky, ale budú to robiť v navzájom opačnom poradí. Prvý thread napríklad takto:

synchronized (lock1)
{
  synchronized (lock2)
  {
    // ...
  }
}

a druhý takto:

synchronized (lock2)
{
  synchronized (lock1)
  {
    // ...
  }
}

Ak sa prvému threadu podarí získať lock1 a druhému lock2, v tom okamihu sú odsúdení na večné čakanie, pretože ani jeden nemôže získať druhý zámok (má ho ten druhý). Vyriešenie tohto konkrétneho problému je jednoduché – stačí zameniť poradie príkazov synchronized v niektorom z threadov. Univerzálny liek však na odstránenie nebezpečenstva deadlocku neexistuje a pri písaní programov treba používať predovšetkým zdravý rozum a riadnu dávku predstavivosti.

Podmienečné premenné

Java poskytuje ešte jeden synchronizačný prostriedok, ktorý sa najviac podobá konceptu podmienečných premenných (conditional variables). Tie si môžeme predstaviť ako metaobjekty, ktoré predstavujú synchronizačné body viazané na splnenie nejakej podmienky. Poskytujú dve operácie s tradičnými názvami waitsignal, ktorým v Jave zodpovedajú metódy wait()notify(). Ide o metódy triedy Object, a teda ich môžeme zavolať nad každou inštanciou objektového typu.

Aká je sémantika týchto dvoch operácií? Ak thread zistí, že podmienka, ktorá je pridružená k podmienečnej premennej, nie je splnená, vykoná operáciu wait. Tým sa zaradí do zoznamu čakateľov na danú podmienečnú premennú, prejde do stavu „spiaci“ a naďalej nesúťaží o pridelenie procesora. Zo spánku ho môže zobudiť iný thread, ktorý nad podmienečnou premennou vykoná operáciu signal. Dôsledkom tejto operácie je výber jedného z čakateľov a jeho zobudenie. Zobudený thread sa presúva do stavu „pripravený“ a čaká na pridelenie procesora. Ak je zoznam čakateľov prázdny, operácia signal nemá nijaký efekt.

Pozrime sa teraz na vec na úrovni zdrojového kódu. Operácii wait zodpovedá zavolanie metódy wait() nad ľubovoľným objektom (ktorý nahrádza spomínanú podmienečnú premennú). Aktuálny thread musí nad týmto objektom vlastniť zámok. V rámci vykonávania metódy je thread pozastavený a zámok sa mu odoberie (to je nevyhnutný krok, pretože inak by spiaci thread blokoval akýkoľvek prístup k synchronizačnému objektu). Pri volaní metódy wait() možno ako voliteľný argument zadať maximálny čas, počas ktorého thread bude čakať na zobudenie.

Druhej operácii signal zodpovedá metóda notify(), ktorú, pochopiteľne, voláme nad rovnakým objektom ako metódu wait(). Thread, ktorý metódu volá, tak obyčajne činí preto, lebo zabezpečil splnenie podmienky pridruženej k objektu. Ako dôsledok volania metódy notify() sa zobudí jeden z čakajúcich threadov (ktorý to bude, je vecou implementácie). Ak nikto nečaká, metóda je bez efektu. Niekedy príde vhod zobudiť všetky čakajúce thready – vtedy použijeme metódu notifyAll(), ktorá sa inak správa ako metóda notify(). Metódy notify()notifyAll() by mal volať len thread, ktorý je momentálne vlastníkom zámku nad synchronizačným objektom.

Zobudený thread nezačne bežať okamžite. Je zaradený do radu threadov čakajúcich na naplánovanie a po pridelení procesora sa najprv snaží získať zámok, ktorý mu bol pred pozastavením odobraný. Po získaní zámku ukončí volanie metódy wait() a pokračuje nasledujúcim príkazom. V prípade, že thread volal metódu wait() ako dôsledok nesplnenia nejakej podmienky, je veľmi dôležité test podmienky zopakovať, pretože nie je zaručené, že sa od okamihu zobudenia threadu po jeho opätovné naplánovanie táto podmienka nezmenila. Namiesto kódu:

synchronized ( obj )
{
  if (! podmienka )
    obj.wait();
}

treba bezpodmienečne použiť kód:

synchronized ( obj )
{
  while (! podmienka )
    obj.wait();
}

A ešte jedna poznámka na záver: Threadu sa počas vykonávania metódy wait() odoberá len zámok na aktuálny objekt. Prípadné ďalšie zámky mu zostávajú a sú tak dobrým kandidátom na vznik deadlocku.

Príklady

Zdrojové texty k príkladom možno tradične nájsť na webovej stránke PC REVUE. Nachádza sa tam okrem iného riešenie klasického problému synchronizácie prístupu ku konečnému bufferu (inak známeho aj ako problém producentov/konzumentov).

 

 

 


Nechajte si posielať prehľad najdôležitejších správ emailom

Mohlo by Vás zaujímať

Ako na to

Tipy a triky: Ako na snímku obrazovky na akomkoľvek počítači s Windows?

02.12.2016 00:13

Ak snímky obrazovky robíte často apotrebujete napríklad funkcie na posun stránok alebo snímanie zobrazenia pri vyššom rozlíšení displeja, zrejme používate nejakú špecializovanú aplikáciu. Väčšina použ ...

Ako na to 1

Tipy a triky: Ako aplikácii prednastaviť spúšťanie s administrátorskými právami?

30.11.2016 00:10

Väčšina aspoň trochu skúsenejších používateľov vie, že aj keď máte na operačnom systéme Windows vytvorený administrátorský účet, aplikácie pre bezpečnosť nefungujú vždy splnými administrátorskými práv ...

Ako na to 2

Tipy a triky: Ako vypnúť uzamykaciu obrazovku vo Windows 10?

29.11.2016 00:10

Rozčuľuje vás, že pred každým prihlásením doúčtu vášho počítača musíte prejsť uzamykacou obrazovkou? Windows 10 na tejto obrazovke ukazuje čas,dátum anejakú zaujímavú fotografiu zrôznych kútov sveta. ...

Žiadne komentáre

Vyhľadávanie

Kyocera - prve-zariadenia-formatu-a4-s-vykonom-a3

Najnovšie videá