PV168/Vlákna

Z FI WIKI
Verze z 15. 4. 2014, 17:39; 2154@muni.cz (diskuse | příspěvky)

(rozdíl) ← Starší verze | zobrazit aktuální verzi (rozdíl) | Novější verze → (rozdíl)
Přejít na: navigace, hledání

Vlákna jsou mechanismus, který umožňuje souběžné vykonávání více činností (čili posloupností operací) najednou. Jedno vlákno reprezentuje právě jednu posloupnost operací, která je prováděna v rámci nějakého procesu. Každý proces musí mít minimálně jedno vlákno, ale může jich mít více.

Tvorba vícevláknových aplikací v prostředí platformy Java je jednodušší než např. v C/C++, přesto se však nejedná o triviální úlohu a je nutné znát řadu zásad a postupovat velice obezřetně. S vlákny je spojena řada záludností, které nemusí být na první pohled vůbec zřejmé. Přitom se použití vláken u většiny aplikací prakticky nedá vyhnout, ať už se jedná o aplikaci s GUI, nebo serverové aplikace. Věnujte proto zvýšeno pozornost tomuto textu a uvedeným informacím.

Vlákna a pamětový model

Pro pochopení vláken je dobré vědět jak vlastně počítač provádí programy. Program má svoje instrukce uloženy v paměti (RAM). V procesoru je speciální registr IP (instruction pointer), který obsahuje adresu právě prováděné instrukce, tedy ukazuje na místo v programu, které je právě vykonáváno.

Je možné, aby bylo vykonáváno více míst v programu naráz, a každému takovému sledu vykonávání instrukcí se právě říká vlákno. Více vláken může být vykonáváno jedním ze dvou způsobů:

  • je více CPU, a každý provádí jiné vlákno
  • na jednom CPU se vlákna střídají ve stanovených časových intervalech

Threads in computer.png

Každé vlákno má vlastní oblast paměti, tzv. zásobník (stack), kam si ukládá obsahy lokálních proměnných, a při volání metod/funkcí/procedur si tam ukládá argumenty, návratové adresy a návratové hodnoty. Adresu vrcholu zásobníku v paměti obsahuje speciální registr SP (stack pointer).

U jazyka Java je to mírně zkomplikováno tím, že program je vykonáván na virtuálním stroji JVM, ale tento virtuální stroj je vykonáván instrukcemi CPU, takže výše uvedené platí taky.

Při vykonávání více vláken na jednom procesoru se vlákna střídají tak, že po určité době přijde přerušení, obsah registrů se odloží do paměti, a nahraje se obsah registrů jiného vlákna, načež to jiné vlákno pokračuje v činnosti.

Přístup ke sdílené paměti

Kromě zásobníku, který má každé vlákno vlastní, existuje ještě oblast paměti, ve které jsou data programu sdílená mezi vlákny, tato oblast se jmenuje halda (heap).

U jazyka Java jsou na haldě uloženy všechny alokované objekty, tj. instance tříd, a dokonce třídy samotné, tedy i statické proměnné tříd.

Při přístupu k datům na haldě je třeba si uvědomit, že data zpracovávaná procesorem mohou být kromě hlavní paměti též v paměti cache procesoru, a nebo v univerzálních registrech. Je tedy třeba ošetřit problém současného přístupu více vláken k jednomu místu na haldě.

Na tomto místě je dobré zdůraznit, že v Javě se argumenty metod předávají vždy hodnotou, u primitivních typů se tedy na zásobník uloží přímo hodnota, u objektových typů se na zásobník uloží hodnota ukazatele na objekt umístěný na haldě. Uvnitř volané metody tedy nemůžeme změnit hodnotu lokálních proměnných z volající metody, ale můžeme měnit obsah objektů na haldě.

Tedy volání metody a() v následujícím kódu

 
    void a() {
        int number = 1;
        StringBuffer text = new StringBuffer("ABCD");
        b(number,text);
        System.out.println("number = " + number);
        System.out.println("text = " + text);
    }
 
    void b(int i, StringBuffer sb) { //argumenty jsou de facto lokální proměnné
        i = 22;
        sb.reverse();
        sb = null;
    }

vypíše

