Multi-threading met Futures

6 January 2012 13:18 Lennaert van der Linden Algemeen, Java

In dit artikel wordt aan de hand van een concreet voorbeeld getoetst of we java applicaties multi-threaded kunnen maken zonder bijkomstige complexiteit te introduceren met behulp van futures.

Enkele jaren terug heb ik voor een klant een functionaliteit om een afbeelding samen te stellen uit deelafbeeldingen gerealiseerd. Ik hanteerde daarbij een implementatie die recht toe recht aan was: de afbeeldingen werden één voor één geladen om vervolgens op elkaar geplakt te worden. De aanpak werkte prima en voldeed aan de performance-eisen. Maar het bleef daarna knagen bij mij… is een efficiëntere manier mogelijk zonder extra (technische) complexiteit te introduceren?

Als voorbeeld wordt hier een implementatie getoond, zeer losjes gebaseerd op de bovenstaande case. In de loop van dit artikel wordt deze implementatie aangepast naar een versie waarbij afbeeldingen parallel aan elkaar worden geladen.

ImageService imageService = new ImageService();

// laden
BufferedImage bottom = imageService.getBottomLayer();
BufferedImage middle = imageService.getMiddleLayer();
BufferedImage top = imageService.getTopLayer();

// stapelen
ImageStacker imageStacker = new ImageStacker();
imageStacker.add(bottom);
imageStacker.add(middle);
imageStacker.add(top);
BufferedImage stackedImage = imageStacker.getImage();

Parallelle en seriële functies

De implementatie had sneller gekund als de afbeeldingen niet achter elkaar geladen zouden worden, maar gelijktijdig. Bij het uitvoeren wordt bestaat een groot deel van de tijd aan het wachten op tot de afbeelding beschikbaar is. Maar in plaats van de tweede afbeelding pas op te vragen als de eerste gereed is, kunnen de afbeelding ook allemaal tegelijk opgevraagd worden. De totale doorlooptijd wordt hiermee verkort.

Maar programmeertalen als Java zijn inherent serieel. Dat wil zeggen, je moet als programmeur aan de compiler duidelijk maken welke delen van de code parallel uitgevoerd kunnen worden. De compiler is niet in staat om te bepalen of instructies tegelijkertijd uitgevoerd kunnen worden en het resultaat is dat code daarom niet parallel wordt uitgevoerd. Het voordeel is dat de afstemming tussen instructies impliciet is geregeld (het samenvoegen start pas als alle afbeeldingen zijn geladen) ten koste van een optimale benutting van de processorkracht.

Gelukkig biedt Java verschillende mogelijkheden om instructies parallel in aparte threads uit te laten voeren. Een mogelijke manier is gebruik te maken van Thread-objecten en joins. De operatie wordt in een aparte thread uitgevoerd en vervolgens laten we de applicatie op een bepaald punt wachten tot deze operatie gereed is door middel van een join-aanroep (pseudocode):

  Thread thread = new Thread(/* operatie() */);
  thread.run();

  // doe iets anders

  thread.join();

  // doe iets met het resultaat van operatie()

Een belangrijk nadeel van deze methode is dat we met threads en joins de implementatie complexer maken om te schrijven, te lezen en te onderhouden. De bijkomstige complexiteit die hiermee wordt geïntroduceerd weegt niet altijd op tegen de tijdwinst die we behalen met het uitvoeren van de code. En de code wordt complexer naarmate meer threads parallel worden uitgevoerd.

Futures

Gelukkig zijn er meer mechanismen in Java om om te gaan met concurrency. Met futures wordt het synchroniseren van threads uit handen genomen van de programmeur. Handelingen worden in een aparte thread uitgevoerd en de applicatie wacht tot een resultaat beschikbaar is als dat nodig is, zonder expliciete code. Het gevolg is beter te begrijpen code en geen kans op deadlocks. Futures zijn al sinds Java 1.5 beschikbaar en zijn recent ook toegevoegd aan de laatste C++-standaard.

Een uitgebreide introductie van futures valt buiten het bereik van dit artikel, maar ik zal proberen futures toe te lichten aan de hand van een eenvoudig voorbeeld:

  Future<Double> a = f1();
  Future<Double> b = f2();

  // doe iets anders ...

  System.out.println("a=" + a.get() + ", b=" + b.get());

In plaats van een operatie uit te voeren en het resultaat te retourneren wordt door de functies f1 en f2 een zogeheten future geretourneerd. De operatie wordt asynchroon uitgevoerd terwijl het programma verder gaat. Om de waarde van de operatie op te vragen moet de get methode worden aangeroepen op de future.

Als de waarde wordt opgevraagd van een future terwijl de operatie nog niet is afgerond, dan wordt automatisch gewacht tot de waarde wel beschikbaar is. Als de waarde wel beschikbaar is, dan wordt deze meteen doorgegeven en gaat het programma zonder verdere vertraging verder. Het enige wat we hebben hoeven veranderen aan de business logica is dat waardes via futures worden doorgegeven.

Toch nog bijkomstige complexiteit

Het aanmaken en asynchroon starten van een future zou niet veel omhanden moeten hebben. In Scala is dit dan ook mogelijk zonder veel code:

  val bottom = future { imageService.getBottomLayer }

In Java kan gebruik gemaakt worden van een ExecutorService om de future aan te maken en asynchroon te starten. Deze methode is helaas wel omslachtiger omdat we de aanroep naar de ImageService moeten inpakken in een Callable. Ik heb daarom gekozen voor een decorator om het aanmaken van de futures te abstraheren zodat de businesslogica zo min mogelijk wordt verstoord. Met de submit methode wordt een future aangemaakt en asynchroon gestart.

  class AsynchronousImageService {
    private ImageService imageService;
    private ExecutorService pool = Executors.newFixedThreadPool(5);

    public Future<BufferedImage> getBottomLayer() throws InterruptedException, ExecutionException {
      return pool.submit(new Callable<BufferedImage>() {
        public BufferedImage call() throws IOException {
          return imageService.getBottomLayer();
        }
      });
    }

    // getMiddleLayer en getBottomLayer zijn analoog aan getBottomLayer
  }

Met Java 8 worden hopelijk anonieme functies geïntroduceerd, waarmee we het aanmaken van de Callable kunnen vervangen. Wellicht is het beter de ExecutorService te injecteren en ook met bj het afsluiten van de applicatie de close methode van ExecutorService instantie worden aangeroepen, anders sluit de applicatie niet. Maar als voorbeeld is dit afdoende.

Conclusie

Met gebruik van de hierboven gedefinieerde AsynchronousImageService kunnen we met een paar kleine aanpassingen de originele applicatie veranderen zodat de afbeeldingen asynchroon geladen worden:

    AsynchronousImageService imageService = new AsynchronousImageService(new ImageService());

    // laden
    Future<BufferedImage> bottom = imageService.getBottomLayer();
    Future<BufferedImage> middle = imageService.getMiddleLayer();
    Future<BufferedImage> top = imageService.getTopLayer();

    // stapelen
    ImageStacker imageStacker = new ImageStacker();
    imageStacker.add(bottom.get());
    imageStacker.add(middle.get());
    imageStacker.add(top.get());
    BufferedImage stackedImage = imageStacker.getImage();

Zoals is te zien is de originele code ongewijzigd, met uitzondering van het gebruik van futures en het aanroepen van de get-methode. De synchronisatie wordt door de futures uit handen genomen en de totale wachttijd op het laden van afbeeldingen is teruggebracht tot de wachttijd voor de traagst ladende afbeelding.

Be Sociable, Share!

Reageer


twee + 6 =

RSS feed for comments on this post · TrackBack URI