Haskell Erweiterungsanforderungen


... [ Informatik und Master-Seminar SS2003 ] ... [ HWS Gesamtübersicht ] ... [ Web Server Implementierung ] ...

Übersicht: Haskell Erweiterungsanforderungen


Input und Output

Input / Output (dt.: Eingabe / Ausgabe) ist bei Programmen wie einem Web Server - aber auch bei allen anderen Programmen, die von einem menschlichen Benutzer verwendet werden - notwendig. In diesem Zusammenhang, wird in Haskell zwischen Funktionen und Aktionen unterschieden. Funktionen sind reine Funktionen (engl.: pure functions) ohne Seiteneffekten und Aktionen sind Funktionen mit Seiteneffekten. Hierbei kann man unter Seiteneffekten die Interaktion mit der Aussenwelt (z.B. Benutzer, Bildschirm, Tastatur, Datei schreiben/verändern etc.) verstehen, d.h. Seiteneffekte sind letztendlich Input/Output. Anders formuliert, kann man auch sagen, dass Aktionen IO durchführen und Funktionen kein IO durchführen.
In der ersten Version von Haskell wurden diesbezüglich zwei Algebraische Datentypen Request (Output / Ausgabe) und Response (Input / Eingabe) wie folgt realisiert:
type FilePath = String
data Request = ReadFile FilePath
             | WriteFile FilePath String
             | ...
data Response = RequestFailed
             | ReadSucceeded String
             | WriteSucceeded
             | ...
Jedoch hat diese Implemenierung folgende Schwächen (Nachteile):
Eine bessere Umsetzung sind die IO Monaden, bei denen IO a eine Aktion ist die - vor der Rückgabe des Wertes vom Typ a - Input/Output durchführen kann.
Ein einfaches Beispiel zur Verdeutlichung:
type IO a = World -> (a, World)
In dieser Definition wird ein Input vom Typ World von der Aktion IO a bearbeitet und gibt eine veränderte World mit dem Ergebnis a zurück.

Eine spezielle Syntax für Monaden ist durch die "do Notation" gegeben, die praktischer ist, weil man nicht den Operator (>>=) und die Lambda Funtion verwenden muss.
Ein Beispiel:
getTwoChars :: IO (Char, Char)
getTwoChars = do { c1 <- getChar ;
                   c2 <- getChat ;
                   return (c1, c2)
              }
Es ist jedoch nur Syntax, die vom Compiler wieder in die Form mit dem Operator (>>=) übersetzt wird.

Eine weitere Anforderung zur Realisierung von einem Web Server (und anderen praktischen Programmen) wären Kontrollstrukturen wie z.B. for-, while-Schleifen etc.
Man kann eine Endlosschleife mit dem (>>) Kombintator implementieren:
forever :: IO () -> IO ()
forever a = a >> forever a
Damit wiederholt sich (forever a) durch rekursives Aufrufen von forever (sich selbst).
Hierbei nimmt forever, genauso wie die Kombinatoren (>>) und (>>=), eine Aktion als ein Argument, wodurch Kontrollstrukturen realisiert werden können.
Als nächstes gäbe es die for-Schleife:
for :: [a] -> (a -> IO ()) -> IO () 
for []     fa = return ()
for (n:ns) fa = fa n >> for ns fa
(for ns fa) wendet hier die Funktion fa auf jedes Element von ns an und liefert jedesmal eine Aktion, die in eine Liste eingefügt wird.
Man kann for auch auf eine andere Weise definieren:
for ns fa = sequence_ (map fa ns)

sequence_ :: [IO a] -> IO ()
sequence_ as = foldr (>>) (return ()) as
In dieser Definition wendet  map  auf jedes Element von ns die Funktion fa an und gibt eine Liste von Aktionen aus. Dann bildet  sequence_  eine Sequenz aus dieser Liste von Aktionen.
Hinsichtlich der Definition von sequence_ ist noch anzumerken, dass das "_" in "sequence_" darauf hinweist, dass die Ergebnisse der Sub-Aktionen weggeworfen werden und nur () ausgegeben wird (d.h. die Ergebnisse sind in dieser Funktion nicht relevant). sequence_ hat eine verwandte Funktion sequence, die eine Liste von Aktionen nimmt und dabei die einzelnen (Sub-)Ergebnisse vom Typ a liefert und diese als eine Komponente vom Typ [a] zurück gibt:
sequence :: [IO a] -> IO [a]
sequence []     = return []
sequence (a:as) = do { r <- a ;
                       rs <- sequence as ;
                       return (r:rs) }