number = 1
text = DCBA

Jak vlákna fungují

Na jednoprocesorových systémech může ve skutečnosti v jednom okamžiku běžet pouze jedno vlákno, iluze souběžného běhu více vláken je dosahována tzv. přepínáním kontextu. Procesor je postupně přidělován jednotlivým vláknům, která provádějí svoji posloupnost operací. Po uplynutí určité doby nebo na základě jiných okolností je vláknu procesor zase odebrán a přidělen jinému vláknu. K přepínání kontextu dochází mnohokrát za sekundu, takže se zdá, že vlákna běží zároveň. K tomuto dojmu přispívá i to, že se jedná o preemptivní multitasking (k přidělování a odebírání procesoru dochází bez kooperace s vláknem a vlákno o tom nemá ponětí).

Threadswitching.png

Na víceprocesorových strojích nebo na moderních procesorech s více jádry může skutečně běžet více vláken zároveň (např. procesor UltraSPARC T2, známý také pod svým kódovým názvem Niagara, má 8 jader a každé jádro podporuje 8 vláken, takže procesor podporuje až 64 skutečně souběžných vláken). Přesto i zde dochází k přepínání kontextu, protože počet procesorů nebo jejich jader bývá obvykle nižší, než celkový počet vláken v systému. Fakt, že vlákna mohou běžet opravdu souběžně, způsobuje některé rozdíly v chování vícevláknových aplikací oproti jednoprocesorovým strojům (zejména pokud nejsou naprogramovány správně).

Přidělování procesoru (nebo procesorů) jednotlivým vláknům řídí plánovač vláken. Ten je v případě platformy Java implementován především na úrovni JVM, ale na různých platformách více či méně spolupracuje s plánovačem procesů operačního systému. Proces plánování je ovlivňován tolika faktory, že je chování plánovače vláken prakticky nedeterministické.

Vytváření vláken v Javě

Vlákna jsou v Javě reprezentována třídou Thread. Pokud chceme vytvořit nové vlákno, musíme nejdříve definovat kód, který bude v tomto vlákně prováděn. Toho můžeme docílit dvěma způsoby:

  • Vytvořením třídy, která bude implementovat rozhraní Runnable.
  • Vytvořením třídy, která bude potomkem třídy Thread.

V obou případech je nutné v dané třídě překrýt metodu run() a do této metody umístit kód, který má být v novém vlákně prováděn. Pak můžeme začít vytvářet jednotlivá vlákna. V prvním případě vytvoříme instanci třídy Thread a jako parametr předáme instanci naší třídy implementující rozhraní Runnable. Ve druhém případě vytváříme přímo instance naší třídy, která je potomkem třídy Thread. Vlastní spuštění vlákna provedeme zavoláním metody Thread.start().

class Counter implements Runnable {
    // Tato metoda obsahuje kód, 
    // který bude vykonáván v našem vlákně 
    public void run() {
        for(int i = 0; i < 10; i++) {
            System.out.println(i);
        }
    }
}
 
Runnable counter = new Counter();
 
// Vytvoříme nové vlákno, jako parametr
// konstruktoru předáme referenci na 
// naši implementaci rozhraní Runnable
Thread counterThread = new Thread(counter);
 
// spustíme vlákno, kód metody Counter.run()
// se od této chvíle začne vykonávat v novém
// vlákně
counterThread.start();
class CounterThread extends Thread {
    // Tato metoda obsahuje kód, 
    // který bude vykonáván v našem vlákně 
    public void run() {
        for(int i = 0; i < 10; i++) {
            System.out.println(i);
        }
    }
}
 
// Vytvoříme nové vlákno
Thread counterThread = new CounterThread();
 
// spustíme vlákno, kód metody CounterThread.run()
// se od této chvíle začne vykonávat v novém
// vlákně
counterThread.start();

Pozor! Častou začátečnickou chybou je, že se místo metody Thread.start() volá metoda Thread.run(). (Kontrolní otázka: co se v takovém případě stane?)

Executors

Od Javy verze 5 je ke spouštění vláken k dispozici rozhraní ExecutorService a jeho implementace, které lze získat pomocí statických metod ve třídě Executors.

 
        int threadNum = 3;
        ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
        for(int i=1;i<=threadNum;i++)  {
            executorService.execute(new MyRunnable(i));
        }
        executorService.shutdown();

