Vad är virtuella trådar i Java?
9 minuter i lästid Java Threads Loom Coroutines

Vad är virtuella trådar i Java?

Det har nu gått ett flertal veckor sedan Java 19 släpptes och om du följt nyhetsflödet kring Java så har du troligtvis hört om begreppet virtuella trådar (Virtual Threads). Vad är då detta? I denna artikel tänkte jag reda ut begreppen och förklara vad detta är samt vad det innebär för programutveckling i Java, framgent i takt med att detta övergår från förhandsvisning till att bli en del av Java plattformen.

En bra startpunkt är att börja med att kort förklara vad är en vanlig tråd (Thread). Begreppet thread introducerades av operativsystemet Mach i slutet på 1980-talet, som ett sätt att låta flera samtidiga operation pågå inom en och samma (applikations-) process. Threads blev snabbt populärt och implementerades i olika former under 1990-talet.

I början var implementationerna mestadels utan stöd av operativsystemet, men under andra halvan blev threads en del av de flesta operativsystem, såsom Windows NT och med lite senare Linux.

Till det förstnämnda, kan räknas Green Threads, som var en del av Project Green hos SUN, vilket efter ett kommersiellt tillkortakommande återuppstod som projektet Java. Eftersom threads har varit en del av språket Java sedan början i och med att SunOS (aka Solaris) hade threads, så användes Green Threads som en ersättare för OS som saknade detta. Typiskt gällde detta för Java på Windows95. Med tiden har begreppet green threads kommit att använts som en allmän beteckning på trådar utan OS stöd.

Ett annat exempel på det förstnämnda, var "Sticka Lill-Babs Threads", som undertecknad implementerade i början på 1990-talet som en del i min undervisning om trådprogrammering på KTH. Det var ett enkelt API i C, samt några rader inline ASM för att få tag på register som IP, SP, med flera.

För en yngre publik, låter namnet ganska märkligt, så det kan vara på plats med en kort beskrivning av tidsandan för 30 år sedan. På den tiden var det vanligt med veckotidningar, som trycktes på papper och såldes i kiosker eller skickades via vanlig post hem till folk om de hade en prenumeration. Tidningar riktade till en kvinnlig publik hade ofta stickbeskrivningar av tröjor och liknade, ofta med titlar som t.ex. "Så här stickar du Lill-Babs tröjan". Lill-Babs var för övrigt en mycket populär och folkkär sångartist. Så jag ville inte vara sämre, utan erbjöd mina elever på KTH en "stickbeskrivning" för hur man kunde fixa sina egna trådar. Som sagt, detta var långt innan dylikt var en del av alla OS.

En thread är ett miniprogram och fungerar på samma sätt som huvudprogrammet, genom att varje funktionsanrop lägger på en stackpost överst på en stack av sådana. Varje thread har sin egen stack och upptar typiskt en storlek på någon eller några megabytes.

Rent generellt, så skiljer man på user-level threads (t.ex. Lill-Babs Threads) och kernel-level threads, vilket innebär att operativet skapar och hanterar dessa (t.ex. Linux Threads). Den primära skiljelinjen mellan dessa är det som kallas preemptive scheduling och innebär att en thread hanterad av operativet får exekvera maximalt en viss tid (t.ex. 50ms) och byts sedan (preemption) till en annan, såvida inte tråden redan utfört en in/ut operation, som innebär väntan (blocking I/O operation).

I och med att minnesbehovet för en threads är rätt stort, finns det en övre gräns för hur många threads det går att skapa. Några hundra threads är hur bra som helst, men skapar man flera tusen börjar operativet bli segt till följd av många trådbyten och hur detta påverkar virtuell-minnes systemet, pga av många s.k. page-faults, vilket innebär att ladda in sparade minnessidor från hårddisken. Till slut, hänger sig eller kraschar operativet.

När Java var nytt kunde man inte föreställa sig system med fler än något hundratal threads. Emellertid, visade internet att så inte var fallet. Den idiomatiska programdesignen för ett kommunikationssystem i Java är att låta vara kanal hanteras av en egen thread. Det kan röra sig om chat-system eller andra kollaborativa applikationer.

Denna designmodell, klarade inte internet-ålderns framväxt, vilket jag bl.a. beskrev i förra artikeln. Ryan Dahl formulerade via sitt Node.js för drygt tio år sedan en väg framåt, via asynkron programexekvering. Kruxet var sen att inkorporera detta i programspråk, som var konstruerade för en helt annan exekveringsmodell.

Under årens lopp har det dykt olika modeller för asynkron kod i Java, såsom java.util.concurrent.CompletableFuture, RxJava med flera. Emellertid, den model som visat sig vara enklast att programmera i är coroutines, vilket vi sett i t.ex. async functions i JavaScript. Därmed krokar vi där förra artikeln slutade.

Låt oss börja med att skriva lite kod. Följande Java program skapar en vanlig thread och en virtuell sådan, lägger dem i en lista och startar och väntar på dem. Trådarna skriver ut information om dem själva.