Eine weitere Funktionalität von imperativen Programmiersprachen stellen die Variablen dar, die in Haskell in Form von  IORefs  umgesetzt wurden (MVars sind eine synchronisierte Form von IORefs und werden im Abschnitt Concurrency behandelt):
data IORef a               -- An abstract type
newIORef   :: a -> IO (IORef a)
readIORef  :: IORef a -> IO a
writeIORef :: IORef a -> a -> IO ()
IORef a  ist eine Referenz, die auf eine variable (engl.: mutable) Speicherstelle verweist in der ein Wert vom Typ a enthalten ist. Mit  newIORef  kann eine neue Speicherstelle (Variable) mit einem initialen Wert erstellt werden. Diese Speicherstelle (Variable) kann mit  readIORef  bzw.  writeIORef  gelesen bzw. beschrieben werden.
IORef  wird oft dazu benutzt den Zustand eines externen Objektes (Objekt von Aussenwelt) zu erfassen bzw. zu verfolgen. Hierzu bietet Haskell98 folgende Funktionen zum Öffnen, Lesen und Schreiben von Dateien:
openFile :: String -> IOMode -> IO Handle
hPutStr  :: Handle -> [Char] -> IO ()
hGetLine :: Handle -> IO [Char]
hClose   :: Handle -> IO ()
Wenn man feststellen will wieviel Zeichen (Chars) aus einer Datei gelesen bzw. in eine Datei geschrieben worden sind, kann man das dadurch erreichen, indem man  hPutStr  und  hGetLine  eine Variable (IORef) inkrementieren lässt. Dazu braucht man ein modifiziertes Handle:
type HandleC = (Handle, IORef Int)
Damit muss man eine modifizierte Variante von  openFile  erzeugen die eine Variable beim Öffnen einer Datei erstellt und ein  HandleC  zurück gibt. Dann können  hPutStr  und  hGetLine  in modifizierter Form dieses  HandleC  nehmen und die Variable (IORef) entsprechend verändern.
Beispiel:
openFileC :: String -> IOMode -> IO HandleC
openFileC fn mode = do { h <- openFile fn mode ;
                         v <- newIORef 0 ;
                         return (h,v) }
hPutStr :: HandleC -> String -> IO ()
hPutStr (h,r) cs = do { v <- readIORef r ;
                        writeIORef r (v + length cs) ;
                        hPutStr h cs }
Bis dahin wurden die IO Monads als Abstrakte Datentypen (ADTs) dargestellt. Jedoch haben die Abstrakte Datentypen folgende Eigenschaften, die je nach Sichtweise entweder verhindern oder erlauben (ermöglichen):
Aufgrund der Restriktionen von IO Monads, gibt es auch unsichere (engl.: unsafe) IO Aktionen deren zeitliche Ausführung nicht relevant ist, d.h. zu unvorhersehbaren (nicht festgelegten) Zeitpunkt oder überhaupt nicht ausgeführt werden.
Beispiel:
unsafePerformIO :: IO a -> a

configFileContents :: [String]
configFileContents = lines (unsafePerformIO (readFile "config"))
Diese Art von Operation bringt logischerweise Gefahren mit sich und sollte deshalb nicht viel genutzt werden bzw. nur von System- bzw. Bibliothekenprogrammierern benutzt werden, d.h. von Leuten die wirklich wissen was sie tun und sich der Gefahren bewusst sind.


Concurrency (multi-threading)

Wenn man einen Web Server programmieren will, kommt man nicht an der Problematik von Concurrency (Ablauf von mehreren Prozessen / Threads zur gleichen Zeit) und der damit verbundenen Synchronisation (welcher Prozess darf auf welche Ressource zur welchen Zeit zugreifen) vorbei, weil es in der Eigenschaft / Funktionsweise eines Servers festgelegt ist das mehrere Benutzer bzw. mehrere Operationen gleichzeitig behandelt bzw. durchgeführt werden können.
Um mehrere Threads zu ermöglichen, braucht man die Funktion forkIO, die neue Threads erzeugt.
Beispiel anhand der main Schleife des Servers:
acceptConnections :: Config -> Socket -> IO ()
acceptConnections config socket = forever ( do { conn <- accept socket ;
                                                 forkIO (serviceConn config conn) } )
