parallell och asynkron programmering i Java 8

parallell kod, som är kod som körs på mer än en tråd, var en gång mardrömmen för många en erfaren utvecklare, men Java 8 medförde många förändringar som skulle göra detta prestationshöjande trick mycket mer hanterbart.

parallella strömmar

före Java 8 var det en stor skillnad mellan parallell (eller samtidig) kod och sekventiell kod. Det var också mycket svårt att felsöka icke-Sekventiell kod. Att bara ställa in en brytpunkt och gå igenom flödet som du normalt skulle göra, skulle ta bort parallellaspekten, vilket är ett problem om det är det som orsakar felet.

lyckligtvis gav Java 8 oss strömmar, det bästa för Java-utvecklare sedan bean. Om du inte vet vad de är, gör Stream API det möjligt att hantera sekvenser av element i en funktionell fråga. (Kolla vår jämförelse mellan strömmar och. NET: s LINQ här.) En av fördelarna med strömmar är att kodens struktur förblir densamma: oavsett om det är sekventiellt eller samtidigt, förblir det lika läsbart.

för att få din kod att springa parallellt använder du helt enkelt .parallelStream() istället för .stream(), (eller stream.parallel(), om du inte är skaparen av strömmen).

men bara för att det är enkelt betyder det inte att parallellkod alltid är det bästa valet. Du bör alltid överväga om det är vettigt att använda samtidighet för din kod. Den viktigaste faktorn i det beslutet är hastigheten: använd endast samtidighet om det gör din kod snabbare än dess sekventiella motsvarighet.

Hastighetsfrågan

parallell kod får sin hastighetsfördel från att använda flera trådar istället för den enda som Sekventiell kod använder. Att bestämma hur många trådar som ska skapas kan vara en knepig fråga eftersom fler trådar inte alltid resulterar i snabbare kod: om du använder för många trådar kan prestandan för din kod faktiskt gå ner.

det finns ett par regler som kommer att berätta vilket antal trådar du ska välja. Detta beror mest på vilken typ av operation du vill utföra och antalet tillgängliga kärnor.

beräkningsintensiva operationer bör använda ett antal trådar som är lägre än eller lika med antalet kärnor, medan Io-intensiva operationer som kopiering av filer inte har någon användning för CPU och därför kan använda ett högre antal trådar. Koden vet inte vilket fall som är tillämpligt Om du inte säger vad du ska göra. Annars kommer det att vara standard för ett antal trådar som är lika med antalet kärnor.

det finns två huvudfall när det kan vara användbart att köra din kod parallellt istället för sekventiell: tidskrävande uppgifter och uppgifter som körs på stora samlingar. Java 8 tog ett nytt sätt att hantera de stora samlingarna, nämligen med strömmar. Strömmar har inbyggd effektivitet av latskap: de använder Lat utvärdering som sparar resurser genom att inte göra mer än nödvändigt. Detta är inte detsamma som parallellism, som inte bryr sig om resurserna så länge det går snabbare. Så för stora samlingar behöver du förmodligen inte klassisk parallellism.

Going Async

lärdomar från JavaScript

det är en sällsynt händelse att en Java-utvecklare kan säga att de lärt sig något från att titta på JavaScript, men när det gäller asynkron programmering, JavaScript faktiskt fick det rätt först. Som ett grundläggande async-språk har JavaScript mycket erfarenhet av hur smärtsamt det kan vara när det är dåligt implementerat. Det började med återuppringningar och ersattes senare av löften. En viktig fördel med löften är att den har två ”kanaler”: en för data och en för fel. Ett JavaScript-löfte kan se ut så här:

func.then(f1).catch(e1).then(f2).catch(e2);

så när den ursprungliga funktionen har ett framgångsrikt resultat kallas f1, men om ett fel kastades kommer e1 att ringas. Detta kan föra den tillbaka till det framgångsrika spåret (f2), eller resultera i ett annat fel (e2). Du kan gå från dataspår till felspår och tillbaka.