Navíc kromě možnosti spouštět třídy implementující Runnable podporuje nově i rozhraní Callable, které umožňuje navíc vracet návratovou hodnotu:

 
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.*;
 
public class Exekuce {
 
    static public class Producer implements Callable<String> {
 
        private final int n;
 
        public Producer(int n) {
            this.n = n;
        }
 
        public String call() throws Exception {
            return " Product("+n+")";
        }
    }
 
    public static void main(String[] args) throws ExecutionException, InterruptedException {
 
        //pool vláken odpovídající počtu CPU v počítači
        ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
 
        //zpracovatelé
        List<Producer> producers = Arrays.asList(new Producer(1), new Producer(2), new Producer(3));
 
        //paralelní  zpracování
        List<Future<String>> futures = executorService.invokeAll(producers);
        executorService.shutdown();
 
        //získání výsledků
        for (Future<String> fs : futures) {
            System.out.println("výsledek " + fs.get());
        }
 
    }
}

Synchronizace vláken

Jednotlivá vlákna spolu obvykle potřebují nějakým způsobem komunikovat nebo sdílet některé zdroje. Nejčastěji sdíleným zdrojem je paměť. Každé vlákno má svůj vlastní zásobník, ale všechna vlákna sdílí paměťový prostor procesu, v němž se alokují objekty (heap).

Každý přístup ke sdílenému prostředku je třeba ošetřit tak, aby nedocházelo k vzájemnému negativnímu ovlivňování vláken (nazývané Race Condition). K tomu slouží synchronizace.

Pozor! Chyby v synchronizace jsou velice zákeřné a obtížně odhalitelné. Obvykle se nedají reprodukovat ani odhalit testováním. Jejich důsledky však mohou být fatální. Proto je nutné správné synchronizace věnovat zvýšenou pozornost a je potřeba znát všechny související aspekty. Některé věci v této oblasti jsou velmi neintuitivní (jsou ve skutečnosti jinak, než by se mohlo na první pohled zdát).

Pokud hovoříme o synchronizaci přístupu k paměti, pak synchronizace plní dva důležité úkoly:

  1. Brání vláknu v tom, aby pozorovalo nějaký objekt v nekonzistentním stavu. Pokud nějaké vlákno zrovna mění stav objektu, ostatní vlákna nemohou jeho stav číst nebo měnit, dokud není změna dokončena. Každá změna stavu objektu se tak ostatním vláknům jeví jako atomická.
  2. Pokud jedno vlákno dokončí změnu stavu objektu, ostatní vlákna okamžitě vidí nový stav. Jednotlivé změny stavu objektu tak na sebe navazují a tvoří posloupnost s určitým pořadím. Nemůže dojít ke dvěma souběžným změnám stavu objektu, vždy nejdříve proběhne jedna a teprve po ní další.

Druhý úkol synchronizace bývá často opomíjen, což může vést k vážným chybám. Někteří vývojáři se například domnívají, že pokud zabrání souběžnému přístupu ke sdílenému objektu jiným způsobem (např. nejdříve hodnotu zapíší a později ji už pouze čtou), nemusí už synchronizaci provádět. To je omyl!

Paměťový model javy (Java Memory Model, pro Javu verze 5 a novější definován prostřednictvím JSR 133) je poměrně komplikovaný a může se stát, že každé vlákno drží svoji kopii sdílené proměnné. K tomu dochází např. pokud ji optimalizátor umístil do registru, nebo pokud každé vlákno běží na jiném procesoru a JVM z důvodů efektivity každému vláknu přiřadila jeho vlastní kopii sdílené proměnné, umístěnou v paměti RAM nebo cache daného procesoru (viz architektura SMP systémů). Další optimalizací může být změna pořadí operací. Proto je nutné použít synchronizaci, neboť ta zajistí nejen vyloučení souběžného přístupu, ale také zabrání čtení neaktuálního stavu jiným vláknem (tím že zajistí přečtení aktuálních dat z hlavní paměti při získání zámku a jejich zápis do hlavní paměti při uvolnění zámku a zabrání optimalizacím, které by mohly způsobit nekonzistenci).

