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.

In Pocket speichern vorlesen Druckansicht 140 Kommentare lesen
Race,Tracks,Ready,For,The,Olympic,Games

(Bild: Menna/Shutterstock)

Update
Lesezeit: 17 Min.
Von
  • Anzela Minosi
Inhaltsverzeichnis

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.

Anzela Minosi

(Bild: 

Anzela Minosi

)

Anzela Minosi arbeitet freiberuflich an Gigs wie dem Erstellen von LibreOffice-Projekten, der Rekompilation von Software, die mit C++, Haskell oder Java geschrieben wurde, und Raspberry Pi Support. Beispiele finden sich in ihrem Github-Account: github.com/amxyz-cyber

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).

Bezüglich des Multithreading bietet Haskell die ausgeprägteste Komplexität (Abb. 1).

(Bild: Anzela Minosi)

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.

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.

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.

Um sich einen aktuellen Überblick über die Umsetzung von Multithreading-basierten Anwendungen zu verschaffen, führten wir Countdown-Tests auf unterschiedlichen Computerarchitekturen durch (siehe Tabellen). Die Compiler stammen aus offiziellen Quellen: Die Python-Interpreter 11 und 12, die beide über den GIL verfügen, installierten wir über die Paketmanager der Distributionen Gentoo und Manjaro, wobei wir zur Ausführung der einzelnen Python-Module, diese lediglich um eine Main-Methode ergänzen.

Zudem installierten wir auf beiden PCs die Java Runtime-Umgebung (JRE) von Liberica, wobei unter Gentoo die Version 20.0.2 und bei Manjaro die Version 17.0.9 zum Einsatz kam. Aufgrund der plattformunabhängigen Eigenschaft von Java wurden die auf Java laufenden Programme lediglich auf dem Intel-Computer kompiliert. Für die Java-Tests erstellten wir ein Kommandozeilentool mit Picocli, um damit alle drei Testrunden auszuführen. Danach erfolgte die Kompilierung mit dem Maven-Plugins Really-executable-jar, sodass sich das Java-Benchmark-Tool ähnlich wie ein Bash-Skript auf der Konsole ausführen ließ (Abbildung 3).

Das Benchmark-Tool für die Java-Tests zeigt eine Übersicht seiner Funktionen in der Hilfe, zum Beispiel coutdown-test (Abb. 3).

(Bild: Anzela Minosi)

Der Countdown-Test auf der Intel-CPU i7 wird mit acht Threads durchgeführt (Abb. 4)

(Bild: Anzela Minosi)

Zur Kompilierung des Haskell-Benchmark-Tools kam auf beiden PCs Stack zum Einsatz, wobei dem GHC-Compiler (Version 9.6.3) weitere Argumente übergeben wurden, um Multithreading zu aktivieren. Hierfür verwendeten wir die Option -threaded, um das Haskell-Benchmark-Tool mit der Threaded-Laufzeitbibibliothek zu verknüpfen. Zusätzlich optimierte das Argument -O2 das Haskell-Benchmark-Tool weiter, indem es das Tool verkleinerte. Dadurch dass sich die Python-Programme, das Java-Benchmarktool sowie das Haskell-Benchmarktool auf der Konsole aufrufen ließen, konnten wir alle Benchmarks mit nur einem Tool, Hyperfine, durchführen (Abbildung 4). In unserer Umgebung führten wir insgesamt 18 Tests verteilt auf beiden Rechnern aus.