Hierbei wird accept endlos vom Eltern-Thread aufgerufen und für jeden eingehenden Request ein neuer Thread erzeugt, der diesen dann bearbeitet. accept gibt ein Handle zurück, mit dem der Server mit dem Client kommunizieren kann.
accept :: Socket -> IOConnection
type Connection = ( Handle, SockAddr )
forkIO :: IO a -> IOThreadId
Der neue Thread erhält eine IO Aktion (der zu bearbeitende Request) als Parameter (serviceConn config conn) und läuft dann parallel zum Eltern-Thread. forkIO gibt einen Identifier (ThreadId) des neuen Threads zurück (siehe asynchrone Exceptions -> Fehlererkennung).
forkIO kann aber (wie unsafePerformIO) gefährlich sein, weil zwei oder mehrere Threads gleiche Aktionen (z.B. Ausgabe am Bildschirm / Datei lesen/schreiben, etc.) zur gleichen Zeit ausführen können.
Um diese Probleme zu beheben, braucht man einen Synchronisationsmechanismus, der im Fall von Haskell in Form von MVar realisiert wurde. MVar ist hierbei eine synchronisierte Version von IORef, d.h. eine synchronisierte Referenz auf eine Variable die nur von einem Thread zur gleichen Zeit in-/dekrementiert werden kann. Die Funktionsweise ist wie bei Semaphoren: will ein Thread eine Ressource schreiben (lesen) wird die Ressource vor Zugriff anderer Threads blockiert, bis dieser Thread die Ressource wieder freigibt.
data MVar a
newEmptyMVar :: IO (MVar a)
takeMVar     :: MVar a -> IO a
putMVar      :: MVar a -> a -> IO ()
Mit newEmptyMVar kann man ein MVar erzeugen, wobei dieses leer ist (im Gegensatz zu IORef). takeMVar holt (liest) den Wert aus der MVar heraus und macht es leer. putMVar fügt einen Wert (schreibt) in die MVar ein. Wenn die erzeugte MVar leer ist, wird takeMVar solange blockiert bis ein Wert mit putMVar eingefügt wurde.
Beispiel: Zähler (count) in der Server Schleife
acceptConnections :: Config -> Socket -> IO ()
acceptConnections config socket = do { count <- newEmptyMVar ;
                                       putMVar count 0 ;
                                       forever ( do { conn <- accept socket 
                                                      forkIO ( do { inc count ;
                                                                    serviceConn config conn;
                                                                    dec count } )
                                                     } ) }
                                  inc,dec :: MVar Int -> IO () 
                                  inc count = do { v <- takeMVar count; putMvar count (v+1) }
                                  dec count = do { v <- takeMVar count; putMVar count (v-1) }
Wenn wir nun mehrere Threads laufen lassen bzw. erzeugen können, wird eine Kommunikation zwischen den Threads notwendig, die im Falle des Servers mehreren Client-Threads erlaubt (Error-)Log-Einträge in eine (Error-)Log-Datei einfügen zu können. In Haskell wurde hierzu Channel implementiert, das mehreren Threads erlaubt darin reinzuschreiben bzw. daraus herauszulesen:
type Channel a = ( MVar (Stream a)    -- Read end
                   MVar (Stream a) )  -- Write end (hole: empty MVar = end of Channel)
newChan :: IO (Channel a)
putChan :: Channel a -> a -> IO ()
getChan :: Channel a -> IO a
Channel Illustration  
Channel wird als ein Paar von MVars definiert (MVars mit dickeren Rändern gekennzeichnet), die das Lese-Ende (read end) und Schreib-Ende (write end) eines Channels bilden. Um das Schreiben/Lesen mehrerer Threads zu ermöglichen, gibt es zwischen jedem Wert (das in einem Item steht) ein MVar das nur den jeweiligen Wert (ent)blockiert und nicht den ganzen Channel.
type Stream a = MVar (Item a)
data Item a = MkItem a (Stream a)
newChan = do { read <- newEmptyMVar ;
               write <- newEmptyMVar ; 
               hole <- newEmptyMVar;
               putMVar read hole ;
               putMVar write hole ;
               return (read,write) }
