Článek přečtěte do 11 min.

Podívejte se, jak I/O řídí velkou část efektivity závitové revoluce JEP 444.

Virtuální vlákna, jak jsou implementována v JEP 444, jsou pravděpodobně nejočekávanější funkcí Java 21.

  • Java I/O
  • Operační systém a úzké místo vlákna Java
  • Virtuální vlákna

Tato cesta vysvětlí nejen to, jak virtuální vlákna fungují, ale také proč fungují.

Počínaje Java I/O

Možná jste se již setkali s tradiční formou Java I/O v „Moderní souborový vstup/výstup s Java Path API a pomocnými metodami souborů“ a moderní formou (Path API) v „Moderní souborový vstup/výstup s Java: Pojďme praktické“, stejně jako nativní I/O v „Moderní souborový vstup/výstup s Javou: Rychlá cesta s NIO a NIO.2„. Pokud jste tyto články ještě nečetli, možná se na ně budete chtít podívat; ale pokud ne, je to také v pořádku.

Přesto se na chvíli zamyslete nad rozdílem mezi formami I/O. Začněte s blokováním I/O, které má tu výhodu, že je koncepčně jednoduché. Například třída Socket zpřístupňuje metody, jako je getInputStream(), které vám umožňují psát kód, jako je následující jednovláknový uživatelský kód, který čte (jeden řádek po druhém) ze soketu a tiskne data blokujícím způsobem:

try (var sock = new Socket(hostname, port);
  var from = new BufferedReader(
      new InputStreamReader(sock.getInputStream()))) {

  for (String l = null; (l = from.readLine()) != null; ) {
      System.out.println(l);
  }
}

Všimněte si, že pokud InputStream vrácený z getInputStream()byl podporován kanálem v neblokujícím režimu, operace čtení vstupního toku by vyvolaly java.nio.channels.IllegalBlockingModeException– jinými slovy, tento tok musí být v režimu blokování.

Blokovací povaha volání čtení probublává prostřednictvím dekoračních tříd InputStreamReader a BufferedReader, které poskytují mnohem užitečnější rozhraní pro lidi než nízkoúrovňová čtení soketu. Tato jednoduchost však přináší potenciální nevýhody.

  • Musíte načíst data ze souboru a přenést je do haldy Java jako objekty (jeden objekt typu String na řádek).
  • Vlákno nebude moci dělat nic jiného, ​​dokud operace se souborem neskončí.

První z těchto problémů můžete vyřešit pomocí rozhraní API kanálů (součást knihovny Java NIO) ke čtení obsahu souboru do vyrovnávací paměti, která není součástí haldy Java, následovně:

var file = Path.of("/Users/bje/reasonable.txt");

try (var channel = FileChannel.open(file)) {
    var buffer = ByteBuffer.allocateDirect(1_000_000);
    var bytesRead = channel.read(buffer);

    System.out.println("Bytes read [" + bytesRead + "]");
    BusinessProcess.doSomethingWithFile(buffer);
} catch (IOException e) {
    e.printStackTrace();
}

Tento kód však stále blokuje a pro vyřešení tohoto problému musíte udělat něco propracovanějšího. Jednou z možností je použít neblokující asynchronní I/O třídy z NIO.2; s těmi se I/O volání vrátí okamžitě bez ohledu na to, zda I/O operace skončila či nikoli.

Volajícímu je vrácen objekt a tento objekt představuje výsledek asynchronní operace. Obvykle se na výsledek stále čeká a plně se projeví až po dokončení operace.

Jednoduchý příklad, který přečte až 100 megabajtů ze souboru a poté získá výsledek (což bude počet skutečně přečtených bajtů), může vypadat takto:

var file = Path.of("/Users/bje/enormous.txt");

try (var channel = AsynchronousFileChannel.open(file)) {
    var buffer = ByteBuffer.allocateDirect(100_000_000);
    Future<Integer> result = channel.read(buffer, 0);

    while (!result.isDone()) {
        BusinessProcess.doSomethingElse();
    }

    var bytesRead = result.get();
    System.out.println("Bytes read [" + bytesRead + "]");
    BusinessProcess.doSomethingWithFile(buffer);
} catch (IOException | ExecutionException | InterruptedException e) {
    e.printStackTrace();
}

