Coroutinen, virtuelle Threads und Structured Concurrency

Parallele Programmierung (3IB)

Prof. Dr.-Ing. Sandro Leuchter
Hochschule Mannheim, Fakultät für Informatik
Wintersemester 2024/2025

 

Dieses Werk ist lizenziert unter einer Creative Commons „Namensnennung – Nicht-kommerziell – Weitergabe unter gleichen Bedingungen 4.0 International“ Lizenz.

Überblick

  • Theorie
    • Subroutinen (“Methoden”)
    • Coroutinen
    • Fibers (“virtuelle Threads”)
  • Praxis
    • Fibers in Dart
    • Java-Neuerungen: “Project Loom”
      • Virtual Threads, Thread-API, Structured Concurrency

Theorie: Coroutinen und Fibers im Allgemeinen

Unterprogramme: Subroutinen, Coroutinen/Fibers, Threads

  • Unterprogramme gibt es in unterscheidlicher Form
    • Subroutinen: geschachtelt aufrufbar
    • Threads (run-Methode): stehen für sich
  • Fibers (“virtuelle Threads”) sind beidem ähnlich:
    • zu Subroutinen: im Aufrufer-Thread ausgeführt
    • zu Threads: nebenläufige Programmteile

Subroutine

  • geschachtelte Aufrufe von Subroutinen (andere Namen: “Methoden-”, “Funktionsaufruf”)
    • Aufruf: Parameterwerte und lokale Variablen werden oben auf dem Stack abgelegt
    • Ende einer Subroutine: Parameterwerte und lokale Variablen der Subroutine vom Stack entfernen, Rückgabewert auf Stack ablegen (optional)
  • damit steht auf jeder Ebene der Aufrufhierarchie jeweils der richtige Ausführungskontext (Parameterwerte, lokale Variablen, Rückgabewert des letzten Subroutinenaufrufs) oben auf dem Stack zur Verfügung
int fac(int n, int acc) {
    if (n == 1) return acc;
    return fac(n - 1, acc * n);
}

System.out.println(fac(4, 1));

Stack:

Call Stack ist vor dem Aufruf leer

Subroutine

  • geschachtelte Aufrufe von Subroutinen (andere Namen: “Methoden-”, “Funktionsaufruf”)
    • Aufruf: Parameterwerte und lokale Variablen werden oben auf dem Stack abgelegt
    • Ende einer Subroutine: Parameterwerte und lokale Variablen der Subroutine vom Stack entfernen, Rückgabewert auf Stack ablegen (optional)
  • damit steht auf jeder Ebene der Aufrufhierarchie jeweils der richtige Ausführungskontext (Parameterwerte, lokale Variablen, Rückgabewert des letzten Subroutinenaufrufs) oben auf dem Stack zur Verfügung
int fac(int n, int acc) {
    if (n == 1) return acc;
    return fac(n - 1, acc * n);
}

System.out.println(fac(4, 1));

Stack:

fac(4,1): die beiden Parameter + Platz für den Rückgabewert

Subroutine

  • geschachtelte Aufrufe von Subroutinen (andere Namen: “Methoden-”, “Funktionsaufruf”)
    • Aufruf: Parameterwerte und lokale Variablen werden oben auf dem Stack abgelegt
    • Ende einer Subroutine: Parameterwerte und lokale Variablen der Subroutine vom Stack entfernen, Rückgabewert auf Stack ablegen (optional)
  • damit steht auf jeder Ebene der Aufrufhierarchie jeweils der richtige Ausführungskontext (Parameterwerte, lokale Variablen, Rückgabewert des letzten Subroutinenaufrufs) oben auf dem Stack zur Verfügung
int fac(int n, int acc) {
    if (n == 1) return acc;
    return fac(n - 1, acc * n);
}

System.out.println(fac(4, 1));

Stack:

Rekursionsschritt: fac(4-1, 1*4)

Subroutine

  • geschachtelte Aufrufe von Subroutinen (andere Namen: “Methoden-”, “Funktionsaufruf”)
    • Aufruf: Parameterwerte und lokale Variablen werden oben auf dem Stack abgelegt
    • Ende einer Subroutine: Parameterwerte und lokale Variablen der Subroutine vom Stack entfernen, Rückgabewert auf Stack ablegen (optional)
  • damit steht auf jeder Ebene der Aufrufhierarchie jeweils der richtige Ausführungskontext (Parameterwerte, lokale Variablen, Rückgabewert des letzten Subroutinenaufrufs) oben auf dem Stack zur Verfügung
int fac(int n, int acc) {
    if (n == 1) return acc;
    return fac(n - 1, acc * n);
}

System.out.println(fac(4, 1));

Stack:

Rekursionsschritt: fac(3-1, 4*3)