Nezapomínejme, že musíme synchronizovat nejen zápis, ale i čtení sdílených dat. Jinak riskujeme, že přečteme nekonzistentní nebo neaktuální stav objektu.

Poznámka: Rada 48 v knize Java Efektivně nepokrývá některé změny v paměťovém modelu Javy od verze 5 (kniha vyšla dříve), takže ne všechny informace jsou zcela aktuální. Pokud však všechna doporučení této rady dodržíme, nic tím nezkazíme.

Jak synchronizovat

Při synchronizaci musíme nejdříve identifikovat tzv. kritické sekce. To jsou části kódu, které přistupují ke sdíleným datům a které musí proběhnout atomicky.

Základním synchronizačním prostředkem v Javě je monitor. Každý objekt v paměti má svůj vlastní monitor. Synchronizace pomocí monitoru je řízena klíčovým slovem synchronized, které označuje kritickou sekci. Za kritickou sekci může být označena buď celá metoda, nebo pouze její část. Pokud je klíčovým slovem synchronized označena celá metoda, kritická sekce je řízena monitorem dané instance, pro níž je metoda zavolána (čili monitorem objektu this).

class Counter {
    // sdílená proměnná reprezentující stav objektu
    private int currentValue = 0;
    public synchronized int next() {
        // toto je kritická sekce, která musí proběhnout atomicky
        return ++currentValue;
    }
}

Pokud je označena pouze část kódu, kritická sekce je řízena monitorem objektu, který je explicitně uveden v závorce za klíčovým slovem synchronized.

class Counter {
    // sdílená proměnná reprezentující stav objektu
    private int currentValue = 0;
    public int next() {
        synchronized(this) {
            // toto je kritická sekce, která musí proběhnout atomicky
            return ++currentValue;
        }
    }
}

Při synchronizaci si musíme dát pozor, aby všechna vlákna pracující s nějakými sdílenými daty používala pro synchronizaci přístupu k nim stejný monitor!

Kdy synchronizovat

Nějaká forma synchronizace je potřeba při jakémkoliv přístupu ke sdíleným zdrojům. Je ovšem otázka kdo a kde ji bude implementovat. Některé třídy v Java Core API jsou již synchronizované a můžeme je bezpečně používat a sdílet mezi více vlákny, aniž bychom byli nuceni synchronizaci provádět ve vlastní režii. Stejně tak mohou být pro provoz ve vícevláknovém prostředí připraveny i třídy z různých dalších knihoven. Jiné třídy však pro vícevláknové použití připraveny být nemusí a případnou synchronizaci musíme implementovat sami.

Poznámka: Původně byla v Javě snaha synchronizovat všechny třídy, ale později se zjistilo, že synchronizace má velkou režii a z důvodů efektivity se od tohoto přístupu ustoupilo. Proto jsou synchronizovány zejména třídy, které jsou v Java Core API od prvních verzí (např. Vector), zatímco novější třídy už často synchronizaci postrádají (např. ArrayList) U některých synchronizovaných tříd (např. StringBuffer) byla dokonce zavedena varianta bez synchronizace (např. StringBuilder). Na druhou stranu přibyla v Javě verze 6 nová optimalizace, která se za určitých okolností snaží detekovat nepotřebnou synchronizaci a při kompilaci bytecode do nativního kódu ji vynechat [1]). Ale pozor, ne vždy je to možné, proto se i nadále doporučuje se synchronizací vyhnout, pokud není třeba.

To, zda je či není třída vláknově bezpečná (thread safe), by mělo být uvedeno v její dokumentaci. V Java Core API bývá obvykle explicitně uváděno, když třída synchronizována není. Pokud tam žádná informace není, doporučoval bych obezřetnost a raději nahlédnout do zdrojových kódů, než spoléhat na to, že synchronizovaná je. Pokud vytváříme vlastní třídu, měli bychom do její documentace JavaDoc explicitně uvést, zda je či není vláknově bezpečná (viz Java efektivně, rada 52).

public class SomeThreadSafeClass {
 
