TCP Chat Server in C mit select()
Autor: Andre Adrian
Version 10.Juni.2007
Zusammenfassung
Beim Internet Relay Chat (IRC) schreibt ein Teilnehmer eine Textzeile
welche alle anderen Teilnehmer lesen können. Die Chat Client
Programme sind über TCP mit dem Chat Server Programm verbunden.
Der Server verwaltet für jeden Client eine eigene TCP Verbindung.
Ein einfaches, kurzes Chat Server Programm in C für Linux und
für MS-Windows wird vorgestellt. Am
Beispiel werden die Themenbereiche Funktionale Programmierung,
Fehlerbehandlung (Error Handling), Binär-Strings in C und
natürlich die Kombination aus Socket Schnittstelle und select()
besprochen.
Ein Streifzug durch die Themengebiete Zustand-Übergang-Diagramm
(state transition diagram), ereignisgesteuertes Modell (event driven
programming), Transaktion, wohl definierter Zustand (invariant) und ein
bißchen System Analyse und Design bilden den theoretischen Rahmen.
Der Text ist geschrieben für Informatik Studenten (Vorlesung
Betriebssysteme)
und Junior System Programmierer.
Inhaltsverzeichnis
Funktionale
Programmierung (FP)
Die Programmiersprache C gehört zu den imperativen
Programmiersprachen. Die Programmiersprachen Erlang und Haskell sind
funktionale Programmiersprachen. Hinter C steht als geistiges Modell
die Turing-Maschine von Alan Turing. Typische
Programmelemente sind die Prozedur mit Call-by-reference Parametern.
Bei FP ist es das Lambda-Kalkül von Alonzo
Church mit der Funktion. Beide Ansätze sind gleich mächtig -
oder gleich beschränkt.
Bei der FP wird das Ergebnis einer reinen Funktion nur durch die
Eingangsparameter der Funktion bestimmt. Bei der imperativen
Programmierung bestimmen oft globale oder statische Variablen neben den
Eingangsparametern das Ergebnis der Funktion.
Die Programmiersprache C erlaubt Funktionen. Der Chat Server ist -
soweit es C zuläßt - im Sinne der funktionalen
Programmierung geschrieben worden. Es werden keine globalen oder
statischen Variablen benutzt. Die "State" Information liegt nur in
lokalen Variablen der Funktionen. Das Programm ist nicht
"state less", sondern "State minimiert". Der State ist die aufsummierte
relevante Vergangenheit des Programms oder das Gedächtnis des
Programmes.
Bemerkung: Das TCP Protokoll selbst hat "states".
Nur das UDP Protokoll ist state less.
Die Vermeidung von globalen und statischen Variablen macht ein C
Programm nicht zu einem Erlang oder Haskell Programm. Es macht das
Programm aber "lokaler" oder "Schnittstellen
minimierter". Und minimale Schnittstellen, am besten auf allen
Abstraktionsebenen, führen zu robusten Programmen. Je kleiner die
Abhängigkeit zwischen den Elementen (Funktionen, Prozeduren,
Threads, Prozessen), umso einfacher ist das Debugging. Die
kombinatorische Explosion der Testfälle ergibt sich aus zuviel
"State" bzw. aus zuviel Wechselwirkung zwischen den Elementen.
Relevante
Zustandswechsel, Invariante, Transaktion
Bei imperativen Programmiersprachen ändert die Ausführung
jeder Quelltextzeile den
State (die aufsummierte Vergangenheit, das Gedächtnis) des
Programmes. Mit den Mitteln des State Transition Diagrams (siehe unten)
ist es möglich den Quelltext so zu ordnen, daß nur wenige relevante Zustandswechsel
auftreten. Das Program Pattern für "relevante Zustandwechsel" ist:
Die Zustandsvariable gibt es einmal als
"offizielle" Variable (die Zustandsvariable kann natürlich eine
Struktur oder eine Klasse sein). Daneben gibt es eine (lokale)
Arbeitskopie der
Zustandsvariable. Mit dieser Arbeitskopie wird der neue Inhalt der
Zustandsvariable nach einem Zustands-Wechsel vorbereitet. Sind alle
Konsistenzchecks erfolgreich, wird zum Abschluß des
Zustands-Wechsels der Inhalt der Arbeitskopie in die "offizielle"
Zustands-Variable kopiert. Falls die Konsistenzchecks fehlschlagen
behält die "offizielle" Zustands-Variable entweder ihren alten
Inhalt, oder ein anderer, konsistenter Inhalt der Zustandsvariablen
wird erzeugt (z.B. ein Fehlerzustand).
Durch die Anwendung dieses Program Pattern wandert das Programm von
einem "wohldefinierten Zustand" zum nächsten "wohldefinierten
Zustand". Die
Schritte dazwischen sind nötig, können aber nicht mehr zu
Inkonsistenzen der Zustandsvariable führen. Wenn die
Konsistenzchecks versagen, können wieder Inkonsistenzen auftreten.
Das Program Pattern "relevanter Zustandswechsel" in der
Multi-Tasking Version:
- Wenn Bedingung für Zustands-Wechsel erfüllt, dann
- Kritischen Abschnitt auf Zustandsvariable beginnen
- Offizielle Zustandsvariable nach Arbeitskopie kopieren
- Erzeugen des neuen Zustandes in der Arbeitskopie
- Wenn alle Konsistenzchecks erfolgreich waren, dann Arbeitskopie
nach offizielle Zustandsvariable kopieren
- Kritischen Abschnitt auf Zustandsvariable beenden
Eine einfache Variante des Zustands-Wechsels ohne kritischer Abschnitt
und ohne Arbeitskopie ist:
- Wenn Bedingung für Zustands-Wechsel erfüllt, dann
- Wenn alle Konsistenzchecks erfolgreich waren, dann offizielle
Zustandsvariable ändern
Bjarne Stroustrup spricht im Buch Die C++
Programmiersprache von wohldefinierten Zustand (Invariante) und
meint damit die Folge von konsistenten Inhalten der Zustandsvariable,
d.h. der Kette der
relevanten Zustandswechsel. Er schreibt in Kapitel 24.3.7.1: "Die
Aufgabe der Initialisierung besteht somit darin, ein Objekt in einen
Zustand zu bringen, in dem die Invariante gilt ... Jede Operation einer
Klasse kann davon ausgehen, daß die Invariante zu Beginn des
Aufrufs erfüllt ist, und muß sicherstellen, daß sie
auch beim Verlassen der Operation gilt."
Die Datenbank-Programmierung hat mit der Transaktion ein ähnliches
Programmiermittel. Eine Transaktion erfolgt "ganz oder garnicht".
Entweder wird ein relevanter Zustandswechsel von einem wohldefinierten
Zustand zum nächsten wohldefinierten Zustand ausgeführt oder
per Rollback werden die Zwischenschritte rückgängig gemacht
und der alte wohldefinierte Zustand bleibt weiter bestehen.
Funktionale
Programmierung, Zustand und Ereignis
Für die funktionale Programmierung sind Funktionen mit Zustand
(State) unreine Funktionen (impure functions). Für diese unreinen
Funktionen bietet die FP keine direkte Hilfe. Für reine Funktionen
bietet FP vieles. So können FP Compiler für die reinen
Funktionen alle verfügbaren Prozessoren optimal ausnutzen, ohne
das der Programmierer dem Compiler irgendwelche Tipps geben muß.
Weiterhin ist für reine Funktionen die Program Verification
möglich und manchmal sogar trivial. Bei dem TCP Chat
Server sind alle Funktionen unreine Funktionen, hier bringt FP
nichts.
Zustands-Übergangs-Diagramm,
State Transition Diagram (STD),
Ereignisgesteuertes Modell
Im Abschnitt "Relevante Zustandswechsel" wurde das Program Pattern
für einen einzelnen Zustands-Übergang beschrieben. Alle
Zustands-Übergänge zusammen bilden das Zustands-Übergangs-Diagramm
(STD). Das STD ist ein Design Pattern,
kein Program Pattern. Das STD beschreibt welche Zustände es gibt
und
welche Bedingungen für einen Zustands-Wechsel zu erfüllen
sind. Die Begriffe ereignisgesteuertes Modell oder event-driven
programming betonen den Zustandsübergang. Zustand und Ereignis
sind nur zwei Seiten einer Münze: Der Zustand ist die Statik des
Systems, das Ereignis (der Zustandswechsel) ist die Dynamik des Systems.
Das Werkzeug State Transition
Diagram ist auch in der OO Welt sehr nützlich und
hilfreich. Eine Klasse paßt ausgezeichnet zu STD. In einer Klasse
lassen sich sauber
und ordentlich die Zustandsvariable aufheben und durch
Klassen-Funktionen die Zustandsübergänge mit Konsistenzchecks
realisieren.
Übrigens zeigt sich am STD das alle Programmiersprachen gleich
sind: Ein STD läßt sich mit einer FP Sprache, einer
imperative Sprache mit Klassen oder ohne Klassen und sogar mit
Assembler realisieren. Die Implementierungssprache spielt für das
STD keine Rolle.
Zustands-Übergangs-Diagramm
für TCP Chat Server
Jedes STD beginnt mit dem Start-Zustand. Aus der Aufgabenstellung (z.B.
Anforderungsdokument) lassen sich die weiteren "Work" Zustände
ermitteln. Oft ist es Aufgabe des System-Designers die "Error"
Zustände zu finden. Wenn das System mit jedem, auch
fehlerhaften, Input, mit dem Ausbleiben von Input und mit der
Wiederholung von Input klarkommt weil ein entsprechender Zustand
für alle diese Fälle besteht, dann ist das System robust.
Der System-Designer muß bei dem Entwurf des STD auch die
Lücken
in den Anforderungen aufdecken und diese Lücken schließen.
Oft heißt es im Anforderungsdokument: "Nach A kann B, C, D
folgen". Der System-Designer muß fragen: Wie soll sich das System
verhalten wenn der Zustand A etwas anderes erhält als die
Ereignisse welche zu den Zuständen B, C, D führen? Soll in
diesem Fall das System abstürzen? Oder welches andere
System-Verhalten wird gewünscht?
Der TCP Chat Server ist einfach. Die vagen Anforderungen an das System
sind im Abschnitt "Zusammenfassung" oben schon angegeben. Die
Wörter
"Teilnehmer", "Server", "Client" und "TCP Verbindung" führen den
System Designer zu folgenden Fragen, welche im Anforderungsdokument
nicht ausdrücklich beantwortet werden:
- Wieviele Server? Nur 1 Server.
- Wieviele Clients? 0 bis N Clients.
- Wieviele Teilnehmer? Ein Teilnehmer pro Client, d.h. 0 bis N
Teilnehmer.
- Wieviele TCP Verbindungen? Eine Verbindung pro Client, d.h. 0 bis
N Verbindungen.
Damit ist das Mengengerüst geklärt. Für die
Zustandsübergänge sind die Ereignisse, welche auftreten
können, wichtig:
- Ein Teilnehmer (Client, Connection) meldet sich am Server
- Ein Teilnehmer verabschiedet sich vom Server
- Der Server empfängt eine Meldung von einem Teilnehmer
- Ein schwerer Fehler tritt auf und das Programm wird beendet
- Ein leichter Fehler tritt auf und wird kompensiert
Die Fehler treten nicht direkt als Ereignisse auf. Die Fehler sind
durchgefallene Konsistenzchecks. Die Fehlerbehandlung wird durch
folgende Vereinbarung Teil des Zustands-Übergangs-Diagrammes:
Fehlgeschlagende Konsistenzchecks führen entweder
- zu keinem Zustandswechsel (das Ereignis wird ignoriert) oder
- zu einem Zustandswechsel von einem "Work" Zustand zu einem
anderen "Work" Zustand (der Fehler wird kompensiert) oder
- zu einem Zustandswechsel von einem "Work" Zustand zu einem
"Error" Zustand.
Aus praktischen Gründen ist das absichtliche Beenden (termination)
des Programmes ein Error Zustand.
Mit dieser Konsistenzcheck Erweiterung des
Zustands-Übergangs-Diagrammes können die normale
Verarbeitung,
die Konsistenzchecks und die Fehlerbehandlung gemeinsam betrachtet
werden.
Server (Daemon)
Programmierung
Programmierer wurden früher in Anwendungsprogrammierer und
Systemprogrammierer unterschieden. Die geistigen Fähigkeiten eines
Anwendungsprogrammierers reichten aus zur Programmierung eines Client
Programmes mit Character User Interface (CUI) Oberfläche. Ein
Systemprogrammierer mußte auch die Tücken
des Server Programmes meistern. Heute, zur Zeit der Peer-to-Peer
Netzwerk Applikationen, verschwimmt die Trennlinie. Eine Applikation
wie Skype ist Client für den lokalen Nutzer und Server für
entfernte (remote) Nutzer. Skype benutzt für die
Server-Funktionalität den Begriff Super-Node.
Der Anwendungsprogrammierer hat es leicht, weil er sich nur um einen
Nutzer kümmern muß. Der Systemprogrammierer muß sich
im Server Programm um viele Nutzer kümmern. Es ist der Schritt von
1 auf N welcher die Komplexität schafft und Rechtfertigung
für das bessere
Systemprogrammierer Gehalt ist.
Weiterhin sorgt der Absturz des Server Programmes für Ärger
bei allen Nutzern. Der Absturz des Client Programmes verärgert nur
einen Nutzer. Mehr verärgerte Nutzer erzeugen mehr Druck auf die
Programmierer. Deshalb werden Server Programm Fehler eher repariert als
Client Programm Fehler (so hofft man wenigstens).
Die N Nutzer des Server Programmes müssen irgendwie gleichzeitig
bedient werden. Ein Server mit mehreren CPUs oder mehreren Cores kann
verschiedene Tätigkeiten echt parallel ausführen. Ein Single
Core Server kann verschiedene Tätigkeiten nur quasi parallel
abarbeiten.
Für Multi Core Server sollte das Server Programm als Multi Prozess
Programm realisiert werden. Der Apache Server ist ein gutes Beispiel.
Er verwendet Multi Prozess und Multi Threading Technik für gute
Skalierbarkeit.
Am anderen Ende der Leistungs- und Komplexitätsskala für das
Server Programm liegen Software Interrupt Routinen. Die Software
Interrupt Routinen benutzen nur einen Core und einen Thread. Alle
Variablen liegen in einem Adressraum. Eine Synchronisation (Semaphore,
Mutex, ...) für den Variablenzugriff ist nicht nötig. Im
Betriebssystem UNIX (Linux) werden Software Interrupt Routinen mit dem
Betriebsystem Aufruf (system call) select() verwaltet.
Callback Funktionen
und select()
Die Programmierung von Graphischen Oberflächen (Graphical User
Interface GUI) ist heute ohne Callback Funktionen nicht mehr
vorstellbar. Das typische Programm eines Anwendungsprogrammierers
besteht aus Hunderten von "register callback" Aufrufen zum
Programmbeginn und aus massivem "message pumping" später.
Wenn jedes graphische Element (Button, Entry, Form, ...) als eigener
"Task" angesehen wird, dann ist der Anwendungsprogrammierer dank
GUI schon lange bei der Multi-Tasking Programmierung
angekommen.
Der Betriebssystem Aufruf select() ist älter als das Callback
Prinzip. Die Callback Funktionen des X Window System laufen in
einem select() zusammen. Callback und select() lassen sich leicht
zusammenbringen. Beim Callback ist die Callback Funktion das Wichtige.
Bei select() ist der Filedeskriptor das Wichtige. Beide stehen für
ein Programm-Objekt. Einmal ist der Objekt-Griff (object handle) eine
Aufruf-Adresse, im anderen Fall ein Datei-Index. Das ist aber nicht
weiter problematisch, weil dem eindeutigen Filedeskriptor leicht eine
eindeutige Aufruf-Adresse zugeordnet werden kann.
Und nicht viel mehr geschieht bei dem register callback XtAddInput()
eines neuen
Eingabe Gerätes: Wird dem X Programm ein solches Gerät
angemeldet, so wird in einer X Window System internen Tabelle die
Callback Adresse mit dem entsprechenden Filedeskriptor verknüpft.
Meldet nun die select() Funktion im Herzen des X Programmes neue Daten
auf diesem Filedeskriptor (Details siehe unten), so wird
über die Tabelle der passende Callback ausgesucht und aufgerufen.
Die Callback Funktionen für graphische Elemente im Fenster werden
etwas anders verwaltet. Für das Betriebssystem ist jeder Mausklick
gleich, d.h. egal ob der Benutzer einen Button anklickt oder eine
Menüleiste, bei der select() Funktion kommt eine Eingabe von der
Maus an. Diese Eingabe wird nach der Mausposition ausgewertet.
Mit der Mausposition wird das passende graphische Objekt gesucht, z.B.
der Okay Button eines Popup Fensters. Diesem Objekt wird dann eine
Nachricht gesendet.
Pivot Funktion
select()
Die select() Funktion ist bei Programmen welche mehrere Aufgaben
quasigleichzeitig per Software Interrupt Routinen ausführen das
Nadelöhr. Es darf nur eine Endlosschleife im Programm geben. Und
in dieser einzigen Endlosschleife liegt select(). Alle
Filedeskriptoren, welche ja die Datenquellen des Servres darstellen,
sind dem select() anzugeben. Der select() Aufruf legt die eigene
Applikation im Betriebssystem schlafen. Wenn irgendwelche
Daten an den per select() mitgeteilten Datenquellen oder Schnittstellen
eintreffen, dann wird die Applikation wieder geweckt und
arbeitet weiter.
Nach dem select() Aufruf wird üblicherweise die Liste der
Filedescriptoren "abgeklappert". An select() wurden die offenen
Filedescriptoren gemeldet. Diese fd_set Struktur wird von select()
geändert. Nach dem Return der select() Funktion enthält die
Struktur die Filedeskriptoren mit noch ungelesenen Eingangsdaten.
Mit read() können und sollen nun alle Eingangsdaten von allen
aktiven Filedeskriptoren gelesen und verarbeitet werden. Die select()
Endlosschleife durchläuft somit die Schritte
- fd_set Struktur mit offenen Filedeskriptoren zusammenstellen
- select() aufrufen
- Im Betriebssystem warten auf eingehende Daten
- select() beenden
- In der fd_set Struktur die Filedeskriptoren mit ungelesenen Daten
suchen und finden
- Die entsprechenden Funktionen zum Daten lesen und Daten
verarbeiten aufrufen
- Endlosschleife erneut durchlaufen
Fehlerbehandlung
(Error Handling)
Jeder einzelne Betriebssystemaufruf kann scheitern. Eine einfache
Reaktion auf solch ein negatives Ereignis ist Programm Beendigung
(termination). Mit einem "stillen" Tod der Applikation ist dem
Programmierer bei der Fehlersuche nicht geholfen. Es sollte schon
ein bißchen "Warum" gemeldet werden. Mit der Zeit kann der
Fehlerbehandlungs-Quelltext leicht den produktiven Quelltext an
Quelltextzeilen übertreffen. Ein Verhältnis von 70% Quelltext
für Eingangsdatenprüfungen, Konsistenzprüfungen,
Fehlermeldungs Logging Verwaltung, Wiederanlaufmethoden nach Fehlern
usw. zu 30%
Quelltext für die reine Programmfunktion sind typisch für
"mission critical" Server Programme.
Damit der "error source code" nicht den Blick auf den "work source
code" verstellt können Makros eingesetzt werden. Im
Beispielprogramm werden die Makros exit_if() und return_if() verwendet.
Beide Makros führen eine Konsistenzprüfung zusammen mit einer
Fehlermeldung aus. Wenn der Test fehlschlägt, beendet das Makro
exit_if() das Programm mit Fehlermeldung. Das Makro return_if() beendet
die Funktion mit Fehlermeldung. Beide Makros erzeugen den Fehlertext
automatisch. Gemeldet wird Quelltextdatei, Quelltextzeile, Funktion und
Fehlerursache.
Die Programmiersprache C kennt kein catch() und throw() für die
Fehlerbehandlung. Für die Fehlerbehandlung in C wird goto und
setjmp()/longjmp() eingesetzt. Das goto wird eingesetzt wenn
Aufräumarbeiten in einer Funktion nötig sind um memory leaks
zu vermeiden. Die
setjmp()/longjmp() Funktionen werden eingesetzt wenn im Fehlerfall
mehrere Funktionen gleichzeitig beendet werden sollen. Der Sprung
erfolgt dann z.B. von der fünften Unterprogrammebene direkt
zurück zur zweiten Unterprogrammebene. Im Abschnitt
"Zustands-Übergangs-Diagramm für TCP Chat Server" oben wurde
gezeigt wie Fehler als Ereignisse zu zusätzlichen Zustände
führen. Diese Art der Fehlerbehandlung benötigt kein
setjmp()/longjmp().
Besonders bei C sollte neben der Fehlerbehandlung innerhalb des C
Programmes auch an eine Fehlerbehandlung ausserhalb des C Programmes
gedacht werden. Es ist einfach und praktisch den Aufruf des Server
Programmes in eine Shell Endlosschleife zu stellen. Wenn das C Programm
"verstirbt", dann wird über die Shell Endlosschleife eine neue
Inkarnation des C Programmes gestartet. Für die Nutzer ist dieser
kleine Tod weniger ärgerlich als die "Server ist abgestürzt,
wir warten auf den Techniker" Auskunft des Helldesks.
Binär-Strings in C
Die Null-terminierten C-Strings erlauben im String nicht das
Zeichen '\0'. Für Binär-Strings kann eine Kodierung mit
zwei Variablen verwendet werden. Die erste Variable ist die Adresse des
Strings (string pointer), die zweite Variable ist die String
Länge. Wird die String Länge mit einem call-by-reference
Parameter übergeben läßt sich eine weitere
"Schmutzecke" von C reinigen: Die call-by-reference String Länge
kann beim Aufruf mit der maximalen String-Länge sizeof(string)
geladen werden.
Beim Return kann im gleichen Parameter die aktuelle String-Länge
zurückgegeben werden.
Diese Binär-String Darstellung mit String pointer und
call-by-reference String Länge wird bei der Berkeley Socket
Schnittstelle
seit 1983 verwendet. Im Beispiel wird die Socket Binär-String
Lösung verwendet.
Compile und
Ausführen
Der Quelltext wird unter Linux mit
cc -o chatd chatd.c
übersetzt. Die
Programmdatei wird mit ./chatd
& im Hintergrund
gestartet. Für den ersten Test können zwei oder mehr telnet
Programme mit telnet
127.0.0.1
51234 auf dem gleichen Rechner gestartet werden.
Eine Eingabe im
telnet Programm wird per TCP Verbindung zum chatd Programm gesendet.
Von dort wird die Eingabe an alle anderen Clients weitergesendet
und ausgegeben.
Weitere Tests
Das Chat Server Programm chatd und das Chat Client Programm telnet
können auch auf verschiedenen Servern laufen. Der Client muß
die IP-Adresse des Servers kennen.
Mit dem Aufruf netstat
-antp
lassen sich die TCP Verbindungen ansehen. Laufen auf dem gleichen
Rechner chatd und ein telnet gibt es drei Einträge: ein LISTEN auf
dem Port 51234 durch das Programm chatd, ein CONNECT von chatd zu
telnet und ein CONNECT von telnet nach chatd. Die letzten beiden
Einträge beschreiben die gleiche TCP Verbindung, nur von den
beiden unterschiedlichen Endpunkten aus.
Wird eine zweite Instanz (Inkarnation) von chatd gestartet,
erscheint die Fehlermeldung chatd:
exit_if() chatd.c: 85: tcp_server_init: Error Address already in use.
Mit "Address" ist das 5er-Tupel "tcp 0.0.0.0:51234 0.0.0.0:*", d.h. der
Listen Port des TCP Servers gemeint.
Mit killall chatd
kann
der Server "brutal" beendet werden.
Weitere
Programmentwicklung
Mehr Error Handling ist immer möglich. Man kann versuchen die
exit_if() Stellen durch Error Recovery im C-Quelltext zu ersetzen. Ziel
ist jede Programm Beendigung zu vermeiden (Dieses Ziel ist in der
Praxis in letzter Konsequenz nicht erreichbar, aber Studenten wollen ja
irgendwie beschäftigt werden).
Das Programm kann mit pselect() oder mit poll() programmiert werden -
beide system calls sind Varianten von select().
Das chatd Programm wird mit & als Hintergrund Prozess (background
process) gestartet. Ein Prozess kann sich selbst in den Hintergrund
stellen und kann sich von der Konsole abkoppelt.
Die einzige Verarbeitung von Signalen im Moment besteht aus dem
Ignorieren von Signalen. Wer Frustration liebt kann das Thema Signale
vertiefen. So können die Signale SIGINT oder SIGQUIT benutzt
werden um das chatd Programm sauber "runterzufahren". Leider lassen
sich einige Signale wie SIGKILL nicht fangen (catch) und machen das
ganze Signal Handling zur vergeudeten Mühe.
Eine exotische TCP Eigenschaft sind "Out of band (OOB)" Daten. Kein
normaler Mensch benutzt soetwas. Aber das OOB Thema zieht sich durch
die komplette Socket Schnittstelle. Bei genügend viel Masochismus
läßt sich die komplette chatd Kommunikation per OOB Daten
ausführen. Der vierte select() Parameter exceptfds ist der
Schlüssel zur OOB Hölle.
Etwas weniger sinnlos ist die Nutzung des dritten select() Parameters
writefds. Üblicherweise vertraut man auf die Write Buffer des
Betriebssystems und benutzt write() direkt. Aber man kann immer nach
Betriebssystemfehlern suchen. Und wer select() benutzt um sein write()
zu kontrollieren bewegt sich in so einer dunklen Betriebssystem-Ecke
das kleine Blessuren nicht auszuschließen sind. Und selbst wenn
es jetzt funktioniert - eine andere Hardware, eine neue
Betriebssystemversion oder eine Portierung bereiten bestimmt Ärger.
Eine moderne Alternative zu select() ist die asynchrone IO. Ein
aio_read() system call blockiert nicht. Mit aio_error() Aufrufen wird
der Status der Leseoperation abgefragt. Zum Abschluß wird mit
aio_return() der read() Returnwert geholt. Die asynchrone IO verlangt
busy waiting auf aio_error() in der Applikation, aber eigentlich
möchte der Programmierer vom Betriebssystem entlastet werden. Bei
asynchroner IO
bekommt der Programmierer eine Ladung "Verwaltungskram" obendrauf
um die sich sonst das Betriebssystem kümmert. Mit apropos aio_ sieht man den
asynchrone IO Zoo.
Zum Abschluß noch ein Tipp von Herzen: Wer select() so verwendet
wie im Beispielprogramm unten der wird seines Lebens froh. Wer mehr
Leistung braucht sollte sich den Apache Quelltext ansehen um
"multi-processes/multi-threading workers" zu verstehen. Für den
Autor gibt es nur zwei sinnvoll nutzbare select() Parameter: den
zweiten Parameter readfds und den letzten Parameter timeout. Bei
Echtzeit Systemen ist timeout nicht NULL.
Achtung: Timeout Verarbeitung ist oft nötig aber erschwert die
Programmentwicklung deutlich - die Programm Dynamik aufgrund von
Timeouts macht den Debugger nutzlos.
Der Quelltext
Der Quelltext besteht aus den Funktionen
- tcp_server_init() zum Öffnen des "well known" Ports des TCP
Servers
- tcp_server_init2() zum Öffnen einer TCP Verbindung zwischen
dem Server und einem Client
- tcp_server_write() zum Schreiben von Daten an den Client
- tcp_server_read() zum Lesen von Daten vom Client
- loop() als die Funktion rund um select() und dem State in der
Variablen
the_state
- main() als Program Entry Function
Die genaue Funktion des Quelltextes erschließt sich durch
aufmerksames Lesen des Quelltextes. Das man UNIX Kommando
ist sehr
hilfreich beim Verständnis der Betriebssystem Aufrufe. Besonders
man select_tut
für das
select() Tutorial wird empfohlen. Falls
Lesen nicht mehr weiterhilft, sollte man mit dem Programm "spielen":
Quelltext ändern, printf() Kommandos einfügen, das Programm
im Debugger ausführen, usw.
Falls auch Spielen nicht dem Verständnis hilft dann sollte man
vielleicht der Systemprogrammierung Adieu sagen. Viel leichter als
in diesem Beispiel wird Serverprogrammierung nicht. Für
Fortgeschrittene gibt es hier eine Lösung
mit Threads.
/*
chatd.c
* Chat Server (Daemon) fuer Linux
* Autor: Andre Adrian
* Version: 09jun2007
*
* Chat Server wartet auf TCP Port 51234 auf einen
Verbindungsaufbau
* von telnet Clients. Der Client sendet ASCII Meldungen an den
Server.
* Die Meldungen werden vom Server an alle anderen Clients
gesendet.
*
* Aufruf Server:
* ./chatd &
*
* Aufruf Client:
* telnet IP_Adresse_Server 51234
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <syslog.h>
#include <fcntl.h>
#include <errno.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/select.h>
/* Die Makros exit_if() und return_if() realisieren das Error Handling
* der Applikation. Wenn die exit_if() Bedingung wahr ist, wird
* das Programm mit Fehlerhinweis Datei: Zeile: Funktion: errno
beendet.
* Wenn die return_if() Bedingung wahr ist, wird die aktuelle
Funktion
* beendet. Dabei wird der als Parameter 2 angegebene Returnwert
benutzt.
*/
#define exit_if(expr) \
if(expr) { \
syslog(LOG_WARNING, "exit_if() %s: %d: %s: Error %s\n", \
__FILE__, __LINE__, __PRETTY_FUNCTION__, strerror(errno)); \
exit(1); \
}
#define return_if(expr, retvalue) \
if(expr) { \
syslog(LOG_WARNING, "return_if() %s: %d: %s: Error %s\n\n", \
__FILE__, __LINE__, __PRETTY_FUNCTION__, strerror(errno)); \
return(retvalue); \
}
#define MAXLEN 1024
#define OKAY 0
#define ERROR (-1)
int tcp_server_init(int port)
/* Server (listen) Port oeffnen - nur einmal ausfuehren
* in port: TCP Server Portnummer
* return: Socket Filedescriptor zum Verbindungsaufbau vom Client
*/
{
int listen_fd;
int ret;
struct sockaddr_in sock;
int yes = 1;
listen_fd = socket(PF_INET, SOCK_STREAM, 0);
exit_if(listen_fd < 0);
/* vermeide "Error Address already in use" Fehlermeldung */
ret = setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes,
sizeof(int));
exit_if(ret < 0);
memset((char *) &sock, 0, sizeof(sock));
sock.sin_family = AF_INET;
sock.sin_addr.s_addr = htonl(INADDR_ANY);
sock.sin_port = htons(port);
ret = bind(listen_fd, (struct sockaddr *) &sock,
sizeof(sock));
exit_if(ret != 0);
ret = listen(listen_fd, 5);
exit_if(ret < 0);
return listen_fd;
}
int tcp_server_init2(int listen_fd)
/* communication (connection) oeffnen - fuer jeden neuen client
ausfuehren
* in listen_fd: Socket Filedescriptor zum Verbindungsaufbau vom
Client
* return: wenn okay Socket Filedescriptor zum lesen vom Client,
ERROR sonst
*/
{
int fd;
struct sockaddr_in sock;
socklen_t socklen;
socklen = sizeof(sock);
fd = accept(listen_fd, (struct sockaddr *) &sock,
&socklen);
return_if(fd < 0, ERROR);
return fd;
}
int tcp_server_write(int fd, char buf[], int buflen)
/* Schreibe auf die Client Socket Schnittstelle
* in fd: Socket Filedescriptor zum Schreiben zum Client
* in buf: Meldung zum Schreiben
* in buflen: Meldungslaenge
* return: OKAY wenn Schreiben vollstaendig, ERROR sonst
*/
{
int ret;
ret = write(fd, buf, buflen);
return_if(ret != buflen, ERROR);
return OKAY;
}
int tcp_server_read(int fd, char buf[], int *buflen)
/* Lese von der Client Socket Schnittstelle
* in fd: Socket Filedescriptor zum lesen vom Client
* out buf: Gelesene Meldung
* inout buflen: in = maximale Meldungslaenge, out = gelesene
Meldungslaenge
* return: OKAY wenn Lesen okay, ERROR sonst
*/
{
/* lese Meldung */
*buflen = read(fd, buf, *buflen);
if (*buflen <= 0) {
/* End of TCP Connection */
close(fd);
return
ERROR;
/* bedeutet fd ist nicht mehr gueltig */
}
return OKAY;
}
void loop(int listen_fd)
/* Server Endlosschleife
* in listen_fd: Socket Filedescriptor zum Verbindungsaufbau vom
Client
*/
{
fd_set the_state;
int maxfd;
FD_ZERO(&the_state);
FD_SET(listen_fd, &the_state);
maxfd = listen_fd;
for (;;)
{
/* Endlosschleife */
fd_set readfds;
int ret;
int rfd;
readfds =
the_state; /* select()
aendert readfds */
ret = select(maxfd + 1, &readfds, NULL, NULL,
NULL);
if ((ret == -1) && (errno == EINTR)) {
/* Ein Signal ist aufgetreten.
Ignorieren */
continue;
}
exit_if(ret < 0);
/* TCP Server LISTEN Port (Client connect) pruefen */
if (FD_ISSET(listen_fd, &readfds)) {
rfd = tcp_server_init2(listen_fd);
if (rfd >= 0) {
FD_SET(rfd,
&the_state); /* neuen
Client fd dazu */
if (rfd > maxfd) {
maxfd = rfd;
}
}
}
/* TCP Server CONNECT Ports (Clients communication)
pruefen */
for (rfd = listen_fd + 1; rfd <= maxfd; ++rfd) {
if (FD_ISSET(rfd, &readfds)) {
char msgbuf[MAXLEN];
int msgbuflen;
/* Meldung vom Client lesen
*/
msgbuflen = sizeof(msgbuf);
ret = tcp_server_read(rfd,
msgbuf, &msgbuflen);
if (ERROR == ret) {
FD_CLR(rfd,
&the_state); /* toten Client rfd
entfernen */
} else {
/* Meldung an
alle anderen Clients schreiben */
int wfd;
for (wfd =
listen_fd + 1; wfd <= maxfd; ++wfd) {
if
(FD_ISSET(wfd, &the_state) && (rfd != wfd)) {
tcp_server_write(wfd, msgbuf, msgbuflen);
}
}
}
}
}
}
}
int main(int argc, char *argv[])
{
/* Fehler Logging einschalten */
openlog(NULL, LOG_PERROR, LOG_WARNING);
/* open Chat as TCP server */
loop(tcp_server_init(51234));
return OKAY;
}
MS-Windows TCP Chat Server
Quelltext
Unter Microsoft Windows ist die Socket Schnittstelle ebenfalls
verfügbar. Es gibt einige Unterschiede zwischen der Linux und der
Microsoft Version:
- Die Socket Header Dateien sind bei MS-Windows unter winsock2.h
zusammengefasst.
- Anstelle von errno wird WSAGetLastError() verwendet.
- Die strerror() Funktion kennt die MS-Windows error codes nicht.
In winerror.h können die error codes nachgelesen werden.
- setsockopt() mit SO_REUSEADDR verhält sich unter MS-Windows
seltsam: Es erlaubt mehreren Programmen den gleichen Listen Port zu
öffnen.
- Der letzte Parameter bei setsockopt() ist ein char * wie bei Sun
Solaris.
- Die Socket Filedeskriptoren sind vom Type SOCKET und nicht vom
Type int.
- Mehrere accept() Aufrufe nacheinander liefern nicht
aufeinanderfolgende Zahlen wie 5, 6, 7 sondern vielleicht 132, 160, 176.
- Der Type socklen_t fehlt. Der Parameter ist vom Type int.
- Es gibt kein write() und kein read(). Statt dessen wird send()
und recv() verwendet, welche es auch unter Linux gibt.
- Anstelle von close() wird closesocket() verwendet.
- Windows Sockets 2 kennt keine Signale. Somit gibt es keinen
select() Return mit WSAEINTR aufgrund von Signalen.
- Sockets müssen mit WSAStartup() initialisiert und mit
WSACleanup() terminiert werden.
Als Compiler wurde der kostenfreie Microsoft
Visual C++ 2005 Express
Edition verwendet. Der Linker benötigt die WS2_32.Lib unter "Property,
Configuration Properties, Linker, Input, Additional Dependencies"
welche im
ebenfalls kostenlosen Microsoft
Platform SDK enthalten ist.
Die Programme telnet und netstat gibt es auch unter MS-Windows.
Anstelle von netstat -antp
wird bei Microsoft netstat
-anp TCP geschrieben. Die Optionen bei telnet sind gleich.
/* winchatd.c
* Chat Server (Daemon) fuer MS-Windows
* Autor: Andre Adrian
* Version: 07jun2007
*
* Chat Server wartet auf TCP Port 51234 auf einen
Verbindungsaufbau
* von telnet Clients. Der Client sendet ASCII Meldungen an den
Server.
* Die Meldungen werden vom Server an alle anderen Clients
gesendet.
*
* Aufruf Server:
* winchatd
*
* Aufruf Client:
* telnet IP_Adresse_Server 51234
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
/* Die Makros exit_if() und return_if() realisieren das Error Handling
* der Applikation. Wenn die exit_if() Bedingung wahr ist, wird
* das Programm mit Fehlerhinweis Datei: Zeile: Funktion: errno
beendet.
* Wenn die return_if() Bedingung wahr ist, wird die aktuelle
Funktion
* beendet. Dabei wird der als Parameter 2 angegebene Returnwert
benutzt.
*/
#define exit_if(expr) \
if(expr) { \
fprintf(stderr, "exit_if() %s: %d: %s: WinError %d\n", \
__FILE__, __LINE__, __FUNCTION__, WSAGetLastError()); \
exit(1); \
}
#define return_if(expr, retvalue) \
if(expr) { \
fprintf(stderr, "return_if() %s: %d: %s: WinError %d\n\n", \
__FILE__, __LINE__, __FUNCTION__, WSAGetLastError()); \
return(retvalue); \
}
#define MAXLEN 1024
#define OKAY 0
SOCKET tcp_server_init(int port)
/* Server (listen) Port oeffnen - nur einmal ausfuehren
* in port: TCP Server Portnummer
* return: Socket Filedescriptor zum Verbindungsaufbau vom Client
*/
{
SOCKET listen_fd;
int ret;
struct sockaddr_in sock;
listen_fd = socket(PF_INET, SOCK_STREAM, 0);
exit_if(listen_fd < 0);
memset((char *) &sock, 0, sizeof(sock));
sock.sin_family = AF_INET;
sock.sin_addr.s_addr = htonl(INADDR_ANY);
sock.sin_port = htons(port);
ret = bind(listen_fd, (struct sockaddr *) &sock,
sizeof(sock));
exit_if(ret != 0);
ret = listen(listen_fd, 5);
exit_if(ret < 0);
return listen_fd;
}
SOCKET tcp_server_init2(SOCKET listen_fd)
/* communication (connection) oeffnen - fuer jeden neuen client
ausfuehren
* in listen_fd: Socket Filedescriptor zum Verbindungsaufbau vom
Client
* return: wenn okay Socket Filedescriptor zum lesen vom Client,
SOCKET_ERROR sonst
*/
{
SOCKET fd;
struct sockaddr_in sock;
int socklen;
socklen = sizeof(sock);
fd = accept(listen_fd, (struct sockaddr *) &sock,
&socklen);
return_if(fd < 0, SOCKET_ERROR);
return fd;
}
int
tcp_server_write(SOCKET fd, char buf[], int buflen)
/* Schreibe auf die Client Socket Schnittstelle
* in fd: Socket Filedescriptor zum Schreiben zum Client
* in buf: Meldung zum Schreiben
* in buflen: Meldungslaenge
* return: OKAY wenn Schreiben vollstaendig, SOCKET_ERROR sonst
*/
{
int ret;
ret = send(fd, buf, buflen, 0);
return_if(ret != buflen, SOCKET_ERROR);
return OKAY;
}
int tcp_server_read(SOCKET
fd, char buf[], int *buflen)
/* Lese von der Client Socket Schnittstelle
* in fd: Socket Filedescriptor zum lesen vom Client
* out buf: Gelesene Meldung
* inout buflen: in = maximale Meldungslaenge, out = gelesene
Meldungslaenge
* return: OKAY wenn Lesen okay, SOCKET_ERROR sonst
*/
{
/* lese Meldung */
*buflen = recv(fd, buf, *buflen, 0);
if (*buflen <= 0) {
/* End of TCP Connection */
closesocket(fd);
return
SOCKET_ERROR;
/* bedeutet fd ist nicht mehr gueltig */
}
return OKAY;
}
void loop(SOCKET listen_fd)
/* Server Endlosschleife
* in listen_fd: Socket Filedescriptor zum Verbindungsaufbau vom
Client
* return: immer SOCKET_ERROR
*/
{
fd_set the_state;
SOCKET maxfd;
FD_ZERO(&the_state);
FD_SET(listen_fd, &the_state);
maxfd = listen_fd;
for (;;)
{
/* Endlosschleife */
fd_set readfds;
int ret;
SOCKET rfd;
readfds =
the_state; /* select()
aendert readfds */
ret = select((int)maxfd + 1, &readfds, NULL,
NULL, NULL);
exit_if(ret < 0);
/* TCP Server LISTEN Port (Client connect) pruefen */
if (FD_ISSET(listen_fd, &readfds)) {
rfd = tcp_server_init2(listen_fd);
if (rfd >= 0) {
FD_SET(rfd,
&the_state); /* neuen
Client fd dazu */
if (rfd > maxfd) {
maxfd = rfd;
}
}
}
/* TCP Server CONNECT Ports (Clients communication)
pruefen */
for (rfd = listen_fd + 1; rfd <= maxfd; ++rfd) {
if (FD_ISSET(rfd, &readfds)) {
char msgbuf[MAXLEN];
int msgbuflen;
/* Meldung vom Client lesen
*/
msgbuflen = sizeof(msgbuf);
ret = tcp_server_read(rfd,
msgbuf, &msgbuflen);
if (SOCKET_ERROR == ret) {
FD_CLR(rfd,
&the_state); /* toten Client rfd
entfernen */
} else {
/* Meldung an
alle anderen Clients schreiben */
SOCKET wfd;
for (wfd =
listen_fd + 1; wfd <= maxfd; ++wfd) {
if
(FD_ISSET(wfd, &the_state) && (rfd != wfd)) {
tcp_server_write(wfd, msgbuf, msgbuflen);
}
}
}
}
}
}
}
int main(int argc, char *argv[])
{
WSADATA wsaData;
int ret;
/* MS-Windows spezielle Socket Initialisierung */
ret = WSAStartup(MAKEWORD(2, 2), &wsaData);
exit_if(ret != 0);
/* open Chat as TCP server */
loop(tcp_server_init(51234));
/* MS-Windows spezielle Socket Terminierung */
WSACleanup();
return OKAY;
}
TCP Chat Server in
anderen Programmiersprachen
Ein TCP
Chat Server in Erlang findet sich hier.
Über den Autor
Der Autor hat 20 Jahre professionelle Programmiererfahrung in C und
C++. Auch wenn die Firmen und Tätigkeitsbeschreibungen gewechselt
haben, das Thema war 20 Jahre lang das gleiche: Echtzeit Systeme
Programmierung.
Literatur
Von Brian "Beej Jorgensen" Hall gibt es eine gute Einführung in
Netzwerk Programmierung mit Sockets unter Beej's Guide to Network Programming.
Die Programmierung von Servern in C mit der Socket Schnittstelle wird
in den Büchern von Richard
Stevens behandelt.
UNIX Network Programming, Prentice Hall, 1990, ISBN 0-13-949876-1
Programmieren von UNIX-Netzen, Hanser, Prentice Hall, 1992, ISBN
3-446-16318-2
UNIX Network Programming, Volume 1, Second Edition: Networking APIs:
Sockets and XTI, Prentice Hall, 1998, ISBN 0-13-490012-X
UNIX Network Programming, Volume 2, Second Edition: Interprocess
Communications, Prentice Hall, 1999, ISBN 0-13-081081-9
Die endgültige Literatur über die UNIX Betriebssystem Aufrufe
sind die
man Pages.
Über Funktionale
Programmierung gibt es in Wikipedia eine Einleitung. Der englische
Wikipedia Artikel functional
programming hat teilweise anderen Inhalt. Eine schöne
Einleitung ist auch Functional
Programming For The Rest of Us.
Die Begriffe Funktionale Programmierung und State führen zum
Begriff Monad. Wie so oft erschließt sich das Thema über
einen Wikipedia Artikel Monads
in functional programming. Im Wikibook Artikel Haskell/Understanding
monads wird die State monad eingeführt.
Es gibt einen Zusammenhang zwischen Funktionaler Programmierung und Program Verification. Grob vereinfacht ist Program
Verification bei Programmen in FP Sprachen möglich und bei
Programmen in imperativen Sprachen (praktisch) unmöglich. Für
die Beweisbarkeit muß man leider leiden. Wikipedia stellt Program
Verification in den grösseren Zusammenhang Formal
verification.
Übrigens sorgt Program Verification nicht für fehlerfreie
Systeme. Nach dem Beweis ist die Software fehlerfrei, die Hardware kann
aber immer noch für Fehler sorgen. Gegen Hardware Fehler werden
z.B. 2-aus-3 Entscheider (Voter) eingesetzt. Nun ist leider der
Redundanz-Schalter oder der Mehrheits-Entscheider in einem redundanten
System genau der Systemteil welcher selbst nicht redundant
ausgeführt werden kann. Oder mathematisch/philosophisch
betrachtet: Das System nähert sich asymptotisch der Perfektion,
erreicht diese aber nie.
Zum Abschluß noch ein Link auf die Betriebssystemvorlesung
von Prof. Helmut Weber, meinem Diplom Prof.