Subroutine

  • geschachtelte Aufrufe von Subroutinen (andere Namen: “Methoden-”, “Funktionsaufruf”)
    • Aufruf: Parameterwerte und lokale Variablen werden oben auf dem Stack abgelegt
    • Ende einer Subroutine: Parameterwerte und lokale Variablen der Subroutine vom Stack entfernen, Rückgabewert auf Stack ablegen (optional)
  • damit steht auf jeder Ebene der Aufrufhierarchie jeweils der richtige Ausführungskontext (Parameterwerte, lokale Variablen, Rückgabewert des letzten Subroutinenaufrufs) oben auf dem Stack zur Verfügung
int fac(int n, int acc) {
    if (n == 1) return acc;
    return fac(n - 1, acc * n);
}

System.out.println(fac(4, 1));

Stack:

Rekursionsschritt: fac(2-1, 12*2)

Subroutine

  • geschachtelte Aufrufe von Subroutinen (andere Namen: “Methoden-”, “Funktionsaufruf”)
    • Aufruf: Parameterwerte und lokale Variablen werden oben auf dem Stack abgelegt
    • Ende einer Subroutine: Parameterwerte und lokale Variablen der Subroutine vom Stack entfernen, Rückgabewert auf Stack ablegen (optional)
  • damit steht auf jeder Ebene der Aufrufhierarchie jeweils der richtige Ausführungskontext (Parameterwerte, lokale Variablen, Rückgabewert des letzten Subroutinenaufrufs) oben auf dem Stack zur Verfügung
int fac(int n, int acc) {
    if (n == 1) return acc;
    return fac(n - 1, acc * n);
}

System.out.println(fac(4, 1));

Stack:

Rekursionsanker: Rückgabe von acc

Subroutine

  • geschachtelte Aufrufe von Subroutinen (andere Namen: “Methoden-”, “Funktionsaufruf”)
    • Aufruf: Parameterwerte und lokale Variablen werden oben auf dem Stack abgelegt
    • Ende einer Subroutine: Parameterwerte und lokale Variablen der Subroutine vom Stack entfernen, Rückgabewert auf Stack ablegen (optional)
  • damit steht auf jeder Ebene der Aufrufhierarchie jeweils der richtige Ausführungskontext (Parameterwerte, lokale Variablen, Rückgabewert des letzten Subroutinenaufrufs) oben auf dem Stack zur Verfügung
int fac(int n, int acc) {
    if (n == 1) return acc;
    return fac(n - 1, acc * n);
}

System.out.println(fac(4, 1));

Stack:

Rückgabe der Rückgabe von acc

Subroutine

  • geschachtelte Aufrufe von Subroutinen (andere Namen: “Methoden-”, “Funktionsaufruf”)
    • Aufruf: Parameterwerte und lokale Variablen werden oben auf dem Stack abgelegt
    • Ende einer Subroutine: Parameterwerte und lokale Variablen der Subroutine vom Stack entfernen, Rückgabewert auf Stack ablegen (optional)
  • damit steht auf jeder Ebene der Aufrufhierarchie jeweils der richtige Ausführungskontext (Parameterwerte, lokale Variablen, Rückgabewert des letzten Subroutinenaufrufs) oben auf dem Stack zur Verfügung
int fac(int n, int acc) {
    if (n == 1) return acc;
    return fac(n - 1, acc * n);
}

System.out.println(fac(4, 1));

Stack:

Rückgabe der Rückgabe der Rückgabe von acc

Subroutine

  • geschachtelte Aufrufe von Subroutinen (andere Namen: “Methoden-”, “Funktionsaufruf”)
    • Aufruf: Parameterwerte und lokale Variablen werden oben auf dem Stack abgelegt
    • Ende einer Subroutine: Parameterwerte und lokale Variablen der Subroutine vom Stack entfernen, Rückgabewert auf Stack ablegen (optional)
  • damit steht auf jeder Ebene der Aufrufhierarchie jeweils der richtige Ausführungskontext (Parameterwerte, lokale Variablen, Rückgabewert des letzten Subroutinenaufrufs) oben auf dem Stack zur Verfügung
int fac(int n, int acc) {
    if (n == 1) return acc;
    return fac(n - 1, acc * n);
}

System.out.println(fac(4, 1));

Stack:

Rückgabe der Rückgabe der Rückgabe der Rückgabe von acc