Spezifikation Testcomputer #1: PC mit ATX-Miditower
CPU Intel Core i7 870 @ 2.93GHz (8 Kerne / 16 Threads)
Mainboard Asus P7P55D Pro
Chipsatz Intel P55 Express
RAM 16 GByte
Festplatte 500 GByte Seagate ST3500418AS
Grafik GeForce GT 1030
Audio ASUS XONAR SOUND CARD
Netzwerkkarte Realtek 8112L Gigabit LAN
Router TP-Link TL-MR6500v (10/100 Mbps LAN Ports)
Betriebssystem Gentoo
Kernel 6.4.8-gentoo-x86_64
Desktop-Umgebung Xfce4 4.18
Display Server X Server 1.21
Grafikkartentreiber NVIDIA 535.86.05
Dateisystem Ext4
Monitorauflösung 1920x1080
Python-Interpreter 3.12.1
JRE‍ Liberica 20.0.2
GHC-Compiler‍ 9.6.3
Spezifikation Testcomputer #2: Einplatinenrechner
CPU ARM Cortex-A72 @ 1.5GHz (4 Kerne / 4 Threads)
Raspberry Pi Raspberry Pi 4 Model B
RAM 8 GByte
Festplatte Kingston Canvas Select Mikro-SD-Karte 128 GByte
Grafik VideoCore VI @ 500 MHz (GPU 128 MByte)
Audio Waveshare WM8960
Netzwerkkarte Gigabit LAN
Router TP-Link TL-MR6500v (10/100 Mbps LAN Ports)
Betriebssystem Manjaro
Kernel 6.1.69-1-MANJARO-RPI4 (AARCH64)
Desktop-Umgebung Mate
Display Server X Server 1.21
Grafikkartentreiber MESA 25.2
Dateisystem Ext4
Monitorauflösung 1920x1080
Python-Interpreter 3.11.6
JRE‍ Liberica 17.0.9
GHC-Compiler‍ 9.6.3

Vorweg sei gesagt, dass der Performance-Test einer Programmiersprache nicht in völliger Leere stattfinden kann, sondern vom Gesamtsystem, also Bibliotheken, Compilern, Interpretern usw. abhängt. Wir haben versucht, hier eine realistische Umgebung zu schaffen, die einen Vergleich unter quasi lebensechten Bedingungen ermöglicht. Andere Umgebungen können durchaus zu anderen Ergebnissen führen.

Der Countdown-Test bestand darin, von einer großen ganzzahligen Zahl runter auf 0 zu zählen. Um einen repräsentativen Vergleich zu erzielen, nahmen wir pro Compiler jeweils drei Testrunden auf den beiden Testrechnern vor. Dabei wurde die Countdown-Funktion mit den Werten N = 500.000, N = 1.000.000 und N = 10.000.000 getestet. Die Implementation einer auf Multithreading-basierten Countdown-Funktion unterscheidet sich von Programmiersprache zu Programmiersprache.

Unter Python ist es am einfachsten, zunächst eine simple Countdown-Funktion zu erstellen, die solange den Wert einer Variable n um eins reduziert, bis n = 0 ist:

Listing 1: Implementation eines Countdowns unter Python

def countdown(n):
    while n>0:
        n -= 1
        print(n)

Um die Ausführung der Countdown-Funktion zu beschleunigen, kommt die externe Bibliothek Trio-Parallel zum Einsatz:

import trio

async def time_countdown(n):
    async with trio.open_nursery() as nursery:
        computational_result_aiter = nursery.start_soon(trio_parallel.run_sync, countdown,n)

Unserer Einschätzung zufolge handelt es sich bei Trio-Parallel um die einfachste Art, alle CPU-Kerne während der Ausführung voll auszunutzen. Der Countdown-Test wird schließlich durch den folgenden Code ausgelöst:

import trio
import sys
import multiprocessing

counterN1 = 500000
counterN2 = 1000000
counterN3 = 10000000

if __name__ == '__main__':
    multiprocessing.freeze_support()
    trio.run(time_countdown,counterN3)

Im Vergleich zu Python lässt sich unter Java die Countdown-Funktion unter Zuhilfenahme des Schlüsselworts synchronized thread-sicher implementieren, was dazu führt, dass mehrere Threads gleichzeitig zugreifen können:

public class Countdown {
    private long value = 0;
    private volatile boolean cancelled = false;
    
    public Countdown(long n) {
        this.value = n; 
    }
    
    public synchronized long getValue() {
        return value;
    }
    
    public synchronized void decrement() {
        if (! isCancelled()) {
            while (value > 0) {
                this.value--;
                System.out.println("Current value:"+this.value);
            }
            this.cancel();
        } 
    }

    public boolean isCancelled() {
        return this.cancelled;
    }

    private void cancel() {
        this.cancelled = true;
    }

}