Všimněte si, že návratový typ from read()je Future<Integer>, což je typ kontejneru, který může obsahovat neúplný nebo dokončený výsledek. (Objekty budoucnosti Java jsou velmi podobné slibům v JavaScriptu).

Skutečnost, že read()se okamžitě vrátí, zatímco skutečné zkopírování souboru do paměti proběhne v jiném vláknu (a je zpracováno automaticky běhovým prostředím), umožňuje tomuto jednovláknovému uživatelskému kódu dělat něco jiného, ​​zatímco vy čekáte na dlouhotrvající kopii. být dokončena.

Můžete použít periodické kontroly (pomocí isDone()metody) a poté zavolat get()metodu k načtení výsledků po dokončení asynchronní I/O aktivity.

To je samozřejmě složitější než případ blokování, protože vyžaduje, abyste přemýšleli o asynchronních aspektech. Zejména musí být program strukturován takovým způsobem, aby úkoly nesouvisející s daty – jako doSomethingElse()ve výše uvedeném příkladu – mohly být prováděny v průběhu I/O operace.

Dalším, jemnějším aspektem je, že navzdory podobnosti jejich jmen jsou AsynchronousFileChannel a FileChannel téměř zcela nesouvisející třídy. Ve skutečnosti mají jedinou společnou dědičnost, že obě třídy implementují java.nio.channels.Channel, což má pouze dvě metody.

public interface Channel extends Closeable {
    public boolean isOpen();
    public void close() throws IOException;
}

V této definici výrazně chybí metody čtení, které mají v těchto dvou třídách různé signatury.

// FileChannel
public abstract int read(ByteBuffer dst) throws IOException;

// AsynchronousFileChannel
public abstract Future<Integer> read(ByteBuffer dst, long position);

Tento rozdíl mezi těmito dvěma třídami je navržen tak, aby zdůrazňoval, spíše než skryl, odlišnou povahu volání. Jednalo se o vědomou a záměrnou volbu ze strany návrhářů jazyka Java.

Jiné přístupy

V případě, že jste zvědaví, některé další programovací jazyky, jako je C#, přijaly přístup asynchronního čekání na neblokující I/O, který se pokouší poskytnout flexibilní a obecný způsob, jak přeměnit normální jednovláknový blokovací kód na asynchronní, neblokující verze.

Všimněte si následujícího o verzi C# tohoto přístupu:

  • Klíčové asyncslovo změní metodu na asynchronní metodu, označovanou jako asynchronní metoda.
  • Když provádění dosáhne volání metody zdobené klíčovým awaitslovem, volání metody se pozastaví a vrátí vlákno zpět své metodě volajícího.
  • Klíčové awaitslovo lze použít pouze v rámci metody označené async; pokus o to jinak způsobí chybu kompilace.

Přístupu async-await je dosaženo tím, že kompilátor automaticky převede metodu async na stavový stroj.

Podobně jsou Kotlinovy ​​korutiny založeny v podstatě na stejné myšlence, která spočívá v použití speciálních klíčových slov k označení, že by kompilátor měl sestavit stavový stroj, jak je vysvětleno v této knize.

Neúplné výsledky musí být také reprezentovány v případě asynchronní úlohy, která vrací hodnotu. V Kotlinu tuto roli plní instance typu Deferred<T>, zatímco v C# jsou typu Task<TResult>.

Tento vzor, ​​někdy označovaný jako asynchronní vzor založený na úlohách (TAP), nabízí flexibilitu a obecnost. Má však určité nevýhody – hlavní je mimořádná složitost – které vedly návrháře jazyka Java k tomu, aby prozkoumali jinou cestu, než je pouhé přidání přístupu async-await založeného na stavovém stroji do jazyka.

Celým smyslem vzoru je pokusit se skrýt rozdíl mezi synchronními a asynchronními voláními, ale v praxi je to obtížné dosáhnout. Pro začátek musí být návratový typ synchronní metody zabalen do typu kontejneru podobného typu Future, aby bylo možné zobrazit neúplný stav.