putChan (read,write) val = do { new_hole <- newEmptyMVar ;
                                old_hole <- takeMVar write ;
                                putMVar write new_hole ;
                                putMVar old_hole (MkItem val new_hole) }
getChan (read,write) = do { head_var <- takeMVar read ;
                            MkItem val new_head <- takeMVar head_var ;
                            putMVar read new_head ;
                            return val }
Item besteht aus dem ersten Element eines Streams und den Rest des Streams. Stream ist eine Liste von alternierenden Item und MVars, die mit einem leeren MVar (hole) endet. Das Schreib-Ende (write end) vom Channel (ein MVar) verweist auf dieses "hole".
Damit braucht man zur Erstellung eines neuen Channel nur ein "read" und ein "writeMVar, als auch ein (leeres) MVar für den Stream selbst.
Um in den Channel reinschreiben zu können, muss man einen neuen, leeren Stream (new_hole) erzeugen und anschliessend das neue mit dem vorhandenen (old_hole) ersetzen. Abschliessend, muss man nur noch ein Item in das old_hole einsetzen, womit es vom hole zum Item geworden ist. Anders ausgedrückt: Man erweitert den Channel mit einem neuen hole und fügt ein Item ins old_hole ein, d.h. transformiert es zum Item.
Das Lesen aus dem Channel funktioniert nach gleichem Prinzip: Erzeugen von neuen Item (neues "read end") und ersetzen durch nachfolgendes Item. Es ist aber zu beachten, dass beim zweiten Aufruf von takeMVar (2.Zeile von getChangetChan blockieren kann, wenn der Channel leer ist (bis es wieder aufgefüllt wurde durch einen anderen Thread).
Durch diese Definition des Channel, ist ein sicheres Lesen/Schreiben von mehreren Threads zur gleichen Zeit gewährleistet. Die Werte werden nach der Ankunftsreihenfolge der Threads in den Channel reingeschrieben und jeder Wert wird von jeweils einem Thread rausgelesen.
Man kann in Haskell auch Multi-cast Channel realisieren (d.h. mehrere Leser können alles im Channel lesen), indem man eine neue Operation implementiert:
dubChan :: Channel a -> IO (Channel a)
dubChan (read,write) = do { new_read <- newEmptyMVar ;
                            hole <- readMVar write ; 
                            putMVar new_read hole ;
                            return (new_read, write) }
Hierbei muss man aber auch eine neue Lese-Operation implementieren, die zwar aus dem Channel rausliest, aber das gelesene im Channel zurücklässt (d.h. der Channel bleibt im vollständigen Zustand).
readMVar :: MVar a -> IO a
readMVar var = do { val <- takeMVar var ;
                    putMVar var val ; 
                    return val ;


Fehlererkennung und -behandlung

Als nächste Anforderung an Programme ist die Robustheit bzw. Fehlerbewältigung (-behandlung) des Programms, da niemand ein Programm benutzen will das bei irgendwelchen auftretenden Fehlern bzw. unvorhersehbaren Ereignissen, die in der realen Welt immer wieder stattfinden, abstürtzt.
Ein Web Server sollte deshalb trotz folgender Ereignisse weiter arbeiten (funktionieren) können:
Der Server sollte sich von diesen Ereignissen erholen und weiterhin seine Dienste (engl.: services) an die vorhandenen bzw. neuen Benutzer anbieten.
Um Fehlerbehandlungen dieser Art zu bewältigen, wurden Exceptions in Haskell98 implementiert, so dass ein beliebig grosser Source Code von einem Exception Handler umfasst werden kann und eine Erholung von einem Fehler an irgendeiner Stelle im Code gewährleisten kann.
IO Monaden bieten hierzu eine einfache Form der Fehlerbehandlung, indem jede IO Operation bei einem Fehler eine Exception auslösen kann und diese von einem Handler aufgefangen werden kann.
Beispiele aus Haskell98:
userError :: String -> IOError
ioError   :: IOError -> IO a
catch     :: IO a -> (IOError -> IO a) -> IO a
Man kann in diesem Fall eine Exception auslösen, indem man ioError mit dem Argument IOError aufruft. Mit userError kann man einen IOError aus einem String erstellen und mit catch kann man eine Exception auffangen.(catch a h) ist eine Aktion, die versucht die Aktion a auszuführen um anschliessend ein Ergebnis zu liefern. Wenn aber a eine Exception auslöst, dann wird a abgebrochen und stattdessen (h e) zurückgegeben, wobei e der IOError in der Exception ist.
Beispiel anhand der main-Schleife vom Web Server:
acceptConnections :: Config -> Socket -> IO ()
acceptConnections config socket = forever ( do { conn <- accept socket ;
                                                 forkIO (service conn) } )
                                where
                                   service :: Connection -> IO ()
                                   service conn = catch (serviceConn config conn) 
                                                        (handler conn)
                                   handler :: Connection -> Exception -> IO ()
                                   handler conn e = do { logError config e ;
                                                         hClose (fst conn) }
Der neue Thread (service conn) hat hier einen Exception Handler, so dass im Falle einer ausgelösten Exception der handler aktiv wird. Der handler schreibt den Fehler in die Log Datei (durch senden einer Nachricht zum error-logging Thread über einen Kanal (engl.: channel)) und schliesst den Verbindungshandler h.
Die Haskell98 Exceptions haben aber zwei Schwächen (Nachteile):
Das Problem beim rein funktionalem Code besteht in der Lazy Evaluation von Haskell, d.h. Auswertung von Ausdrücken in einer nicht festgelegten Reihenfolge. Dabei gibt es zwei Schwierigkeiten:
Ob eine Exception ausgelöst wird, kann dadurch gelöst werden, indem man die Werte in aussergewöhnliche und normale Werte unterteilt, so dass throw e aussergewöhnliche Werte (engl.: exceptional values) zurück gibt (bei IEEE sind NaN: "not-a-numbers" und NaT: "not-a-thing" für Hardware definiert). Und welche Exception ausgelöst wird (bei mehreren Exceptions), hängt letztendlich vom Compiler ab (s. lazy evaluation in Haskell).

Um asynchrone Exceptions ausführen zu können, braucht man folgende Aktion:
throwTo :: ThreadId -> Exception -> IO ()
Damit kann ein Thread einen anderen Thread unterbrechen, wobei dieser die ThreadId braucht. Die ThreadId wird von forkIO geliefert, so dass eigentlich nur der ElternThread das Kind unterbrechen kann, es sei denn es gibt die ThreadId des Kindes an andere Threads weiter.
Beispiel mit throwTo:
parIO :: IO a -> IO a -> IO a
parIO a1 a2 = do { m <- newEmptyMVar ;
                   c1 <- forkIO (child m a1) ;
                   c2 <- forkIO (child m a2) ;
                   r <- takeMVar m ;
                   throwTo c1 Kill ;
                   throwTo c2 Kill ;
                   return r  }
             where
                child m a = do { r <- a ; putMVar m r }
parIO lässt zwei Threads mit jeweils einer separaten Aktion seiner Argumente gleichzeitig laufen und wartet bis einer der beiden zuerst fertig wird. Wenn ein Thread fertig ist, wird der andere Thread von parIO durch ein Kill Signal beendet.
Man kann mit parIO ein einfaches timeout implementieren:
timeout :: Int -> IO a -> IO (Maybe a)
timeout n a = parIO ( do { r <- a ; return (Just r) } )
                    ( do { threadDelay n ; return Nothing } )
(timeout n a) liefert Nothing zurück, wenn die Aktion a länger als n Mikrosekunden dauert, ansonsten gibt es Just r zurück, wobei r der von a gelieferteWert ist.
Nun gibt es aber auch externe Interrupts, z.B. Control-C, die zu behandeln sind, wobei sich die Frage stellt ob dabei alle Threads oder nur ein bestimmter Thread terminiert wird bzw. wie der bestimmte Thread festgestellt wird. Eine allgemeine Antwort gibt es nicht, sondern es hängt an dem/der Programmierer(in) wie er/sie ein Programm implementiert. Jedoch ist es in Haskell festgelegt, dass bei einem externen Interrupt der Thread zum entsprechenden catch Handler springt. Um den bestimmten Thread aber zu beenden, ist eine entsprechende Modifizierung von parIO notwendig.


... [ Informatik und Master-Seminar SS2003 ] ... [ HWS Gesamtübersicht ] ... [ Haskell Erweiterungsanforderungen ] ... [ Web Server Implementierung ] ...