Java-versionen av JavaScript-löften heter CompletableFuture.

CompletableFuture

CompletableFuture implementerar både gränssnittet Future och CompletionStage. Future fanns redan före Java8, men det var inte särskilt utvecklarvänligt av sig själv. Du kunde bara få resultatet av den asynkrona beräkningen med hjälp av .get() – metoden, som blockerade resten (vilket gjorde async-delen ganska meningslös för det mesta) och du behövde implementera varje möjligt scenario manuellt. Att lägga till gränssnittet CompletionStage var genombrottet som gjorde asynkron programmering i Java användbar.

CompletionStage är ett löfte, nämligen löftet att beräkningen så småningom kommer att göras. Den innehåller en massa metoder som låter dig bifoga återuppringningar som kommer att utföras vid den slutförandet. Nu kan vi hantera resultatet utan att blockera.

det finns två huvudmetoder som låter dig starta den asynkrona delen av din kod: supplyAsync om du vill göra något med resultatet av metoden och runAsync om du inte gör det.

CompletableFuture.runAsync(() → System.out.println("Run async in completable future " + Thread.currentThread()));CompletableFuture.supplyAsync(() → 5);

Callbacks

nu kan du lägga till dessa callbacks för att hantera resultatet av din supplyAsync.

CompletableFuture.supplyAsync(() → 5).thenApply(i → i * 3).thenAccept(i → System.out.println("The result is " + i).thenRun(() → System.out.println("Finished."));

.thenApply liknar funktionen .map för strömmar: den utför en transformation. I exemplet ovan tar det resultatet (5) och multiplicerar det med 3. Det kommer sedan att passera det resultatet (15) längre ner i röret.

.thenAccept utför en metod på resultatet utan att omvandla det. Det kommer inte heller att returnera ett resultat. Här kommer det att skriva ut ”resultatet är 15” till konsolen. Det kan jämföras med .foreach – metoden för strömmar.

.thenRun använder inte resultatet av async-operationen och returnerar inte någonting, det väntar bara på att ringa dess Runnable tills föregående steg är klart.

Asyncing din Async

alla ovanstående återuppringningsmetoder kommer också i en async-version: thenRunAsync, thenApplyAsync, etc. Dessa versioner kan köras på egen tråd och de ger dig extra kontroll eftersom du kan berätta vilken ForkJoinPool att använda.

om du inte använder async-versionen kommer alla återuppringningar att köras på samma tråd.

när saker går fel

när något går fel används metoden exceptionally för att hantera undantaget. Du kan ge den en metod som returnerar ett värde för att komma tillbaka på dataspåret eller kasta ett (nytt) undantag.

….exceptionally(ex → new Foo()).thenAccept(this::bar);

kombinera och komponera

du kan kedja Flera CompletableFutures med metoden thenCompose. Utan det skulle resultatet vara kapslat CompletableFutures. Detta gör thenCompose och thenApply som flatMap och map för strömmar.

CompletableFuture.supplyAsync(() -> "Hello").thenCompose(s -> CompletableFuture.supplyAsync(() -> s + "World"));

om du vill kombinera resultatet av två CompletableFuturesbehöver du en metod som bekvämt kallas thenCombine.

future.thenCombine(future2, Integer::sum).thenAccept(value → System.out.println(value));

som du kan se i exemplet ovan kan resultatet av återuppringningen i thenCombine hanteras som en vanlig CompletableFuture med alla dina favoritmetoder CompletionStage.

slutsats

parallell programmering behöver inte längre vara ett oöverstigligt hinder i jakten på snabbare kod. Java 8 gör processen så enkel som möjligt, så att någon kod som eventuellt kan dra nytta av den, kan dras, sparka och skrika på alla trådar, in i Den flerkärniga framtiden som faktiskt bara är idag. Med vilket jag menar: det är lätt att göra, så prova och se dess fördelar för dig själv.



+