V jazycích, které podporují přístup async-await, musí proto kompilátor zdrojového kódu přidat kód, aby se výsledky automaticky boxovaly a rozbalovaly. To se může špatně kombinovat s odvozením typu, kde se skutečný návratový typ může lišit od toho, co se očekává.

Závažnějším problémem je, že když je synchronní kód převeden na asynchronní formu, často zjistíte, že kód funguje lépe, pokud asynchronní kód současně volá a je volán jiným asynchronním kódem. To může vést k tomu, že se stále větší části kódové základny stávají asynchronními, což zase ztěžuje uvažování o kódu.

To je někdy známé jako asynchronní nákaza nebo barevné funkce, které jsou takto pojmenovány v blogovém příspěvku z roku 2015 „Jaká barva je vaše funkce“. Mimochodem, autor tohoto příspěvku, Bob Nystrom, nesprávně předpověděl, že Java plánovala podporovat přístup async-await.

Zpět k Javě

Pozorování zkušeností programátorů používajících přístup async-await v jiných jazycích vedlo tým pro jazyk Java k závěru, že to pro Javu není správný směr, a tak prozkoumali další možnosti. Jak asi tušíte, konečným řešením problému Java jsou virtuální vlákna (upozornění na spoiler).

Než se však pustíme do tohoto tématu, je třeba zvážit ještě jeden aspekt blokování versus neblokování I/O.

Tradiční I/O API Java původně blokovalo, ale Java 13 JEP 353, která reimplementovala starší Socket API a Java 15 JEP 373, která reimplementovala starší DatagramSocket API, to vše změnily.

Všimněte si, že pokud vaše aplikace běží na Javě 17 (nebo nejnovější verzi funkcí), v době dodání těchto dvou JEP již tato vylepšení používáte. Na druhou stranu, pokud jsou vaše aplikace stále založeny na Javě 8 nebo Javě 11, tato vylepšení nepoužíváte.

Původní implementace soketu byla založena na neveřejné třídě PlainSocketImpl s podporou SocketInputStream a SocketOutputStream. Nová implementace spoléhá na novou třídu NioSocketImpl, která spojuje implementaci kódu soketu s nativním I/O.

To znamená, že i když je pro programátora zachováno blokovací API, pod krytem se používá neblokující I/O. Ale proč na tom záleží a jak to souvisí s virtuálními vlákny? Je třeba vyprávět příběh.

Operační systémy a úzké místo vlákna

Java verze 1.0, která se poprvé objevila v roce 1995, byla první běžnou programovací platformou, která zahrnovala vlákna v základním jazyce. Než existovala vlákna, musely programovací jazyky k provádění multiprocesingu používat více procesů a různé mechanismy k předávání dat.

Z hlediska operačního systému jsou vlákna nezávislé prováděcí jednotky, které patří k procesu. Každé vlákno má čítač instrukcí provedení a zásobník volání, ale sdílí haldu s každým dalším vláknem ve stejném procesu.

Vlákna jsou plánována a spravována OS. Chcete-li změnit, které prováděcí jednotky jsou spuštěny, provede se přepnutí kontextu. Protože sdílejí stejnou haldu, je levnější přepínat mezi dvěma vlákny ve stejném procesu než mezi dvěma vlákny v různých procesech. To vede k lehkému přepínání kontextu.

Vlákna představují významné zlepšení výkonu (zejména z hlediska propustnosti), protože všechna data, která je třeba sdělit, zůstávají v paměti a na stejné hromadě procesu. Zapojení operačního systému do správy vláken však způsobuje některé problémy se škálovatelností, které není snadné vyřešit.

Klíčem k pochopení tohoto problému je, že když operační systém vytvoří vlákno, alokuje segment zásobníku, což je pevné množství paměti, které je vyhrazeno operačním systémem v rámci virtuálního adresního prostoru procesu pro každé vlákno. Paměť je rezervována při vytvoření vlákna a není uvolněna, dokud vlákno neukončí.

Pro procesy JVM běžící na Linuxu x64 je výchozí velikost zásobníku 1 MB a tento prostor je vyhrazen operačním systémem pokaždé, když spustíte nové vlákno. Díky tomu je matematika docela jednoduchá. Například 2 000 vláken znamená, že je třeba vyhradit 20 GB místa v zásobníku.