    // kolekce je vláknově bezpečná
    private final List<String> data = Collections.synchronizedList(new ArrayList<String>());
 
    public void addData(String newObject) {
        data.add(newObject);
    }
 
    public String removeData(int index) {
        return data.remove(index);
    }
 
}

Pozor! Pokud bychom ve výše uvedeném příkladě chtěli navíc iterovat přes použitou kolekci, explicitní synchronizace by již byla třeba, protože jak uvádí kontrakt metody Collections.synchronizedList(java.util.List), iterátor vrácené kolekce synchronizován není.

public class SomeThreadSafeClass {
 
    // kolekce není vláknově bezpečná, je nutná explicitní synchronizace
    private List<String> data = new ArrayList<String>();
 
    public synchronized void addData(String newObject) {
        data.add(newObject);
    }
 
    public synchronized String removeData(int index) {
        return data.remove(index);
    }
 
}

Kdy není potřeba synchronizovat

  • Při přístupu k proměnným a objektům, k nimž se vůbec nepřistupuje z jiných vláken (nejsou tudíž sdílené).
  • Při přístupu k instanci třídy, která je vláknově bezpečná (tj. je sama synchronizovaná).
  • Při přístupu ke statickým finálním konstantám (pokud používáme javu verze 5 nebo novější). Ty jsou inicializované zároveň s třídou a specifikace platformy Java garantuje, že inicializace třídy včetně zapsání dat do hlavní paměti je dokončena dříve, než je možné se třídou jakýmkoliv způsobem pracovat.
  • Při přístupu k lokálním proměnným. Ty jsou uloženy na zásobníku vláka a nejsou tedy sdíleny mezi více vlákny. Ale pozor, lokální proměnná může obsahovat referenci na objekt na heapu, který už může být sdílený. Nemusíme tedy synchronizovat manipulaci s hodnotou lokální proměnné, ale pro manipulaci s odkazovaným objektem už to platit nemusí.
  • Při atomických operacích nad proměnnými označenými klíčovým slovem volatile (pokud používáme javu verze 5 nebo novější, podrobněji viz dále).

Atomické operace

Java garantuje atomičnost operací čtení a zápisu hodnot typu byte, short, int, char, float, boolean a reference na objektový typ. Atomickou operací není čtení nebo zápis hodnoty typu long a double. Atomickou operací není ani posloupnost více atomických operací (např. i++).

Atomická operace jenom a pouze zaručí vláknu, že v dané proměnné neuvidí při čtení nesmyslnou hodnotu. Aby byl zajištěn správný přenos aktuální hodnoty mezi vlákny, musí být použita synchronizace, nebo musí být daná proměnná označena klíčovým slovem volatile (viz Java Efektivně, rada č. 48). Pozor! Klíčové slovo volatile nefunguje dobře na starších verzích Javy (1.4 a starší).

Pokud potřebujeme pracovat se sdílenou hodnotou některého z výše uvedených typů (včetně long a double), je v drtivé většině případů nejlepším řešení použít některou ze tříd v balíku java.util.concurrent.atomic. Tyto třídy jsou implementovány nejefektivnějším možným způsobem a garantují atomičnost všech svých operací.

Balík java.util.concurrent

Až do verze Javy 1.4 byla synchronizace vláken možná pouze pomocí dvou základních primitiv:

Tato primitiva sice zajišují správnou funkci, ale způsobují přepnutí kontextu procesoru, což je poměrně drahá operace, pokud chceme například jenom inkrementovat sdílenou proměnnou.

Proto ve verzi Java 5.0 přibyl balík java.util.concurrent, ve kterém jsou třídy využívající hardwarovou podporu atomičnosti operací. Tyto třídy tedy nevyužívají ve své implementaci klíčové slovo synchronized, ale nativní kód (sdružený ve třídě sun.misc.Unsafe) využívající speciální instrukce procesoru. Jsou to

  • instrukce CAS [2] (Compare-and-swap) na procesorech x86 a SPARC
  • instrukce LL/SC [3] (Load-Link/Store-Conditional) na procesorech ARM, Alpha, PowerPC, MIPS

