Moderne concurrency i Java
"tråder" vs "oppgaver"
Java-objekter er vanligvis ganske billige, og du trenger ikke tenke så mye på om du lager mange.
Tråder derimot, er relativt dyre, både i oppstartstid og minne.
Vanligvis bryr vi oss ikke om tråder, vi vil bare gjøre ting "i bakgrunnen" eller "parallellt".
Så vi lar en annen klasse ta seg av trådene, og lar den ta seg av kompliserte ting som resirkulering av dem, og så leverer vi bare oppgaver til den klassen.
"tråder" vs "oppgaver"
Time for a task to complete in a new Thread 71.3 us
Time for a task to complete in a thread pool 0.39 us
Time for a task to complete in the same thread 0.08 us
En liten fotnote om hva "dyrt" egentlig er. Eksempel på tid brukt for å gjøre minimale oppgaver:
ExecutorService og Executors
ExecutorService er et grensesnitt for klasser som kan ta imot jobber og utføre dem.
Executors er en hjelpeklasse for å lage forskjellige varianter av ExecutorService.
ExecutorService es = Executors.newCachedThreadPool();
es.execute(() -> System.out.println("Hello world"));
Executors - de viktigste
ExecutorService es = Executors.newCachedThreadPool();
[...] creates new threads as needed, but will reuse previously constructed threads when they are available.
[...] reuses a fixed number of threads operating off a shared unbounded queue.
ExecutorService es = Executors.newFixedThreadPool(n);
ExecutorService es = Executors.newSingleThreadExecutor();
ExecutorService es = Executors.newWorkStealingPool(n);
[...] maintains enough threads to support the given parallelism level, and may use multiple queues to reduce contention.
Når er programmet ferdig?
ExecutorService es = Executors.newCachedThreadPool();
es.execute(() -> System.out.println("Hello world"));
// ...
es.shutdown();
En "vanlig" ExecutorService vil holde programmet i live helt til du stopper den.
Etter shutdown() vil den ikke ta imot flere oppgaver, men vil gjøre ferdig de den allerede har.
ForkJoinPool
ForkJoinPool er en spesialisert ExecutorService bl.a. for rekursiv parallellisering, men kan brukes til vanlige oppgaver også.
ForkJoinPool.commonPool() gir tilgang til en instans med samme "parallellitet" som antall kjerner.
Bruker work-stealing og daemon-tråder (tråder som ikke holder igjen programmet fra å avslutte)
Executors.newWorkStealingPool(n) bruker en ForkJoinPool på innsiden, så vil heller ikke holde igjen programmet fra å avslutte.
"atomisk"
At en operasjon er atomisk vil si at hele operasjonen skjer "på en gang".
a += b
// 1. Hent a
// 2. Hent b
// 3. Legg sammen a og b
// 4. Lagre første halvdel av resultatet i a
// 5. Lagre andre halvdel av resultatet i a
Vanligvis kan nemlig selv operasjoner på enkle tall deles opp når det utføres:
Med tråder kan dette selvsagt forårsake masse trøbbel.
Har vi lyst til å tenke på synkronisering som "beskytter" variabelen? Nei! Vi vil at operasjonene ikke skal deles opp! At de skal skje "atomisk".
java.util.concurrent.atomic
En hel pakke med klasser for variable som kan oppdateres atomisk.
// IKKE BRUK: Nesten akkurat samme problemer som uten atomisk
a.set(a.get() + b);
For eksempel med AtomicLong kan problemet på forrige slide løses enkelt:
Men vær obs! Bruker du dem feil løser du ingenting:
a.addAndGet(b);
LongAdder er en ekstra rask variant for å bare legge sammen tall.
a.add(b);
Datastrukturer i java.util.concurrent
På samme måte som atomiske variabler løser problemet med tilgang til enkle variabler har vi de fleste andre datastrukturene vi trenger i java.util.concurrent
For eksempel tilsvarer selvsagt ConcurrentHashMap en vanlig HashMap, tilpasset concurrency.
Stort sett er disse løsningene mye raskere enn å bruke synchronized eller manuell låsing.
Postkontor
Trix-oppgave 8.3,
en liten oppfriskning
Postkontor.leverPost(post)
Putt post inn i en kø, vent hvis det ikke er plass.
Postkontor.hentPost()
Hent post, vent hvis det ikke er noe.
Postkontor m/wait-notify
public class Postkontor {
private final int maksPost = 10;
private Post[] postHylle = new Post[maksPost];
private int antallPost = 0;
public synchronized void leverPost(Post post) {
while (antallPost >= maksPost) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for (int i = 0; i < maksPost; i++) {
if (postHylle[i] == null) {
postHylle[i] = post;
antallPost++;
notifyAll();
return;
}
}
throw new IllegalStateException("Ikke plass selv om vi trodde det!");
}
public synchronized Post hentPost() {
while (antallPost <= 0) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for (int i = 0; i < maksPost; i++) {
if (postHylle[i] != null) {
Post ret = postHylle[i];
postHylle[i] = null;
antallPost--;
notifyAll();
return ret;
}
}
throw new IllegalStateException("Ikke post selv om vi trodde det!");
}
}
Postkontor
Alt dette er egentlig bare en BlockingQueue<Post>. En kø som blokkerer når det man prøver å gjøre ikke går.
Men man må ikke nødvendigvis blokkere:
Postkontor
public class Postkontor {
private final int maksPost = 10;
private BlockingQueue<Post> queue = new LinkedBlockingQueue<>(maksPost);
public void leverPost(Post post) {
try {
queue.put(post);
} catch (InterruptedException e) {
// Å sende feilmeldingen oppover hadde nok vært bedre,
// men for å matche signaturen som var:
throw new RuntimeException(e);
}
}
public Post hentPost() {
try {
return queue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Postkontor m/mottaker
public class Postkontor {
private final int maksPost = 10;
private Map<String, BlockingQueue<Post>> queues = new ConcurrentHashMap<>();
private Semaphore plasser = new Semaphore(maksPost);
public BlockingQueue<Post> getQueue(String key) {
return queues.computeIfAbsent(key, k -> new LinkedBlockingQueue<>());
}
public void leverPost(Post post) {
try {
plasser.acquire();
getQueue(post.getMottaker()).put(post);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public Post hentPost(String mottaker) {
try {
Post ret = getQueue(mottaker).take();
plasser.release();
return ret;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Postkontor m/mottaker
public class Postkontor { // utdrag
private final int maksPost = 10;
private Semaphore plasser = new Semaphore(maksPost);
public void leverPost(Post post) {
plasser.acquire(); // plasser--, blokker hvis 0
}
public Post hentPost(String mottaker) {
plasser.release(); // plasser++
}
}
For å fortsatt ha ti plasser totalt bruker vi Semaphore, som er et tall vi kan telle opp og ned, og hvis vi prøver å telle under 0, blokkerer vi til noen teller opp.
Postkontor m/mottaker
public class Postkontor { // utdrag
private Map<String, BlockingQueue<Post>> queues = new ConcurrentHashMap<>();
public BlockingQueue<Post> getQueue(String key) {
return queues.computeIfAbsent(key, k -> new LinkedBlockingQueue<>());
}
public void leverPost(Post post) {
getQueue(post.getMottaker()).put(post);
}
public Post hentPost(String mottaker) {
return getQueue(mottaker).take();
}
}
computeIfAbsent gjør atomisk: henting og eventuell insetting ved manglende element.
Selv om ConcurrentHashMap er trygg må vi fortsatt passe på hva vi lagrer i den og hvordan vi bruker den.
CompletableFuture
CompletableFuture.runAsync(() -> {
System.out.println("Hello world");
});
Lagt til i Java 8 og er på en gang veldig enkel og superkomplisert.
Du kan enkelt kjøre noe i en bakgrunnstråd:
Obs, obs: I utgangspunktet bruker den ForkJoinPool.commonPool(), så jobbene i bakgrunnen vil ikke holde programmet gående.
CompletableFuture
CompletableFuture.supplyAsync(() -> {
return "Hello";
}).thenAcceptBothAsync(CompletableFuture.supplyAsync(() -> {
return " world";
}), (a, b) -> {
System.out.println(a + b);
});
Det som gjør ting komplisert (og nyttig) er at du kan kombinere oppgaver:
Å gjøre ting i bakgrunnen i JavaFX
Detaljene ligger i en annen presentasjon:
Moderne concurrency
By Erik Vesteraas
Moderne concurrency
- 1,610