Můžete vidět, že to vytváří obrovský nesoulad mezi počtem objektů, které může Java program vytvořit (miliony nebo dokonce miliardy) a možným počtem vláken platformy, která může OS vytvořit a spravovat, a to především kvůli požadavkům na prostor zásobníku. Požadavek na prostor zásobníku ve skutečnosti omezuje počet vláken v procesu, což se nazývá úzké místo vlákna.

Mimochodem, úzké místo vlákna není v žádném případě specifické pro Javu: Požadavek na zásobníkový prostor pochází z operačního systému a podléhají mu všechna programovací prostředí. Bez ohledu na jazyk je toto úzké hrdlo stále závažnějším problémem, protože moderní serverové procesy (ať už napsané v Javě nebo jiném programovacím jazyce) potřebují spravovat mnohem větší počet kontextů provádění během letu, než tomu bylo v minulosti.

To vede k napětí mezi používáním vláken tím nejjednodušším a nejpřirozenějším způsobem – kde každé vlákno provádí jeden sekvenční úkol – a omezeními, která operační systém a platforma kladou na počet vláken. Kvůli požadavkům na prostor zásobníku není myšlenka na vytvoření nového vlákna platformy pro zpracování každého příchozího požadavku proveditelná, zvláště pokud chcete, aby se aplikace škálovala.

Zadejte virtuální vlákna

Řešením Javy na tento problém, které vám umožňuje zvýšit hustotu vláken a zároveň zachovat jednoduchost kódu, jsou virtuální vlákna poskytovaná Project Loom.

Projekt Loom byl diskutován v článku “Coming to Java 19: Virtual threads and platform threads“ a (v mnohem dřívější podobě projektu, která nemusí být striktně přesná ve srovnání s tím, co bylo skutečně dodáno) v “Going inside Java’s Project Loom a virtuální vlákna“. Popis virtuálních vláken si můžete přečíst v těchto článcích, ale pro úplnost je zde krátká diskuse o této nové jazykové funkci.

Níže jsou uvedeny dva hlavní aspekty virtuálních vláken, které se liší od vláken platformy:

  • Odebrat roli OS při vytváření a správě virtuálních vláken
  • Chcete-li nahradit statickou alokaci segmentů vláken flexibilnějším modelem

V obou případech běhové prostředí JVM přebírá roli, kterou normálně hraje operační systém pro vlákna platformy.

Prvním bodem je, že virtuální vlákna jsou jednoduše spustitelné objekty Java. Vyžadují platformové vlákno (známé jako nosné vlákno v kontextu virtuálních vláken), na kterém běží, ale tato platformová vlákna jsou sdílená. Tím se odstraní vztah 1:1 mezi vlákny Java a vlákny OS a místo toho se vytvoří dočasné přidružení virtuálního vlákna k vláknu nosiče – ale pouze během provádění virtuálního vlákna.

Druhým bodem je, že virtuální vlákna používají objekty Java v haldě shromažďované odpadky k reprezentaci rámců zásobníku. To je mnohem dynamičtější a odstraňuje statické úzké hrdlo způsobené rezervací segmentů zásobníku.

Dohromady to znamená, že počet virtuálních vláken se může teoreticky škálovat podobným způsobem jako počet objektů Java.

Virtuální vlákna a I/O

Odstranění úzkého hrdla vlákna je skvělý úspěch, ale v tuto chvíli vás možná stále zajímá, jak se virtuální vlákna propojují se zpracováním I/O a úvodní diskuzí v tomto článku.

Podívejte se na stavový model pro vlákno platformy na obrázku 1. V tomto modelu plánovač operačního systému přesouvá vlákna platformy za a mimo jádro (to znamená, že provádí přepínání kontextu).

Příklad stavů vláken platformy
Obrázek 1. Stavy vláken platformy