Tyto instrukce umožňují atomicky detekovat zda má určitá proměnná určitou hodnotu (nebo zda se změnila) a pokud ano, nastavit jí hodnotu jinou. Nezpůsobují tedy přepínání kontextu a jsou tak mnohem rychlejší než by byla implementace využívající synchronized bloky.

Například metoda AtomicInteger.incrementAndGet() je implementována takto:

 
public final int incrementAndGet() {
        for (;;) {
            int current = get();
            int next = current + 1;
            //volání nativního kódu
            if (sun.misc.Unsafe.compareAndSwapInt(current, next))
                return next;
        }
    }

což je při běhu efektivnější než by byla implementace pomocí synchronized:

 
class MyAtomicInteger {
    private volatile int value;
 
    public synchronized int incrementAndGet() {
        return ++value;
    }
}

Příklady chyb v synchronizaci

Zkuste se zamyslet, jaké chyby v synchronizaci obsahují následující ukázky kódu:

class Counter {
    private static int currentValue = 0;
    public synchronized int next() {
        return ++currentValue;
    }
}
class Counter {
    private int currentValue = 0;
    private static Object LOCK = new Object();
    public int next() {
        synchronized(LOCK) {
            return ++currentValue;
        }
    }
}
public class SomeThreadSafeClass {
 
    private List<String> data = Collections.synchronizedList(new ArrayList<String>());
 
    public void addData(String newObject) {
        data.add(newObject);
    }
 
    public synchronized void dumpData() {
	for(String s : data) {
            System.out.println(s);
	}
    }
 
}

Časová synchronizace vláken

Monitor v javě umožňuje také časovou synchronizaci vláken, kdy jedno vlákno může čeka na událost způsobenou jiným vláknem. Představme si např. vlákno, které generuje nějaká data a jiné vlákno, které je potom zpracovává (klasická synchronizační úloha typu producer-consumer). V tomto případě potřebujeme, aby vlákno zpracovávající data počkalo, než požadovaná data budou k dispozici, a aby vlákno produkující data v případě potřeby počkalo, až bude místo v bufferu pro předávání dat.

K řešení podobných problémů slouží metody Object.wait() a Object.notify(). Pozor! Jejich používání není triviální a je potřeba jejich kontraktům dobře rozumět. Nezkoušejte je používat, dokud si nepřečtete radu č. 50 v knize Java efektivně. I když si tuto radu přečtete, stejně se jim snažte vyhnout. Pokud byste potřebovali jejich funkcionalitu, raději se podívejte do balíku java.util.concurrent, kde najdete celou řadu užitečných tříd. Např. problém producer-consumer je možné elegantně řešit pomocí některé ze tříd implementujících rozhraní BlockingQueue.

public class ProducerConsumer {
 
    private static final int PRODUCERS_COUNT = 5;
    private static final int CONSUMERS_COUNT = 2;
    private static final int BUFFER_CAPACITY = 10;
 
    private static AtomicBoolean stop = new AtomicBoolean();
    private static BlockingQueue<String> queue = 
            new LinkedBlockingQueue<String>(BUFFER_CAPACITY);
 
    private static Logger logger = Logger.getLogger(ProducerConsumer.class.getName());
    
    private static class Producer implements Runnable {
 
        public void run() {
            int counter = 0;
            while (!stop.get()) {
                try {
                    waitForSimulatingActivity();
                    String data = "Data no " + counter++ + " from " + 
                            Thread.currentThread().getName();
                    queue.put(data);
                } catch (InterruptedException ex) {
                    logger.log(Level.SEVERE, "Exception thrown when " +
                            "putting data into queue", ex);
                }
            }
        }
    }
 
    private static class Consumer implements Runnable {
 
        public void run() {
            while (!stop.get()) {
                try {
                    String data = queue.take();
                    System.out.println(Thread.currentThread().getName() + 
                            " is processing " + data);
                } catch (InterruptedException ex) {
                    logger.log(Level.SEVERE, "Exception thrown when " +
                            "taking data from queue", ex);
                }
            }
        }
    }
    
    public static void main(String... args) throws IOException {
        for (int i = 0; i < PRODUCERS_COUNT; i++) {
            Thread thread = new Thread(new Producer(), "Producer " + i);
            thread.start();
        }
        for (int i = 0; i < CONSUMERS_COUNT; i++) {
            Thread thread = new Thread(new Consumer(), "Consumer " + i);
            thread.start();
        }
        System.err.println("Press Enter to stop the program");
        System.in.read();
        stop.set(true);
    }
    
