Multithreading-Benchmark: Haskell schlägt Java und Python
Warum Haskell? Obwohl Java und Python weitaus beliebter sind, zeigen Benchmarks, dass Haskell beim Multithreading einen deutlichen Vorsprung erzielt.
![Race,Tracks,Ready,For,The,Olympic,Games](https://heise.cloudimg.io/width/610/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/4/5/6/7/3/8/4/shutterstock_88148767-f95f553f464d9549.jpeg)
(Bild: Menna/Shutterstock)
- Anzela Minosi
Im März 2024 rangiert Python im Tiobe Programming Community Index wie viele Monate zuvor auf Platz 1, Java hingegen nimmt den vierten Platz ein. Haskell spielt eher am Rand mit und kommt gerade einmal auf den 30. Platz. Unser Multithreading-Benchmark-Test wird allerdings offenbaren, dass Haskell wenigstens bei der Nebenläufigkeit zu Unrecht vernachlässigt wird.
Die Bedeutung von Multithreading
In der Programmierung wird zwischen parallelen Berechnungen (Parallelism) und gleichzeitigen ausführenden Tasks (Concurrency beziehungsweise Multithreading) unterschieden. Beide Techniken haben unterschiedliche Einsatzgebiete: Parallele Programme erzielen Schnelligkeit durch intensiven Gebrauch von Hardware-Ressourcen, insbesondere mehrerer CPU-Kerne. Ein Programm kommt schneller ans Ziel, indem es die Berechnung in mehrere Teile aufteilt, die es an die einzelnen CPU-Kerne delegiert. Die parallele Programmierung zeichnet sich vor allem durch die Effizienz der Berechnung aus, wodurch beispielsweise Sortieralgorithmen profitieren.
Im Gegenzug dazu handelt es sich bei Multithreading beziehungsweise Concurrency um eine Technik, die auf den Einsatz von mehreren Threads basiert. Konzeptionell betrachtet, laufen die Threads zur selben Zeit ab, das heißt, dass Benutzer deren Auswirkungen überlappend wahrnehmen. Ob sie tatsächlich alle zur selben Zeit ausgeführt werden, ist eine Frage der Implementierung. Ein auf Multithreading basierendes Programm lässt sich auf einem einzelnen CPU-Kern betreiben, indem es die Ausführung verschachtelt, oder auf mehrere Kerne oder physische CPUs verteilt. Multithreading kommt vor allem dort zu tragen, wo eine Interaktion des Programms mit mehreren externen Agenten gewünscht ist, die unabhängig voneinander handeln. Beispielsweise basieren Zugriffe mehrerer externer Clients auf einen Datenbank-Server auf Multithreading. Ein weiterer Vorteil des Multithreadings ist die Modularität solcher Programme. Denn ein Thread, der mit Nutzern interagiert, unterscheidet sich von dem, der mit der Datenbank spricht. Entwicklerinnen und Entwickler müssten ohne Multithreading beispielsweise Client/Server-Anwendungen mit Ereignisschleifen und Rückverweisen schreiben, was deutlich mühseliger ist. Die Implementierungen von Concurrency beziehungsweise Parallelität im Allgemeinen unterscheiden sich von Sprache zu Sprache stark, hinsichtlich der Mechanismen, die zum Einsatz kommen, und der sich daraus ergebenden Komplexität (Abbildung 1).
(Bild: Anzela Minosi)
Python und die Sache mit GIL
Der im Januar 2023 unterbreitete Vorschlag PEP 703 an das Python Steering Council, den Global Interpreter Lock (GIL), der das Multithreading erschwert, optional anzubieten, ist in der Python-Community auf ein breites Echo gestoßen. Und tatsächlich gibt es mit Version 3.13 zwei Builds: Der Standard wird einige Jahre lang weiterhin mit GIL ausgeliefert, wohingegen der Experimental-Build ohne GIL daherkommt.
GIL sorgt dafür, dass der Python-Interpreter lediglich einen Thread ausführt. Bei Single-Threaded-Programmen wirkt sich das nicht negativ auf die Performance aus, allerdings kann er zu einem Flaschenhals mutieren, sobald CPU-lastige Berechnungen oder Multithreading eine Rolle spielen. Der GIL existiert bei Python lediglich aus einem Grund: Python nutzt zur Speicherverwaltung einen Referenzzähler für alle erstellten Python-Objekte. Dieser zählt die Zeiger, die auf ein Objekt verweisen. Sobald er den Wert Null erreicht, wird der Speicher, den das Objekt belegt, freigegeben. GIL dient als Zugriffssperre, um Race Conditions zu vermeiden, bei denen zwei Threads den Wert des Referenzzählers modifizieren. So verhindert Python Deadlocks. GIL macht de facto aus jedem CPU-lastigen Programm ein Single-Threaded-Programm, was für Single-Threaded-Programme sogar eine Performance-Steigerung bedeutet, da lediglich eine Zugriffssperre erforderlich ist.
Weiter unten wird anhand eines Beispiels dargestellt, wie mit Multiprocessing anstelle von Threads Prozesse zum Einsatz kommen, um die parallele Verarbeitung von Teilen eines Python-Programms trotz GIL umzusetzen. Dabei bekommt jeder Prozess seinen eigenen Python-Interpreter sowie Speicherplatz.
Darüber hinaus existieren weitere Alternativen, um den GIL zu umgehen. Beispielsweise lässt sich der Python-Standardinterpreter CPython ersetzen: Der Fork nogil verzichtet auf GIL. Anschließend kann im eigenen Python-Modul die Threading-Bibliothek importiert werden, um das Programm mit mehreren Threads auszuführen. Diese Methode hat jedoch auch Nachteile, denn nogil basiert auf einer veralteten Python-Version. Zudem lassen sich Bibliotheken von Drittanbietern, die von GIL abhängen, nicht ohne Weiteres installieren oder importieren. Und Code, der mit einem Thread auskommt, arbeitet sogar langsamer.
Eine weiter Methode sind in C geschriebene, über das Foreign Function Interface (FFI) eingebundene Erweiterungsmodule. Das FFI ermöglicht eine Kommunikation mit C-Bibliotheken. Der Code, der auf den Einsatz von mehreren Threads basiert, wird als C-Erweiterungsmodul ausgelagert und mit einem C-Compilers wie gcc kompiliert. Das Resultat ist eine geteilte C-Bibliothek mit der Endung .so. Unter Python lässt sich nun durch den Import der Ctypes-Bibliothek zusammen mit der Threading-Bibliothek der Aufruf einer C-Funktion durch mehrere Threads umsetzen.
Multithreading in Java
Die Programmiersprache Java ist traditionell stärker auf Multithreading ausgerichtet als Python, denn jede Java-Anwendung setzt auf Threads. Sobald die Java Virtual Machine (JVM) startet, erstellt sie Threads, um ihren Pflichten – Garbage Collection, Finalisierung – nachzukommen sowie einen Haupt-Thread, der die Main-Methode ausführt. Darüber hinaus machen Frameworks wie die GUI-Rendering-Bibliotheken JavaFX oder Swing intensiven Gebrauch von Threads, um die Ereignisse an der Benutzerschnittstelle zu verwalten. Daneben gibt es weitere Klassen wie der Timer, um Threads zum Ausführen von verzögerten Aufgaben zu erstellen. Die Thread-Sicherheit unter Java macht es unmöglich, dass ein Objekt in einen illegalen oder inkonsistenten Zustand gerät, unabhängig von den Methodenaufrufen und dem Scheduler des Betriebssystems. Im Vergleich zu Python verfügt Java über eine Vielzahl an Werkzeugen, die eine thread-sichere Implementierung von Multithreading-Anwendungen erlauben. Beispielsweise lassen sich Variablen, auf die mehrere Threads gleichzeitig zugreifen, mit dem Schlüsselwort volatile
thread-sicher umsetzen. Bevor eine Funktion auf eine mit volatile
markierte Variable zugreift, muss deren Wert aus dem Hauptspeicher neu eingelesen werden. Genauso achtet Java beim Modifizieren dieser Variable darauf, dass nach Beendigung des Schreibprozesses der Wert in den Hauptspeicher zurückgeschrieben wird. Üblicherweise spielen volatile Variablen bei Client/Server-Anwendungen eine Rolle, um damit Run-until-shutdown-Pattern zu implementieren. Auf diese Weise signalisiert ein Client einem laufenden Thread, den aktuell zu bearbeitenden Job zu beenden, damit der Thread ordnungsgemäß heruntergefahren wird.
Blöcke oder Methoden hingegen lassen sich mittels des Schlüsselworts synchronized
gegen die Inkonsistenz von Daten absichern. Dadurch wird der Zugriff auf den Code innerhalb eines Blocks oder Methode eingeschränkt. Der kooperative Mechanismus, der sich hinter synchronized
verbirgt, setzt sogenannte Monitore als Zugriffssperren ein. Dabei handelt es sich um ein Token, über das jedes Java-Objekt verfügt. Sobald ein Thread Zugriff auf den Block erlangt, beschlagnahmt er den Monitor für sich, führt den Block aus und gibt den Monitor wieder frei. Der nächste Thread, der Zugriff auf den Block erhalten möchte, wird so lange blockiert, bis der Monitor-Holder die Zugriffssperre freigibt.
Das gleichzeitige Ausführen von Code kann unter Java mit der Thread-Klasse implementiert werden, die seit der allerersten Version des Compilers existiert. Gemessen an der Ausführungseinheit, sind Threads im Vergleich zu Prozessen leichtgewichtiger und können dennoch beliebigen Java-Code ausführen. Threads sind Teil jedes beliebigen Prozesses, dessen Adressraum von den Threads gemeinsam genutzt wird. Jeder Thread lässt sich unabhängig voneinander vom Scheduler einplanen und verfügt über einen eigenen Stack und Programmzähler, teilt sich jedoch den Speicher und die Objekte mit anderen Threads desselben Prozesses.
In Summe erlaubt Javas Thread-Model, dass unterschiedliche Threads in einem Prozess Objekte einfach gemeinsam nutzen können, wobei jeder Thread Objekte verändern kann, auf die er verweist. Allerdings ist es für Entwicklerinnen und Entwickler nicht immer einfach, die Zugriffssperren korrekt zu benutzen, und der Zustand gemeinsam geschützter Ressource ist ziemlich gefährdet, auch an unerwarteten Stellen wie ein simpler Lesezugriff. Zudem entscheidet der Scheduler des Betriebssystems darüber, welche Threads gerade laufen.
Haskells Antwort auf Multithreading
Haskell scheint wie geschaffen für Mulithreading zu sein, denn deren Schöpfer haben die üblichen Konzepte neu durchdacht. Haskell ist von Natur aus parallel und verfügt über drei Formen des Multithreading, die sich etwas vereinfacht in zwei Kategorien teilen lassen. Zum einen setzt Haskell Threads ein. Streng genommen stellt ein Thread unter Haskell eine IO-Aktion dar, die unabhängig von den anderen Threads ausgeführt wird. Zum anderen macht Haskell Gebrauch von Parallelität, die anders realisiert wird als ein thread-basiertes Programm.
Die häufigsten Techniken, die zur Umsetzung von Multithreading unter Haskell verwendet werden, sind MVars, Channels sowie Software Transactional Memory (STM). Eine MVar ist im Fachjargon eine thread-sichere Variable, die den Austausch an Informationen zwischen zwei Threads ermöglicht. Sie verhält sich im laufenden Betrieb wie eine Box für lediglich ein Element und kann entweder voll oder leer sein. In sie lässt sich etwas hineinlegen oder herausnehmen. Sobald ein Thread versucht, einen Wert in eine belegte MVar zu legen, wird der Thread in den Schlaf versetzt, bis ein anderer Thread den Wert herausnimmt. Ähnlich verhält sich eine leere MVar: Wird versucht, etwas aus einer leeren MVar zu entnehmen, muss der Thread warten, bis ein anderer Thread darin etwas ablegt.
Haskell setzt verstärkt auf STM, eine Weiterentwicklung von MVar, das eine Abstraktionsebene für Nebenläufigkeit bietet. Operationen lassen sich gruppieren und als singuläre atomare Operation Transaction ausführen. Es ist deutlich weniger problematisch und kennt keine Deadlocks oder Race Conditions. Die Variablen unter STM sind vom Typ TVar, wobei STM ähnlich wie unter Java auf einen ganzen Block angewandt wird. Ein Vorteil, den MVar gegenüber STM hat, ist die Fairness, mit der Threads abgearbeitet werden. Hierzu wendet MVar das First-in-first-out-Prinzip an, was bedeutet, dass Threads der Reihe nach auf die MVar zugreifen.
In Haskell sind die meisten parallelen Programme deterministisch, liefern also reproduzierbare Ergebnisse. Um eine Funktion zu parallelisieren, kommen die Methoden aus dem Modul Control.Parallel
zum Einsatz. Dadurch lassen sich gängige Multithreading-Probleme wie Deadlocks und Race Conditions vermeiden.