Existuje několik způsobů, jak může vlákno platformy opustit jádro.

  • Vlákno může opustit – ale tento případ není příliš zajímavý.
  • Vlákno se může dobrovolně a dočasně pozastavit (výtěžek) a vzdát se zbývajícího aktuálně přiděleného času. Může tak činit buď po pevně stanovenou dobu, přes Thread.sleep(), nebo dokud není splněna podmínka, přes Object.wait().
  • Vlákno může běžet, aniž by došlo k blokovacím voláním standardní knihovny nebo jakýmkoli instrukcím spánku nebo čekání. V tomto případě na konci přiděleného času podprocesu (někdy nazývaného časové kvantum ), plánovač přesune vlákno do zadní části fronty běhu, aby počkalo, dokud nedosáhne přední části fronty a bude způsobilé ke spuštění znovu. (Časové kvantum je obvykle 10 milisekund — nebo 100 milisekund ve starších operačních systémech). Avšak kromě úloh vázaných na výpočty se tento případ v praxi tak často nevidí.
  • Vlákno se může setkat s blokujícím voláním, například I/O operace. Takto se I/O spojuje s virtuálními vlákny.

Jak jsou tyto podmínky řešeny? Zavedení virtuálních vláken znamená, že ne všechna vlákna jsou naplánována OS – pouze vlákna platformy. Virtuální vlákna se proto OS nejeví jako plánovatelné entity. Místo toho má nyní JVM další plánovač na úrovni VM, který je zodpovědný za řízení přístupu ke sdíleným vláknům nosiče.

Tato nosná vlákna patří do fondu vláken, konkrétně exekutoru ForkJoinPool. (Všimněte si, že tento fond vláken se liší od sdíleného společného fondu používaného pro paralelní proudy).

Pokud vlákno narazí na blokovací volání, pokračuje normálně, pokud se jedná o vlákno platformy. Pokud se však jedná o virtuální vlákno (a je tedy dočasně připojeno k vláknu nosiče), běhové prostředí jednoduše odešle I/O operaci a poté odpojí virtuální vlákno od nosiče.

Pamatujte, že I/O se nyní provádí neblokovacím způsobem běhovým prostředím podle JEP 353 a JEP 373 v Javě 15 a novějších. Nosič má nyní možnost připojit jiné virtuální vlákno a pokračovat v provádění, zatímco blokování I/O z prvního virtuálního vlákna probíhá na pozadí.

Plánovač na úrovni virtuálního počítače nepřeplánuje zablokované virtuální vlákno, dokud nebude dokončena I/O operace a nebudou k dispozici data k načtení.

Dnes byla většina běžných blokovacích operací upravena tak, aby podporovala virtuální vlákna. Některé operace, jako například Object.wait(), však ještě nejsou podporovány a místo toho zachycují nosné vlákno. V tomto případě fond vláken zareaguje dočasným zvýšením počtu vláken platformy ve fondu, aby se kompenzovalo zachycené vlákno.

Kromě zachycených vláken existuje také možnost připnutých vláken. K tomu dochází, když virtuální vlákno spustí nativní metodu nebo narazí na synchronizovaný kód. V tomto případě není virtuální vlákno odpojeno; místo toho si ponechává vlastnictví podprocesu dopravce. Fond podprocesů nevytváří další podproces a efektivní velikost fondu podprocesů je po dobu trvání kolíku snížena.

Co to všechno znamená

Virtuální vlákna jsou vylepšením JVM. Toto zařízení se zásadně liší od přístupu async-await, který přijaly Kotlin, C# a další jazyky.

Konkrétně v bajtkódu není žádný skutečný otisk virtuálních vláken, protože virtuální vlákna jsou jen vlákna a manipulace s nimi je součástí platformy (tedy JVM), nikoli uživatelského kódu.

Cílem virtuálních vláken je umožnit programátorům Java psát kód v tradičním sekvenčním stylu vláken, na který jsou již zvyklí. Jakmile se virtuální vlákna stanou hlavním proudem, nemělo by být nutné explicitně spravovat výnosy nebo používat v uživatelském kódu operace bez blokování, zpětného volání nebo reaktivní operace.

Existují i ​​další výhody, jako je umožnění debuggerům a profilovačům pracovat stejným způsobem s virtuálními vlákny jako s tradičními vlákny. To samozřejmě znamená, že výrobci nástrojů a návrháři knihoven musí vykonat práci navíc s podporou virtuálních vláken.