Um so viele Tasks zu erstellen, wie die CPU über Kerne verfügt, wird ein Task für den Countdown vom Typ Callable implementiert, dem als Argument ein zuvor initialisiertes Objekt vom Typ Countdown übergeben wird. Das stellt sicher, dass alle Threads auf ein und denselben Counter zurückgreifen:

public class CountingTask implements Callable <CountingTask>{
    private Countdown countdown;
    
    public CountingTask(Countdown c) {
        this.countdown = c;
    }
    
    public CountingTask call() {
            if (!Thread.currentThread().isInterrupted()) {
                this.countdown.decrement();
            }
            return this;
    }

}

Zum Verwalten der asynchronen Tasks kommt die Schnittstelle ExecutorService zum Einsatz, die so viele Callables vom Typ Countdown erstellt, wie an Threads numThreads festgelegt worden sind:

public class CountdownService  {
    // ruft die Zahl der CPU-Kerne ab   
    public static final int MAX_THREADS = Runtime.getRuntime().availableProcessors();
    private ExecutorService executorService;
    private Countdown countdown;
    private int numThreads = 0;
    private Set<Callable<CountingTask>> tasks = Collections.synchronizedSet(new HashSet<Callable<CountingTask>>());
    private List<Future<CountingTask>> futures;
    
    public CountdownService(Countdown c, int n) {
        this.countdown = c;
        this.numThreads = n;
        this.executorService = Executors.newFixedThreadPool(MAX_THREADS);
        this.futures = new ArrayList<Future<CountingTask>>();
    }
    
    // erstellt die Tasks
    public void prepare() {
        for (int i = 0; i < this.numThreads; i++) {
            CountingTask task = new CountingTask(this.countdown);
            this.tasks.add(task);
        }
    }
    