Coroutine

  • auch ein “Unterprogramm”
  • ist unterbrechbar an bestimmten Punkten
    • nach Unterbrechung geht es direkt nach dem Unterbrechungspunkt der aufrufenden Coroutine weiter
    • lokale Variablen und Aufrufparameter der aufrufenden Coroutine sind nach Unterbrechung wieder mit ihren ursprünglichen Werten verfügbar
  • Coroutinen müssen deshalb ihren eigenen Kontext in der Regel über einen eigenen Call Stack verwalten (denn sie könnten selber wieder Subroutinen aufrufen, deren Aufrufparameter/Rückgabewerte auf einem Call Stack gespeichert werden müssen)
  • Unterbrechungspunkt: in vielen Programmiersprachen call-with-current-continuation (oft abgekürzt als call/cc) oder yield
    Achtung: Bedeutung von yield in Java anders!

Das hat erstmal nichts mit paralleler Programmierung zu tun!

Coroutine

Stoll (1985)

Fiber (“virtueller Thread”)

  • Coroutinen mit kooperativem Multitasking
  • nach der Unterbrechung wird nicht zu einer vom Aufrufer bestimmten Coroutine gesprungen, sondern ein Scheduler bestimmt zu welcher Coroutine als nächstes
    • dieser Scheduler muss ein eigener sein, also unabhängig vom Scheduler des Betriebssystems, der zwischen Threads wechselt
  • nebenläufige Code-Teile werden jeweils in eigene Coroutinen verschoben, die selbst bestimmen, an welchen Stellen sie unetrbrochen werden

Das ist Nebenläufigkeit ohne Parallelität!

  • daher: implizite Synchronisation vorhanden -
    kein gegenseitiger Ausschluss für konkurrierenden Zugriff auf gemeinsame Variablen erforderlich!
  • aber: Blockierung kann durch I/O passieren
    in dem Fall: yield zum Scheduler wünschenswert

Vergleich Fiber mit Subroutine

Subroutine

  • Unterprogramm

  • kann geschachtelte Unteraufrufe von Subroutinen beinhalten

  • wird grundsätzlich immer am Anfang begonnen

  • wird immer “zum Ende” verlassen (bei jedem return)

  • Kontext wird auf Call Stack des Aufrufer-Threads gespeichert

Coroutinen/Fibers

  • Unterprogramm
  • kann geschachtelte Unteraufrufe von Subroutinen beinhalten
  • erster Aufruf am Anfang, danach weiterarbeiten am Punkt des letzten Verlassens
  • kann an unterschiedlichen Punkten unterbrochen werden (yield)
  • jede Coroutine benötigt eine eigene Verwaltung ihres Kontexts

Vergleich mit Threads

Threads

  • Nebenläufige Programmteile
  • Zugriff auf geteilte Variablen im Heap
  • eigener Stack, threadlokale Variablen
  • Scheduling deligiert an Betriebssystem
  • mglw. parallele Ausführung (wenn es mehrere CPU’s gibt)
  • Koordination konkurrierenden Zugriffs auf Speicher erforderlich (da Inkonsistenzen bei nicht-atomaren Zugriffen drohen) \(\to\) synchronized, Locks, Atomics

Coroutinen/Fibers

  • Nebenläufige Programmteile
  • Zugriff auf geteilte Variablen im Heap
  • eigener Stack, Fiber-lokale Variablen
  • durch Anweisung kontrolliertier Wechsel zwischen Kontexten
  • Ausführung mehrer Kontexte im selben Thread
  • Koordination konkurrierenden Zugriffs auf Speicher nicht erforderlich, da Wechsel zwischen Kontexten nur an gewollten Punkten möglich \(\to\) implizite Synchronisation

Umsetzung in Dart1







Dart Future

Future<void> main() async {
  countSecondsAloud(6);
  print(await getData());
}

void countSecondsAloud(int s) {
  for (var i = 1; i <= s; i++) {
    Future.delayed(Duration(seconds: i), () => print(i));
  }
}

Future<String> getData() async {
  return Future.delayed(Duration(seconds: 3), () => '4711');
}
1
2
3
4711
4
5
6
  • Dart Future: Wert (oder Fehler), der asynchron berechnet wird
  • async und await Schlüsselworte
  • Future.delayed erzeugt ein (Dart-) Future, das nach einer vorgegebenen Verzögerung läuft.
  • Future.value(...) führt ... asynchron aus

Dart Future

Verkettung wie bei CompletableFuture (in Java):

Future.value(fetchData())
  .then((data) => filterRelevantData(data))
  .then((relData) => fitData(relData))
  .then((fittedData) => print(fittedData))

Streams

  • asynchrones Iterable
  • Generator Function mit Schlüsselwort async*
  • nach jedem next() ist Wechsel zur nächsten Fiber (z.B. eine andere Generator Function) möglich
Stream<int> timedCounter(Duration interval, [int? maxCount]) async* {
  int i = 0;
  while (true) {
    await Future.delayed(interval);
    yield i++;
    if (i == maxCount) break;
  }
}

Stream<T> streamFromFutures<T>(Iterable<Future<T>> futures) async* {
  for (final future in futures) {
    var result = await future;
    yield result;
  }
}