Obecně se však prostředí Java snaží snížit kognitivní zátěž pro koncové uživatele Java vývojářů a přesunout tuto složitost do platformy a knihoven. V tomto smyslu je návrh virtuálních vláken klasickým kompromisem Java.

Začínáme s virtuálními vlákny

Hierarchie dědičnosti vlákna byla vylepšena příchodem virtuálních vláken, jak můžete vidět na obrázku 2.

Diagram hierarchie vláken
Obrázek 2. Hierarchie vláken

Všimněte si, že třída VirtualThread je explicitně deklarována jako konečná a nemůže být podtřídou. Proto každá existující třída, která rozšiřuje Thread přímo, bude podprocesem platformy. To je součástí principu, že příchod virtuálních vláken nesmí změnit sémantiku žádného existujícího kódu Java.

Tvůrci vláken jsou další důležitou novou funkcí virtuálních vláken: Třída VirtualThread je neveřejná a pro vývojáře musí existovat nějaký způsob, jak virtuální vlákna vytvořit. Proto byly do vlákna přidány nové statické tovární metody pro vytváření objektů stavitelů, jak je znázorněno níže.

jshell> var tb = Thread.ofVirtual() // or .ofPlatform()
tb ==> java.lang.ThreadBuilders$VirtualThreadBuilder@b1bc7ed

Tvůrci mohou nastavit název a spustitelnou úlohu.

jshell> var t = tb.name("MyThread").unstarted(() -> System.out.println("Virtual World"))
t ==> VirtualThread[#24,MyThread]/new

Tím se vlákno vytvoří ve stavu NEW, takže jej lze spustit obvyklým způsobem. Vlákno můžete také vytvořit a okamžitě spustit voláním start()přímo na objekt stavitele. Všimněte si mírně odlišného .toString()výstupu, který vytvářejí virtuální vlákna.

Továrny na nitě jsou také k dispozici od stavitelů.

jshell> var tf = tb.factory()
tf ==> java.lang.ThreadBuilders$VirtualThreadFactory@c4437c4

Tyto továrny pak lze používat stejně jako jakoukoli jinou továrnu na vlákna, se kterou jste se mohli při práci s platformovými vlákny setkat.

Pokyny pro virtuální vlákna

Virtuální vlákna byla navržena tak, aby se co nejvíce podobala existujícím vláknům, ale jsou zde některé klíčové rozdíly.

Zde je několik pokynů, které je třeba mít na paměti, když začnete přijímat virtuální vlákna ve svých vlastních aplikacích Java.

  • Zkontrolujte datum všech článků, na Java Magazine nebo jinde, které pojednávají o virtuálních vláknech, protože oproti různým náhledovým verzím se toho hodně změnilo.
  • Neberte virtuální vlákna jako oběd zdarma.
  • Přečtěte si, s jakými typy problémů mohou virtuální vlákna pomoci.
  • Nepoužívejte virtuální vlákna pro úlohy vázané na výpočetní výkon, protože potřebují blokovací volání, aby se automaticky generovaly.
  • Očekávejte, že se naučíte nějaké nové intuice a vzory pro virtuální vlákna.
  • Nepoužívejte synchronizované bloky ani nevolejte synchronizované metody z virtuálních vláken.
  • Nepoužívejte Thread.yield()pro virtuální vlákna; funguje, ale nedoporučuje se to používat.

Naučit se to všechno zabere čas a komunita Java se musí přizpůsobit virtuálním vláknům.

Důležitou návrhářskou činností je zejména výběr mezi virtuálními vlákny a vlákny platformy pro jakýkoli konkrétní úkol. Programátor se musí rozhodnout, zda je daná úloha výpočetně vázána (a měla by být tedy řešena platformovým vláknem), nebo zda jde v zásadě o úlohu soustředěnou kolem I/O (a je tedy vhodná pro virtuální vlákno).

Závěr

Od verze Java 21 (k dispozici v září 2023) budou virtuální vlákna k dispozici jako standardní finální funkce. Jak velké přímé využití virtuálních vláken bude – na rozdíl od virtuálních vláken primárně využívaných knihovnami a frameworky – se teprve uvidí.

Pokračující vývoj souběžnosti Java – kterou představují virtuální vlákna – je však známkou celkové vitality a životnosti platformy.

Zdroj: Oracle