    // initialisiert die Callables
    public void init() {
        if (!this.executorService.isShutdown()) {
            try {
                futures = executorService.invokeAll(tasks);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
   
}

Danach werden die Tasks gleichzeitig durch Aufruf von executorService.invokeAll(tasks) gestartet:

public void startCountdown(int n, int numThreads) {
         Countdown c = new Countdown(n);
         CountdownService countdown = new CountdownService(c, numThreads);
         countdown.prepare();
         countdown.init();
}

Haskell verfolgt einen anderen Ansatz, indem die erforderlichen Funktionen in einem Modul implementiert werden:

module MyCounter () where
...

import Control.Monad (forever)
import Control.Concurrent.STM
import Control.Concurrent.MVar
import Control.Concurrent (forkIO,threadDelay)
import System.Exit as Exit
import qualified Lib as Lib

-- komplexe Datentypen
data MyCounter = MyCounter Int deriving (Eq)
data Countdown = C MyCounter | M MAX | N MIN deriving (Show,Eq)
data MAX = MAX Int deriving (Eq)
data MIN = MIN Int deriving (Eq)
type CounterVar = TVar Countdown

-- vergleicht den Zähler mit dem Infimum oder Supremum
myCompare :: Countdown -> Countdown -> Ordering
(M (MAX n)) `myCompare` (C (MyCounter i))
    | n == i = EQ
    | n < i = LT
    | otherwise = GT
(N (MIN n)) `myCompare` (C (MyCounter i))
    | n == i = EQ
    | n < i = LT
    | otherwise = GT
c@(C (MyCounter _)) `myCompare` m@(N (MIN _)) = m `myCompare` c
c@(C (MyCounter _)) `myCompare` m@(M (MAX _)) = m `myCompare` c

-- erstellt einen neuen Countdown mit einem Infimum oder Supremum 
resetCounter :: Countdown -> IO CounterVar
resetCounter (M (MAX n)) = do
    tstore <- atomically $ newTVar (C (MyCounter n))
    return tstore   
resetCounter (N (MIN n)) = do
    tstore <- atomically $ newTVar (C (MyCounter n))
    return tstore   

-- reduziert den Countdown um eins
decCounter' :: Countdown -> Countdown
decCounter' (C (MyCounter c)) = C (MyCounter (c - 1))

decCounter :: CounterVar -> Int ->  IO ()
decCounter tstore i =  do
    -- Wert aus der gemeinsam genutzten Variable abrufen
    c <- Lib.getTVar tstore
    let min' = N (MIN 0)
        newCounter = decCounter' c
        res = auxCompare min' c
    -- prüfen ob der Countdown das Infimum oder Supremum erreicht hat
    if  (auxCompare min' c == LT )
        then do
          -- neuen Wert in die TVar ablegen 
          Lib.modifyTVar_ tstore newCounter
   -- rekursiver Aufruf von decCounter
          decCounter tstore (Steps (succ n)) i

     -- Ansonsnte decCounter verlassen
        else (putStrLn $ "Finished Thread Number: " ++ (show i)) >> return ()
    where
        auxCompare x y = x `myCompare` y

Der Countdown wird zusammen mit einem Infimum MIN oder Supremum MAX durch Aufruf der Funktion resetCounter initialisiert. Auch das ermöglicht eine thread-sichere Implementierung des Countdowns, indem der initiale Wert des Countdowns in die weiter oben erwähnte TVar des STM-Moduls abgelegt wird. Jeder Thread führt dann solange die Funktion decCounter aus, bis der Wert 0 erreicht wird. Dabei wird zunächst der Wert aus der TVar abgerufen und durch Aufruf der Hilfsfunktion myCompare überprüft, ob der Countdown dem Grenzwert entspricht.

Falls das nicht der Fall ist, erfolgt ein rekursiver Aufruf von decCounter, andernfalls bricht der Thread die weitere Ausführung von decCoutner ab. Schließlich startet der Countdown, indem wir der Funktion countdowntest die Zahl der Threads t sowie N als Argumente übergeben:

import Control.Concurrent.Async (replicateConcurrently_,async,wait,mapConcurrently_)

countdowntest :: Maybe Int -> Maybe Int -> IO ()
countdowntest mn mt = do
    -- mn: N
    -- mt: Zahl der Threads    
    case (mn,mt) of
        (Just n,Just t) -> do
            tstore <- C.resetCounter (C.M (C.MAX n))
            let nList = [1..t]
            mapConcurrently_ (\i -> C.decCounter tstore (C.Steps 0) i) nList
            c <- Lib.getTVar tstore
            putStrLn $ "Countdown from " ++ (show n) ++ " using " ++ (show t) ++ " cores stopped at: " ++ (show c)
        _ -> putStrLn "The countdown command takes exactly two numbers."

Die Liste nList besteht aus der Ziffernfolge 1 bis t, die der Funktion mapConcurrently_ des Moduls Control.Concurrent.Async übergeben wird, um so viele Threads gleichzeitig zu initialisieren, wie nList an Elementen besitzt.

Testergebnisse des Countdown-Tests bezogen auf beide Testrechner (Abb. 5)

(Bild: Anzela Minosi)

In unserer Testumgebung schnitt Haskell auf beiden Testrechnern am schnellsten ab (siehe Tabelle 3), was nicht zuletzt die gemessenen Durchschnittswerte belegen (Abbildung 5).

Durchschnittliche Ausführungszeit aufgeschlüsselt nach Compilern (Abb. 6)

(Bild: Anzela Minosi)

Dass Python in diesem Test besser als Java rangiert, hat maßgeblich damit zu tun, dass die Implementierung von thread-sicheren Variablen einen gewissen Overhead mit sich bringt. Würden bei unserer Umsetzung der Countdown-Funktion unter Python mehrere Clients gleichzeitig zugreifen, würde die Ausführung instabil. Der Boxplot in Abbildung 6 zeigt überdies, dass die Tests unter Haskell über eine minimale Range verfügen, zu sehen an der Länge der Box. Auch ist die Entfernung zu den absoluten Minima und Maxima (siehe obere und untere horizontale Linien) bei Haskell nicht allzu groß, sodass dies von einer gewissen Zuverlässigkeit des Haskell-Compilers zeugt.

Die grüne Linie zeigt den absoluten Durchschnittswert der Messungen pro Compiler an.

Countdown-Test: Vergleich zwischen ARM- und Intel-CPU (Abb. 7)

(Bild: Anzela Minosi)

Unterschiede in Bezug auf die Messwerte ergeben sich darüber hinaus beim Vergleich zwischen Intel- und ARM-CPU. So fällt die durchschnittliche Ausführungszeit deutlich geringer aus, wenn mehr Threads zum Einsatz kommen (Abbildung 7).

Tabelle 3: Testergebnisse: N = 18
N Durchschnitt (Sekunden) Bestwert Höchstwert Std-Abweichung Compiler Threads CPU
500000 0.34600 0.33470 0.36990 0.01050 ghc-9.6.3 2 ARM
1000000 1.07600 0.65400 4.74400 1.28900 ghc-9.6.3 2 ARM
10000000 4.10800 1.32600 6.58200 2.16600 ghc-9.6.3 2 ARM
500000 0.82900 0.76190 0.89240 0.47800 ghc-9.6.3 8 Intel
1000000 1.60700 1.49300 1.74200 0.08200 ghc-9.6.3 8 Intel
10000000 15.81700 15.38200 16.33400 0.27200 ghc-9.6.3 8 Intel
500000 3.47900 3.41200 3.51800 0.03700 openjdk-20.0.2 2 ARM
1000000 5.43400 5.35200 5.54600 0.06600 openjdk-20.0.2 2 ARM
10000000 40.91400 39.87300 42.07300 0.74700 openjdk-20.0.2 2 ARM
500000 1.90200 1.84800 1.99100 0.03800 openjdk-20.0.2 8 Intel
1000000 3.35700 3.26500 3.61200 0.10500 openjdk-20.0.2 8 Intel
10000000 29.16500 28.20000 30.87900 0.90000 openjdk-20.0.2 8 Intel
500000 2.50500 2.45300 2.60100 0.04300 python-3.11.6 1 ARM
1000000 3.58300 3.51500 3.64400 0.04300 python-3.11.6 1 ARM
10000000 23.62200 22.94400 23.98700 0.31400 python-3.11.6 1 ARM
500000 1.19200 1.15800 1.26900 0.03300 python-3.12.1 1 Intel
1000000 1.87100 1.75700 2.43300 0.20400 python-3.12.1 1 Intel
10000000 13.05300 12.30300 16.41200 1.19000 python-3.12.1 1 Intel

Der Countdown-Test zeigt, dass der Haskell-Compiler aufgrund der STM-Technologie im Vergleich zu Java und Python unschlagbar ist. Die Implementierung von gemeinsam genutzten Variablen demonstriert, dass Haskell über eine leserliche und prägnante Syntax verfügt, während unter Java mehr Zeilen an Code erforderlich sind. Der Countdown-Test hat überdies gezeigt, dass die parallele Ausführung von Funktionen auch unter Python flott erfolgt, jedoch ist dort der gleichzeitige Zugriff durch mehrere Clients problematisch. Darüber hinaus verhindert der GIL bis zur Version 12, dass Threads gleichzeitig ausgeführt werden. Das aktuelle Release von Python bietet nun eine experimentelle Variante ohne GIL, die Multithreading erleichtert.

Aufgrund der Diskussion im Forum haben wir die Autorin gebeten, uns die gesamten Haskell-Skripte zur Verfügung zu stellen. Sie betont, dass sämtliche Tests Konsolen-Ausgaben enthielten: "Wenn der Code keine Ausgaben enthielte, dann wären auch bei mir die Tests in 0.NIX durchgelaufen". Wie das folgende Bild verdeutlicht.

Die Konsole-Ausgaben des Haskell-Skripts.

(Bild: Anzela Minosi)

Im Folgenden nun die Skripte:

module MyCounter
    ( 
          countdown
        , resetCounter
        , decCounter
        , decT
        , loopCounter
        , inc
        , MAX(..)
        , MIN(..)
        , MyCounter(..)
        , Countdown(..)
        , Steps (..)
    ) where

import Control.Monad (forever)
import Control.Concurrent.STM
import Control.Concurrent.MVar
import Control.Concurrent (forkIO,threadDelay)
import System.Exit as Exit
import qualified Lib as Lib

data MyCounter = MyCounter Int deriving (Eq)
data Countdown = C MyCounter | M MAX | N MIN deriving (Show,Eq)
data MAX = MAX Int deriving (Eq)
data MIN = MIN Int deriving (Eq)
data Steps = Steps Int deriving (Eq)
type CounterVar = TVar Countdown

instance Show MyCounter where
    show (MyCounter i) = show i
    
instance Show MAX where
    show (MAX i) = show i   
    
instance Show MIN where
    show (MIN i) = show i  
    
instance Show Steps where
    show (Steps i) = "Modified counter " ++ (show i) ++ " times."    

myCompare :: Countdown -> Countdown -> Ordering
(M (MAX n)) `myCompare` (C (MyCounter i))
    | n == i = EQ
    | n < i = LT
    | otherwise = GT
(N (MIN n)) `myCompare` (C (MyCounter i))
    | n == i = EQ
    | n < i = LT
    | otherwise = GT
c@(C (MyCounter _)) `myCompare` m@(N (MIN _)) = m `myCompare` c
c@(C (MyCounter _)) `myCompare` m@(M (MAX _)) = m `myCompare` c

resetCounter :: Countdown -> IO CounterVar
resetCounter (M (MAX n)) = do
    tstore <- atomically $ newTVar (C (MyCounter n))
    return tstore   
resetCounter (N (MIN n)) = do
    tstore <- atomically $ newTVar (C (MyCounter n))
    return tstore     


decCounter' :: Countdown -> Countdown
decCounter' (C (MyCounter c)) = C (MyCounter (c - 1))

incCounter' :: Countdown -> Countdown
incCounter' (C (MyCounter c)) = C (MyCounter (c + 1))

inc :: CounterVar -> IO ()
inc tstore = do
	 v <- Lib.getTVar tstore
	 let newValue = incCounter' v
	 Lib.modifyTVar_ tstore newValue
	

decT :: CounterVar -> IO ()
decT r = do 
    v <- Lib.getTVar r
    let min' = N (MIN 0)
        newCounter = decCounter' v
    putStrLn $ show newCounter
    atomically $ (
        do
          check (myCompare min' v == LT )
          writeTVar r newCounter 
          --decT r
          )   
 
decCounter :: CounterVar -> Steps -> Int ->  IO ()
decCounter tstore s@(Steps n) i =  do
    c <- Lib.getTVar tstore
    let min' = N (MIN 0)
        newCounter = decCounter' c
        res = auxCompare min' c
    if  (auxCompare min' c == LT )
        then do
          Lib.modifyTVar_ tstore newCounter
          decCounter tstore (Steps (succ n)) i
        else (putStrLn $ "Finished Thread Number: " ++ (show i)) >> return ()
    where
        auxCompare x y = x `myCompare` y

countdown :: CounterVar -> Int -> IO ()            
countdown tstore 0 = return ()
countdown tstore n  = do
    forkIO $ decCounter tstore (Steps 0) n
    countdown tstore (n-1)
        
loopCounter :: CounterVar -> Int -> IO () 
loopCounter _ 0 = return ()
loopCounter tstore n = do
	c <- Lib.getTVar tstore
	putStrLn $ "Current value: " ++ (show c)
	loopCounter tstore (n-1)
	
module MyCLI
    ( 
        dispatch
    ) where

import qualified MyCounter as C
import qualified Lib as Lib


import qualified Data.Text as T
import Control.Concurrent.Async (replicateConcurrently_,async,wait,mapConcurrently_)
import Text.Read (readMaybe)
import GHC.Conc (numCapabilities)
import Control.Monad (forever)
import Control.Concurrent (forkIO,threadDelay)
import System.Random (mkStdGen,getStdGen,StdGen)
import Data.List (take)

lastBits :: Int
lastBits = 10

threshold :: Int
threshold = 100000

localHost :: Server.MyHost
localHost = Server.MyHost "127.0.0.1"

maxConns :: C.MAX
maxConns  = C.MAX 4096

dispatch :: String -> [String] -> IO ()
dispatch "countdown-test" args = countdownTest args
dispatch command args = doesntExist command args

doesntExist :: String -> [String] -> IO ()
doesntExist command _ = 
    putStrLn $ "The " ++ command ++ " command doesn't exist."


countdownTest :: [String] -> IO ()
countdownTest (nString:[]) = do
    let mn = readMaybe nString :: Maybe Int
    auxCountdowntest mn Nothing
countdownTest (nString:numString:[]) = do
    let mn = readMaybe nString :: Maybe Int
        mt = readMaybe numString :: Maybe Int
    auxCountdowntest mn mt
countdownTest _ = putStrLn "The countdown command takes exactly two arguments." 

auxCountdowntest :: Maybe Int -> Maybe Int -> IO ()
auxCountdowntest mn mt = do
    case (mn,mt) of
        (Just n,Just t) -> do
            tstore <- C.resetCounter (C.M (C.MAX n))
            r <- async $ C.loopCounter tstore n
            let nList = [1..t]
            mapConcurrently_ (\i -> C.decCounter tstore (C.Steps 0) i) nList
            c <- Lib.getTVar tstore
            putStrLn $ "Countdown from " ++ (show n) ++ " using " ++ (show t) ++ " cores stopped at: " ++ (show c)
        (Just n,_) -> do
            tstore <- C.resetCounter (C.M (C.MAX n))
            r <- async $ C.loopCounter tstore n
            let nList = [1..numCapabilities]
            mapConcurrently_ (\i -> C.decCounter tstore (C.Steps 0) i) nList
            c <- Lib.getTVar tstore
            putStrLn $ "Countdown from " ++ (show n) ++ " using " ++ (show numCapabilities) ++ " cores stopped at: " ++ (show c)
        _ -> putStrLn "The countdown command takes exactly two numbers."    


module Main (main) where

import System.Environment (getArgs)

main :: IO ()
main = do
	(command:argList) <- getArgs
	CLI.dispatch command argList
module Lib
    ( 
          modifyTVar_
        , getTVar
        , async 
        , printList
        , finiteRandoms
        , toString
    ) where

import Control.Monad (forever)
import Control.Concurrent.STM
import Control.Concurrent (forkIO)
import System.IO.Unsafe (unsafePerformIO)
import System.Random
import Data.List (intercalate)
import qualified Data.Text as T

data Async a = Async (TVar a)

--async :: IO a b -> TVar a -> b -> Int -> IO String
--async :: (IO a b) -> a -> b -> [Char] -> IO [Char]
async :: (Show c,Show b) => (a -> b -> IO ()) -> a -> b -> c -> IO ()
async action tstore x n = do
            tId <- forkIO ( do
                    r <- action tstore x
                    putStrLn $ "Finished Thread Number: " ++ (show n) ++ " :: "++(show r)
                    )
                   
            return ()
    

modifyTVar_ :: TVar a -> a -> IO ()
modifyTVar_ tv newVal = do
    atomically $ writeTVar tv newVal

-- gets the variable stored in a tv variable
getTVar :: TVar b -> IO b
getTVar tv  = do
    store <- atomically $ readTVar tv
    return store

getTvalue :: TVar (Maybe T.Text) -> String
getTvalue tstore = case (unsafePerformIO $ getTVar tstore) of
                        Just mytext -> T.unpack mytext
                        _ -> ""

printList :: (Show a) => [a] -> IO ()
printList xs = mapM_ print xs

toString :: (Show a) => [a] -> String
toString xs = intercalate ", " (map show xs)

finiteRandoms :: (RandomGen g, Random a, Num n, Eq n) => n -> g -> ([a],g)
finiteRandoms 0 gen = ([],gen)
finiteRandoms n gen =
    let (value,newGen) = random gen
        (restOfList, finalGen) =  finiteRandoms (n-1) newGen
    in (value:restOfList,finalGen)

Update

Aufgrund der Hinweise im Forum haben wir einen Nachtrag mit den kompletten Haskell-Skripten und einem Bild der Konsole-Ausgaben angefügt. Ferner gab es noch eine kleine Korrektur in der prepare-Methode im Java-Code.

(who)