Um das Konzept der Nebenläufigkeit zu integrieren, wurde Haskell um zwei wesentliche Inhalte erweitert:
Da diese Grundlagen auf dem Konzept der EA-Monads basieren, soll dieses zunächst kurz angerissen werden. Als eine detaillierte Quelle zu Monads sei auf das Seminar Monads von Nils Decker verwiesen. Darüberhinaus ist im Literaturverzeichnis weiterführende Literatur zu finden.
Haskell ist eine nicht-strikte Programmiersprache, d.h. es kann in einem Ausdruck nicht im Vornherein bestimmt werden, welcher Teilausdruck zuerst ausgewertet bzw. ob ein Teilausdruck überhaupt ausgewertet wird. Die Auswertung ist vom Kontext abhängig und kann daher nicht vorausgesagt werden. Diese Spracheigenschaft ist für die Durchführung von EA gänzlich ungeeignet. Es kann z.B. passieren, dass eine für das Schreiben von Daten in einen Ausgabekanal wichtige Leseoperation erst nach der Schreiboperation ausgeführt wird.
Diesem Problem begegnet man, in dem EA-Operationen als Zustandsveränderungen behandelt werden. Dabei wird unterschieden zwischen dem internen Zustand des Programms und dem externen Zustand in der Umgebung, der auch als "Rest der Welt" bezeichnet wird. Eine EA-Operation macht nun nichts anderes, als den externen Zustand zu verändern und ein Ergebnis dieser Operation zurückzuliefern. Durch Einsatz von EA-Monads ist es möglich, diese EA-Operationen zusammenzufassen und zu sequenzialisieren. Diese Ideen führen zu folgender Typdefinition von EA-Operationen:
type IO a = World -> (a, World)
Hier ist beschrieben, dass eine EA-Operation vom Typ IO a
einen externen Zustand (World) als Input nimmt und einen veränderten Zustand mit einem Typ a
wieder zurückliefert. Ein Wert vom Typ IO a
wird dabei als Aktion bezeichnet.
Zwei einfache Aktionen, die als Beispiel gegeben werden sollen sind:
hGetChar :: Handle -> IO Char hPutChar :: Handle -> Char -> IO ()
Diese Aktionen können durch die Infix-Kombinatoren >>
oder >>=
miteinander kombiniert werden, sodass z.B. folgendes Programm einen Char als Input nimmt und ihn dann auf dem Bildschirm wieder ausgibt. Die Infix-Operatoren stellen die Grundlage für die Sequenzialisierung von Anweisungen dar. Alternativ kann auch die do
-Notation zur Sequenzialisierung verwendet werden.
oder:hGetChar stdin >>= \c -> hPutChar stdout c
do c <- hGetChar stdin hPutChar stdout c
Bei der Kommunikation von Prozessen, die mit Hilfe von veränderbaren Zustandsvariablen durchgeführt wird, wird ebenfalls mit Aktionen gearbeitet, durch die diese Variablen verändert werden. Das ist notwendig, weil diese Variablen global für alle Prozesse zugänglich sein sollen. Für einen Prozess ist also diese Zustandsvariable zum "Rest der Welt" zuzurechnen, genauso, wie die anderen Prozesse aus Sicht des einen Prozesses dem "Rest der Welt" zuzurechnen sind.
newMVar :: IO (MVar a) takeMVar :: MVar a -> IO a putMVar :: MVar a -> a -> IO ()
Ein Wert vom type MVar a
kann man sich als ein Ort vorstellen, in dem ein Wert vom Typ a
gespeichert ist. Da dieser Ort zum externen Zustand also zum "Rest der Welt" gehört, findet der Ansatz mit EA basierend auf Monads hier Anwendung. Mit putMVar
und takeMVar
wird der Wert, der an dem Ort abgespeichert ist, verändert.
Die EA-Monads sind ein wichtiger Bestandteil der Sprache Haskell. Auch Programme liefern einen Wert vom Typ IO ()
und machen insofern nichts anderes, als den "Zustand der Welt" zu verändern.
Um einen Prozess zu starten, wurde die neue Funktion
forkIO
in Concurrent Haskell bereitgestellt.
Der Prozess selbst ist kein zusätzlicher Prozess im Betriebssystem, sondern wird vom Haskell-Laufzeitsystem verwaltet.
forkIO :: IO () -> IO ()
forkIO a
ist eine Aktion, die eine Aktion a
als ihr Argument
nimmt und einen konkurrierenden Prozess startet, um
die übergebene Aktion auszuführen. EA und andere
Seiteneffekte, die von der Aktion a
durchgeführt werden, werden in einer unspezifizierten Weise mit den verflochten, die
dem forkIO
folgen.
Hierzu ein Beispiel:
main = forkIO (write 'a') >> write 'b'
where write c =
putChar c >> write c
forkIO
erzeugt einen Prozess, der die Aktion write 'a'
ausführt. Der Vaterprozess führt nach dem forkIO
die Aktion write 'z'
aus, sodass eine unspezifizierte Reihenfolge der Buchstaben 'a'
und 'z'
auf dem Bildschirm erscheint.
Im Zusammenhang
mit forkIO
gilt es, folgende Dinge zu beachten:
forkIO
funktioniert nur
dann einwandfrei, wenn die zugrundeliegende Implementierung von Haskell
Interprozesskommunikation unterstützt. Dies ist notwendig, weil Haskellprogramme nicht-strikt ausgewertet werden. Versucht ein Prozess, einen Teilausdruck auszuwerten, der bereits von einem anderen Prozess ausgewertet wird, muss der erste Prozess blockiert werden, bis der andere seine Auswertung beendet hat. Diese Funktionalität muss von der Haskell-Implementierung gesichert werden.forkIO
Nichtdeterminismus eingeführt. Denn durch forkIO
wird es möglich, dass ein Prozess eine
Datei lesen will, während ein anderer Prozess diese löscht. Der Effekt auf den
Ablauf des Programms ist nicht mehr vorhersehbar. Die Lösung dieses Problems
ist ein Mechanismus, der eine sichere Zustandsveränderung von geteilten
Ressourcen ermöglicht (siehe Abschnitt 2.3).forkIO
ist
asymmetrisch , da es einen Prozess startet, der konkurrierend zum fortlaufenden Vaterprozess läuft. Es gibt eine Funktion symFork
, die zwei Prozesse startet die symmetrisch sind und deren Ergebnis zusammengeführt wird, wenn beide Prozesse beendet sind. Da dieser Ansatz jedoch mithilfe von MVars (siehe Abschnitt 2.3) simuliert werden kann, wird in dieser Ausführung nicht darauf eingegangen.
forkIO
erzeugte Prozess besitzt keinen Namen, wie es in Betriebssystemen normalerweise für Prozesse üblich ist. Es gibt daher keine Möglichkeit, einen Prozess zu zerstören oder auf seine Beendigung zu warten. Durch MVars
(siehe Abschnitt 2.3) wird es allerdings möglich, auf die Beendigung eines Prozesses zu warten. Die Zerstörung eines Prozesses ist jedoch weiterhin nicht möglich.Man könnte vermuten, dass forkIO
für die Implementierung von Nebenläufigkeit
alleine bereits ausreicht, vorausgesetzt, die Haskell-Implementierung stellt die Synchronisation zwischen zwei Prozessen sicher, die versuchen, den gleichen Teilausdruck auszuwerten. Die Prozesskommunikation könnte dann über Streams (z.B. in Form von genügend großen Listen) erfolgen, in den ein Prozess hineinschreibt und ein anderer die Daten wieder ausliest. Die folgenden Gründe zeigen jedoch, dass zusätzliche Mechanismen zur Synchronisation und Kommunikation notwendig sind:
Zur Lösung dieser Anforderungen an Synchronisation und Kommunikation wird ein neuer primitiver Typ eingeführt:
type MVar a
Ein Wert vom Typ MVar t
mit t
für einen beliebigen Typ,
ist der Name einer veränderbaren Stelle, die entweder leer ist oder einen Wert
vom Typ t
enthält. Für MVars
werden folgende
primitive Operationen zur Verfügung gestellt:
newMVar :: IO (MVar a)Liefert eine neue
MVar
.
takeMVar :: MVar a -> IO aBlockiert bis die Variable nicht leer ist, liest anschließend den Wert und liefert diesen zurück. Die Stelle wird leer hinterlassen.
putMVar :: MVar a -> a -> IO ()Schreibt einen Wert in an die spezifizierte Stelle. Gibt es durchtakeMVar
blockierte Prozesse, wird einer deblockiert. Zu beachten gilt, dass ein Fehler ausgelöst wird, wenn versucht wird, die Operation auf eine bereits volle Stelle anzuwenden [1]. Es handelt sich also beitakeMVar
undputMVar
nicht um symmetrische Operationen, obwohl der symmetrische Ansatz aus Sicht des Sprachdesigns vernünftig wäre.
Der Typ MVar
kann aus folgenden Blickwinkeln betrachtet werden:
putMVar
äquivalent zum Senden und takeMVar
äquivalent zum Empfangen.
putMVar
und takeMVar
realisiert werden.