public class CompareThreads {
    public static void main(String[] args) {
        Consumer<Thread> waitForTermination = t -> {
            try { t.join(); }
            catch (InterruptedException e) {}
        };
        List.of(
            Thread.ofPlatform().start(() -> {
                pause(1);
                System.out.printf("Operating System Thread: %s%n", Thread.currentThread());
            }),
            Thread.ofVirtual().start(() -> {
                pause(1);
                System.out.printf("Java Virtual Thread    : %s%n", Thread.currentThread());
            })
        ).forEach(waitForTermination);
    } 
    static void pause(int numSecs) {
        try { Thread.sleep(numSecs * 1000L); }
        catch (InterruptedException e) {}
    }
}

Så här ser det ut när man kompilerar och kör programmet (single-source Java file)

$ java --enable-preview --source 19 src/CompareThreads.java
...
Operating System Thread: Thread[#29,Thread-0,5,main]
Java Virtual Thread    : VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1

Raden med punkter ovan innehöll utskrifter om att man kör en förhandsversion (preview). Klassen java.lang.Thread har utökats med builder funktioner ofPlatform() respektive ofVirtual(). Tidigare kunde man bara definiera trådar genom att skapa en subklass eller skicka in en Runnable till dess konstruktor. Sista anropet i en sådan builder-kedja är antingen start(Runnable) eller unstarted(Runnable). För den sistnämnda måste man starta explicit, medan den förstnämnda startar tråden.

Utskrifterna är intressanta, eftersom det visar på skillnaden. För en platform thread, så visar den sitt id. För en virtuell thread ser vi att den tillhör en pool. En virtuell thread behöver en platform thread för att exekvera. En dylik kallas för carrier thread. När en virtuell thread utför en suspenderande operation, såsom I/O eller sleep, tas den bort från OS tråden och sparas på heapen.

En virtuell thread är helt enkelt en coroutine. Den vitala delen här, är inte just detta faktum, utan att man håller på att skriva om valda delar av klasserna i standardbiblioteket, såsom socket I/O med flera, till att utföra en coroutine suspend om det är en virtuell tråd som anropar. Emellertid, om det är en platform thread så blir detta samma som tidigare, nämligen att hela tråden suspenderas.

Det är i skrivande stund inte kommunicerat när man har genomfört dessa ändringar. Ett enkelt jämförande test visar att vi inte är där ännu.

import java.net.URL;
import java.util.ArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import static java.lang.Runtime.getRuntime;
import static java.lang.System.nanoTime;
import static java.util.concurrent.Executors.newFixedThreadPool;
import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor;

public class Download {
    public static void main(String[] args) {
        var N   = 100;
        var url = "https://www.ribomation.se/blog/uppgangen-fallet-aterkomsten-av-coroutines/";
        run(N, url);
    }

    static void run(int N, String url) {
        useCase("Platform Threads", N, url, newFixedThreadPool(getRuntime().availableProcessors()));
        useCase("Virtual  Threads", N, url, newVirtualThreadPerTaskExecutor());
    }

    static  void useCase(String name, int N, String url, ExecutorService pool) {
        var startTime = nanoTime();
        final Callable<Integer> task = () -> {
            try (var is = new URL(url).openStream()) {
                int cnt = 0;
                var buf = new byte[1024];
                for (int rc; (rc = is.read(buf)) > -1; ) cnt += rc;
                return cnt;
            }
        };
        var tasks = new ArrayList<Future<Integer>>();
        for (var k = 0; k < N; ++k) tasks.add(pool.submit(task));
        var totLen = tasks.stream()
                .map(result -> {
                    try {return result.get();}
                    catch (Exception e) {throw new RuntimeException(e);}
                })
                .mapToInt(Integer::intValue)
                .sum();
        var elapsedNanoSecs = nanoTime() - startTime;
        pool.shutdown();
        System.out.printf("[%s] Total length: %d chars, elapsed %.3f seconds%n",
                name, totLen, elapsedNanoSecs * 1E-9);
    }
}

Programmet laddar ned samma fil ett antal gånger och summerar antalet bytes. Först med en thread pool baserad på platform threads. Sedan med en pool, som skapar nya virtual threads. Själva inläsningsloopen, läser 1 KB i taget och lämnar därför möjlighet för växling av virtuella trådar.

Så här ser ett körexempel ut.

Java> java --version
openjdk 19.0.1 2022-10-18
OpenJDK Runtime Environment (build 19.0.1+10-21)
OpenJDK 64-Bit Server VM (build 19.0.1+10-21, mixed mode, sharing)
Java>
Java> java --source 19 --enable-preview Download.java
Note: Download.java uses preview features of Java SE 19.
Note: Recompile with -Xlint:preview for details.
[Platform Threads] Total length: 2993900 chars, elapsed 6,954 seconds
[Virtual  Threads] Total length: 2993900 chars, elapsed 9,680 seconds
Java>

Som synes går det långsammare med virtuella trådar. Sen kan det förvisso vara kopplat till ett långsamt internet. När jag skriver detta sitter jag på ett hotellrum i Göteborg lyssnande på Frank Zappa över Spotify. Att då samtidigt öppna 100 sockets till en webbserver över hotellets wifi kan eventuellt spela in. Emellertid, visar förfluten tid för respektive use-case att det går att komma ned till drygt sex sekunder.

Så sammanfattningsvis, virtual threads i modern Java är en mycket lovande teknik och något att definitivt hålla ögonen i takt med att project Loom realiserar inom ramen för standard Java.

Länkar