asynchrone I/O

Ähnlich wie bei Node.js gibt es eine zentrale Ereignis-Warteschlange, die zyklisch geupdated wird.

Alle I/O-Operationen sind asynchron:

import 'dart:io';

Future<void> main() async {
  var file = File('/etc/passwd');

  var users = await file.readAsString();
  print(users);
}

asynchrone I/O

import 'dart:convert';
import 'dart:io';

Future<void> main() async {
  final process = await Process.start('ls', ['-l']);
  final lineStream = process.stdout
      .transform(const Utf8Decoder())
      .transform(const LineSplitter());

  await for (final line in lineStream) {
    print(line);
  }

  await process.stderr.drain();
  print('exit code: ${await process.exitCode}');
}

Umsetzung in Java

Project Loom

  • OpenJDK 20 enthält Project Loom, teils noch als Preview-Feature
  • einige Neuerungen, u.a.
    • Thread Builder API
    • zentral und ganz tief in der JVM: Continuation ermöglicht Coroutinen

Virtual Threads (“Fibers”)

  • aber etwas anders: Continuation wird in einem Threadpool ausgeführt
    • frühere (inoffizielle) JVM-Umbauten bspw. Apache Commons Javaflow1
  • “klassische” Threads heißen nun “Kernel Thread” oder “Platform Thread”
  • verspricht sehr wenig Overhead
    • ermöglicht sehr viele Virtual Threads
    • geringe Spawn-Zeitdauer
  • im Moment aber noch entäuschende Start-Up Zeiten (Flaschenhals: Threadpool)

Project Loom

  • OpenJDK 20 enthält Project Loom, teils als Preview-Feature
  • einige Neuerungen, u.a.
    • Thread Builder API
    • zentral und ganz tief in der JVM: Continuation ermöglicht Coroutinen

Structured Concurrency

  • Aufspalten und Vereinigen innerhalb eines Code-Blocks
  • soll Virtual Threads nutzen, um “massenhafte Nutzung” zu erlauben
  • soll 1:1-Modellierung der Parallelität der Domäne ermöglichen
  • u.a. StructuredTaskScope
    • ähnlich einem Executor
    • enthält ThreadFactory
    • zum leichten Umsetzen von UML-Fork/-join

StructuredTaskScope

StructuredTaskScope

public class Exec<T> {
  static <T> T race(List<Callable<T>> tasks) 
            throws InterruptedException, ExecutionException {
                
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
      for (var task : tasks) {
        scope.fork(task);
      }
      return scope.join().result();
    }
  }

  public static void main(String... args)
      throws InterruptedException, TimeoutException, Throwable {
    var tasks = new ArrayList<Callable<BigInteger>>();
    for (var i=1; i < Runtime.getRuntime().availableProcessors(); i++) {
      tasks.add(() -> BigInteger.probablePrime(2048, ThreadLocalRandom.current()));
    }
    System.out.println(race(tasks));
  }
}

Thread Builder API

  • Erzeugen aller Threadarten ab jetzt mit Factories
Runnable runnable = () -> System.out.println(Thread.currentThread());
        
var virtualThreadFactory = Thread.Builder.Virtual.factory();
var kernelThreadFactory = Thread.Builder.factory();

var virtualThread = virtualThreadFactory.newThread(runnable);
var kernelThread = kernelThreadFactory.newThread(runnable);

virtualThread.start();
kernelThread.start();
  • u.a. Executors.defaultThreadFactory()

Thread Builder API

Anwendung des Builder Patterns:

var t1 = Thread.ofVirtual()
            .name("Mein Thread")
            .allowSetThreadLocals(true)
            .uncaughtExceptionHandler(
                (t e) -> /*...*/
            )
            .unstarted(runnable)
            .start();
  • ofVirtual() liefert Thread.Builder.OfVirtual
    • ein Builder (für “fluent Interface”)
  • keine Methode für Priority!
  • unstarted() liefert dann Thread (quasi build-Methode)
  • daran kann man start() aufrufen
var t2 = Thread.Builder.OfVirtual
            .start(runnable);          
  • start(runnable) liefert Thread (ebenfalls build-Methode)
var t3 = Thread.Builder.OfPlatform
            .start(runnable);          
var t4 = Thread.ofPlatform()
            .start(runnable);
  • Thread.Builder.OfPlatform ist Builder für konventionelle Threads

Referenzen

Stoll, Jürgen. 1985. Ein Werkzeug für die Systemprogrammierung von Realzeitsystemen auf der Basis von MODULA-2. In:, 39–48. https://dl.gi.de/bitstream/handle/20.500.12116/22088/1985_39-48.pdf?sequence=1&isAllowed=y (zugegriffen: 16. November 2021).