    private static Random pauseLengthGenerator = new Random();
    
    @SuppressWarnings("empty-statement")
    private static void waitForSimulatingActivity() {
        // Wait some time without switching context
        // This is only for example purposes, active waiting loop
        // should never be used in production code
        long pauseLength = pauseLengthGenerator.nextInt(1000);
        long endTime = System.currentTimeMillis() + pauseLength;
        while (System.currentTimeMillis() < endTime) {};
    }
    
}

Fork/join framework (JSR166y) v JDK 7

V JDK 7 má být nový framework pro masivní paralelizaci, viz Parallelize your arrays with JSR 166y a Concurrency JSR-166 Interest Site

Další důležité informace

Jak ukončit vlákno nebo celou aplikaci

Aplikace v Javě je ukončena, když je zavolána metoda System.exit(), nebo jakmile skončí poslední z vláken, které nemají nastavený příznak daemon (ten je možné nastavit před spuštěním vlákna a mají jej nastaveny např. pomocná vlákna, která vytváří automaticky JVM a která zajišťují věcí jako je spouštění Garbage Collectoru nebo metod Object.finalize()).

Pokud chceme ukončit nějaké vlákno z jiného vlákna, nelze to učinit bezpečně bez spolupráce ukončovaného vlákna. Vlákno je sice možné násilně ukončit metodou Thread.stop(), ale ta je označena jako zastaralá a nikdy by se neměla používat, protože to není bezpečné. Správným řešením je použít sdílený příznak typu boolean a jeho hodnotu ve vlákně pravidelně testovat (viz příklad producer-consumer).

Podobným způsobem je možné realizovat také pozastavení vlákna.

Počet vláken v aplikaci

Plánovač vláken se na každé platformě může chovat jinak a je navíc ovlivněn tolika faktory, že je pro nás jeho chování prakticky nedeterministické (viz Java Efektivně, rada 51). Čím více je v aplikaci vláken, tím složitější je rozhodování plánovače, které vlákno má jak naplánovat, a tím také roste riziko, že je bude plánovat jinak, než bychom potřebovali. Proto je vhodné počet vláken udržovat na rozumně nízkém počtu.

Pokud potřebujeme často provádět operace v různých vláknech, můžeme použít techniku nazývanou pooling. Ta funguje podobně jako v JDBC. Udržujeme několik nachystaných vláken a požadované operace zařazujeme do fronty. Nachystaná vlákna postupně operace z fronty vybírají a provádí je. Pro realizaci tohoto mechanismu můžeme použít např. třídu java.util.concurrent.ThreadPoolExecutor. Kromě toho existují specializované třídy pro konkrétní účely, které tento princip také používají (např. javax.swing.SwingWorker).

Priorita vláken

Různá vlákna mohou mít různou prioritu, která se nastavuje metodou Thread.setPriority(int). Chování plánovače vláken a jeho interpretace priorit je silně závislé na konkrétní platformě a proto je nutné postupovat při práci s prioritami vláken velmi obezřetně. Nelze se na jejich vliv příliš spoléhat a je lepší se jim úplně vyhnout.

Pokud máte problém s nevhodným plánováním vláken a některá vlákna dostávají přiděleno málo strojového času, obvykle je mnohem účinnější počet vláken prostě omezit (viz výše).

Kdy dochází k přepnutí kontextu

Jak již bylo zmíněno výše, k přepnutí kontextu dochází po uplynutí určité doby, nebo pokud nastanou jiné okolnosti, kvůli nimž vlákno přeruší svoji činnost. Touto okolností může být např.:

  • pokud vlákno samo požádá o uspání na určitý počet milisekund (v javě jde o metodu Thread.sleep(long));
  • pokud vlákno čeká na dokončení blokující I/O operace (např. InputStream.read() či reader.read() pokud nejsou data připravena a je nutné na ně čekat);
  • pokud chce vlákno vstoupit do kritické sekce hlídané monitorem v níž se již nachází jiné vlákno (čili pokud chce vykonat kód, který nesmí být vykonáván více vlákny souběžně a je momentálně zamčený, protože jej vykonává jiné vlákno);
  • pokud vlákno využívá funkci monitoru, která mu umožňuje čekat na jiné vlákno (tj. zavolá metodu Object.wait()).

Rekapitulace důležitých informací a zásad

  • Chyby v synchronizaci se obtížně hledají a mohou být velice zákeřné a nebezpečné. Některé se navíc projevují pouze na víceprocesorových strojích nebo pouze za určitých okolností. Proto je potřeba při tvorbě vícevláknových aplikací postupovat obezřetně a dávat pozor na to, abychom v synchronizaci neudělali chybu.
  • Nejspolehlivější a nejbezpečnější je, pokud se můžeme synchronizaci vyhnout tím, že používámě vláknově bezpečné třídy. Užitečným zdrojem takových tříd jsou např. balíky java.util.concurrent, java.util.concurrent.atomic a java.util.concurrent.locks.
  • Při synchronizaci si musíme dát pozor, aby všechna vlákna pracující s nějakými sdílenými daty používala pro synchronizaci přístupu k nim stejný monitor.
  • Se synchronizací se to nesmí přehánět. Jinak hrozí deadlocky nebo dramatické snížení výkonu. (Viz Java Efektivně, rada 49).
  • Plánovač vláken se na každé platformě může chovat jinak a je navíc ovlivněn tolika faktory, že je pro nás jeho chování prakticky nedeterministické. Proto se na jeho chování nemůžeme nijak spoléhat.
  • Lokální proměnné nejsou sdílené mezi vlákny, ale atributy objektů ano. Pokud tam, kde stačí lokální proměnná, používáte atribut objektu, riskujete problémy.
  • Nepoužívejte metody třídy Thread, které jsou označeny jako deprecated. K pozastavení nebo zastavení vlákna použijte příznak, který budete ve vlákně pravidelně kontrolovat.

Problémy při paralelizaci

Při používání vláken narazíme na všechny klasické problémy paralelního programování

Vzájemné vyloučení

Vzájemné vyloučení je požadavek, aby v určité citlivé části kódu označované jako kritická sekce bylo nanejvýš jedno vlákno.

Vzájemné vyloučení se obecně řeší pomocí synchronizačních primitiv jako jsou zámky, semafory a monitory. V Javě na to existují

Synchronizace vláken

Slavné problémy synchronizace jsou např. Dining philosophers problem nebo Producer-consumer problem.

Synchronizace vláken je možné provádět

Uváznutí

Uváznutí typicky nastává když jedno vlákno čeká na zámek zamčený druhým vláknem, a druhé vlákno čeká na zámek zamčený prvním vláknem.

Typicky se to stává při používání synchronizovaných metod:

 
public class Deadlock {
 
    static class A {
 
        B b;
 
        synchronized void a()  {
            try { Thread.sleep(500); } catch (InterruptedException e) {}
            System.out.println(Thread.currentThread().getName()+": mám a, čekám na b");
            b.print();
        }
 
        synchronized void print() {
            System.out.println("a");
        }
    }
 
    static class B {
 
        A a;
 
        synchronized void b() {
            try { Thread.sleep(500); } catch (InterruptedException e) {}
            System.out.println(Thread.currentThread().getName()+": mám b, čekám na a");
            a.print();
        }
 
        synchronized void print() {
            System.out.println("b");
        }
    }
 
    public static void main(String[] args) {
        final A a = new A();
        final B b = new B();
        a.b=b;
        b.a=a;
 
        new Thread("v1") { public void run() { a.a(); }}.start();
        new Thread("v2") { public void run() { b.b(); }}.start();
 
    }
}

Kód výše způsobí uváznutí:

v1: mám a, čekám na b
v2: mám b, čekám na a

Pro pozorování činnosti vláken a detekci deadlocků je dobrá utilita jvisualvm, která je součástí JDK. V ní můžeme pozorovat vlákna v grafu:

Deadlock.png

a vypsat si v jakém stavu jednotlivá vlákna jsou:

Jvisualvm threads.png

Další zdroje informací