Winsock 2 - Socket-Erweiterungen für Windows :: Teil 1
-
Inhalt
- Einleitung
- Winsock 2: Overlapped I/O
- Einführung
- Grundlagen
- Asynchrone I/O-Operationen
- Notification-Modelle
- Events und Overlapped-States
- Completion-Routinen
- Exkurs: I/O-Completionports
- Windows-Erweiterungen I
ConnectEx
DisconnectEx
AcceptEx
GetAccpetExSockaddrs()
- Sicherheitsaspekte
AcceptEx()
vs.WSAAccept()
- Hinweise
- Zusammenfassung
- Quellen & Links
1. Einleitung
Es ist kaum zu übersehen, dass das Thema Netzwerkprogrammierung immer beliebter wird, denn die Dichte an Fragen über Sockets in Foren nimmt stets zu. Gleichzeitig ist der Bereich der Netzwerkprogrammierung jedoch so umfangreich und komplex, dass sich viele Anfänger leider schnell den Kopf daran stoßen. Denn gerade in der Socket-Programmierung, wo man verschiedene Systeme miteinander kommunizieren lassen kann, muss man viel Wert auf Sicherheit legen. Deshalb sollte man immer genaustens wissen, was man eigentlich tut. Besonders dann, wenn die erstellte Software in den produktiven Einsatz übergehen sollte.
Mittlerweile gibt es unzählige Libraries für Netzwerkprogrammierung, die in diversen Lizenzen zu haben sind. In C++ haben sich in den Jahren besonders boost::asio und Poco.Net einen Namen gemacht. Die Vorteile, die solche Libraries bieten, sind nicht zu unterschätzen, da sie:
- plattformunabhängig: Sie kapseln die Socket-APIs von vielen verschiedenen Systemen und bieten dafür ein einziges und einfacheres Interface an.
- performant: Programmierer mit viel Erfahrung haben an diesen Libraries gearbeitet und haben das beste aus den APIs der Zielplattformen herausgeholt - bei gleichzeitiger Vereinfachung der Schnittstelle.
- sicher: Tausende Programmierer haben diese Libraries bereits eingesetzt, sie wurden sehr oft getestet und stets verbessert, Sicherheitslöcher konnten in den Jahren weitesgehend geschlossen werden.
- detailliert: Besonders Boost.Asio hat ein unheimlich großes Repertoire an Features.
- bekannt: Die Dokumentationen für die Libraries sind übersichtlich und stets aktuell. Gleichzeitig kann man in den Foren viele Benutzer treffen, die bereit sind, Fragen zu beantworten.
sind.
Dieser Artikel ist für solche Leser geeignet, die wissen wollen, was hierbei speziell hinter den Kulissen auf Windows-Systemen geschieht und für die, die noch mehr Freiheiten und Möglichkeiten benötigen, die Libraries durch ihre allgemeinen Interfaces nicht mehr bieten können.
Dieser Artikel setzt neben genug Fachwissen über C(++) auch grundlegende Erfahrung mit dem Umgang der Berkeley-Socket-API auf Windows voraus. Das Tutorial von C-Worker bietet eine wunderbare Einführung in die Grundlagen der Socketprogrammierung mit Windows, falls man diese Kenntnisse nicht mitbringen kann.2. Winsock 2: Overlapped I/O
2.1 Einführung
Mit Winsock 1.1 und Windows NT erschien ein neues I/O-Konzept. Das damals neue Modell nennt sich "Overlapped I/O", was man wohl mit "Überlappende/Überschneidende Ein-/Ausgabe" übersetzen könnte. Zunächst gab es dieses Konzept nur für die Standard-I/O-Funktionen
ReadFile(Ex)()
undWriteFile(Ex)()
, nach der Entwicklung von Winsock v2 allerdings auch für die Windows Socket-API. Bei Overlapped I/O handelt es sich um eine Technologie, welche effiziente Asynchrone Kommunikation auf Windows-Systemen ermöglicht. Neben der Anwendung in der Socketprogrammierung und in der I/O mit Files kann man sich dieses Konzept auch in Verbindung mit Pipes zunutze machen.
Was bedeutet "Asynchrone Kommunikation" nun eigentlich?
Folgendes Beispiel veranschaulicht den Sachverhalt. Ein TCP-Client soll sich zu einem HTTP-Server verbinden, um eine Website abzufragen. Der Client könnte so aussehen:// Winsock initialisieren... SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in saddr = { 0 }; // Wichtig: Alle Strukturen mit 0 initialisieren // ... Adresse befüllen, auf Port 80 setzten if(connect(s, reinterpret_cast<sockaddr*>(&saddr), sizeof saddr) == SOCKET_ERROR) // Fehler std::vector<TCHAR> http_request, http_response(512); // Vector mit Request füllen if(send(s, &http_request[0], http_request.size() * sizeof(TCHAR), 0) == SOCKET_ERROR) // Fehler // Es wird nichts mehr gesendet --> Verbindung herunterfahren if(shutdown(c, SD_SEND) == SOCKET_ERROR) // Fehler int rc = 0; do { rc = recv(s, &http_response[0], http_response.size() * sizeof(TCHAR), 0); switch(rc) { case SOCKET_ERROR: // Fehler break; case 0: // Verbindung getrennt break; default: // Daten wurden empfangen, 0-Terminierung anhängen und ausgeben } } while(rc > 0); if(closesocket(s) == SOCKET_ERROR) // Fehler // Winsock herunterfahren
Dieser Code arbeitet kontinuierlich die Zeilen von oben bis unten ab. Bis zum
connect()
ist die Geschwindigkeit der Programmausführung nur von der Geschwindigkeit des Systems abhängig (wenn wir nicht zuvor schon eine Namensauflösung über einen DNS-Server gemacht haben).
Sobaldconnect()
aufgerufen wird, blockiert das Programm, d.h. die Ausführung verschwindet irgendwo in der connect()-Routine, die dem Programmierer verborgen bleibt. Als logische Konsequenz wartet das Programm nun an dieser Stelle (das Verhalten ist äquivalent zu der Eingabesemantik vonstd::cin
: Ein Aufruf von z.B.std::cin.get()
wartet so lange, bis ein char gelesen werden konnte). Der Zielcomputer bekommt nun einen TCP-Request und beginnt, die TCP-Verbindung zu initialisieren. Während so die verschiedenen TCP-Packete und Handshakes durch das Netzwerk gehen, wartetconnect()
, bis die Verbindung nach der Definition von TCP steht und bremst das Programm auf die Geschwindigkeit des Netzwerkes ab.Anschließend wird eine
send()
-Operation durchgeführt. Auchsend()
wird blockieren, da die Funktion so lange wartet, bis Winsock die TCP-Bestätigung (ACK
) bekommt, dass die Daten beim Ziel korrekt angekommen sind oder das Timeout abgelaufen ist (FehlercodeWSAETIMEDOUT
).
Diese Form der Programmausführung nennt man "Synchrone Kommunikation", da hier die beiden Verbindungspartner jeweils abgestimmt aufeinander warten, bis die Operation des Gegenübers abgeschlossen ist und der gesamte Vorgang in einer "geordneten" oder deterministischen Art und Weise von statten gehen kann.Inwiefern ist dieser Code nun problematisch? Angenommen, er soll Bestandteil eines Webbrowsers sein. Sobald eine blockierende Funktion aufgerufen wird, wie etwa
connect()
, blockiert Windows folglich den gesamten Thread. Durch diesen Umstand erreichen den Thread keine anderen Nachrichten mehr (etwa dass der Nutzer den zu lange dauerndenconnect()
jetzt abbrechen möchte, da der Server nicht reagiert) und das Fenster "hängt". Die wohl am häufigsten genannte Lösungsmethode sieht den Einsatz von Threads vor. Man trennt in einen GUI- und einen Netzwerkthread auf, sodass das Nutzerinterface weiterhin reagiert, auch wenn der Netzwerkthread blockiert.
Allerdings bringt das dem Endnutzer nichts, da der Netzwerkthread mit einem bestimmten Befehl beschäftigt und folglich unbenutzbar für den Nutzer ist, bis die Blockade verschwunden ist, während die GUI volle Funktionsbereitschaft vorgaukelt. Gleichzeitig bekommt man nun den Aspekt der Threadsicherheit mit all seinen Facetten wie Synchronisation zu spüren, wodurch die Komplexität des Programmes in die Höhe schnellt.
Ein noch etwas abstrakterer Nachteil ist der Flaschenhals: Moderne Computer sind in dieser Zeit auf GHz getaktet: in der Zeit, die durch das Warten verschwendet wird, könnte die CPU tausende andere Befehle abarbeiten (was sie dank Scheduler auch tut, der den blockierten Thread unterbricht, jedoch wird hierbei die Rechenzeit des Prozesses verschenkt), die vom Ergebnis der I/O-Operation unabhängig sind. Es sollte idealerweise nur der Code auf seine Ausführung warten, der vom Ergebnis der Operation auch tatsächlich abhängig ist.In den früheren Winsock-Versionen hatte man aus diesem Grund die Möglichkeit, sog. non-blocking Sockets zu nutzen. Diese Socket-Form blockiert den Thread bei I/O-Operationen nicht, sondern kehrt sofort mit einem Fehlercode zurück und lässt die Operation "im Hintergrund" ausführen, indem man den Socket mit einem IOCTL umschaltet. Als Resultat wartet das Programm nicht auf den Verbindungspartner, sondern kann derweil etwas anderes tun.
Das Programm fragt nun innerhalb eines bestimmten Zeitintervalls ab, ob die Operation(en) schon abgeschlossen sind (indem der selbe API-Call der I/O-Operation nochmal getätigt wird). Dieses Konzept nennt man Polling und ist eine Alternative zu den blocking Sockets. Es wartet also das Betriebssystem und nicht der Thread darauf, dass die Operation abgeschlossen wird. Das Programm muss das Betriebssystem lediglich regelmäßig fragen, ob sich schon etwas getan hat.
Der Nachteil ist, dass hierbei keine Echtzeit bereitgestellt werden kann und man so nur ein mittelmäßiges Ergebnis erreicht (wenn z.B. nur alle 500ms abgefragt wird, kann sich eine I/O-Operation schon mal eine halbe Sekunde umsonst verzögern). Erhöht man die Abfragen pro Zeiteinheit, kann das allerdings sehr schnell in einen sog. busy wait-Zustand umschlagen, sodass der Prozess quasi die gesamte Rechenzeit für sinnlose Abfragen verschwendet und eben nicht blockiert, woraufhin der Scheduler den Thread nicht vorzeitig unterbrechen kann (um anderen Prozessen die Zeit zu geben).Natürlich könnte man das Programm auch ohne Pollen an einer wohldefinierten zentralen Stelle auf alle Netzwerkereignisse warten lassen (Man multiplext/überwacht also verschiedene "Kanäle" über einen einzigen Eingang). Diese Möglichkeit bietet z.B. die Funktion
select()
. Weiterhin gibt es auf Events basierende Alternativen sowie die FunktionenWSAEventSelect()
undWSAWaitForMultipleEvents()
bzw.WSAAsyncSelect()
, die das Message-System von Windows benutzen.Overlapped I/O knüpft an den non-blocking Ansatz an. Auch hier kehren die I/O-Funktionen sofort mit einem bestimmten Fehlercode zurück, blockieren den Thread dabei nicht und lassen die Operation im Hintergrund ablaufen (respektive warten). Jedoch ist hierbei kein Polling von Nöten, da sich in Winsock 2 andere Möglichkeiten etabliert haben, die z.T. unmittelbar an die Overlapped I/O anknüpfen.
Grundlage für Overlapped I/O ist die StrukturOVERLAPPED
:typedef struct _OVERLAPPED { ULONG_PTR Internal; ULONG_PTR InternalHigh; union { struct { DWORD Offset; DWORD OffsetHigh; } ; PVOID Pointer; } ; HANDLE hEvent; } OVERLAPPED, *LPOVERLAPPED; #define WSAOVERLAPPED OVERLAPPED
Die einzelnen Member waren bis auf
hEvent
"reserved for system use". In neueren Versionen bekommen sie hingegen auch für den Nutzer eine Bedeutung:- **
Internal
:** Der Fehlercode der I/O-Operation - **
InternalHigh
:** Die Anzahl der gesendeten/empfangenen Bytes - **
Offset
:** Niederer Teil der Position, ab wann im Puffer gelesen werden soll - **
OffsetHigh
:** Der höhere Teil der Position - **
Pointer
:** Reserviert, muss (noch) auf 0 gesetzt werden - **
hEvent
:** Event-Handle für I/O-Notification
Diese Struktur stellt den Schlüssel für jede asynchrone I/O-Operation dar und koordiniert den Ablauf einer asynchronen Operation.
Was ist nun der Vorteil von Overlapped I/O ggü. dem non-blocking-Ansatz? Als Resultat funktionieren beide Modelle ähnlich, jedoch gibt es Unterschiede:
Die Funktionen der Overlapped I/O nutzen die der API übergebenen Puffer zum Senden und Empfangen im Kernel-Mode und zugleich im User-Mode, sodass sie nicht hin und her kopiert werden müssen. Es wird quasi das alloziierte Array direkt an die Netzwerkkarte weitergereicht, wodurch die Overlapped-Funktionen signifikant schneller sind als z.B.send()
oderrecv()
.
Außerdem ist auf Windows die maximale Anzahl an laufenden Operationen mitselect()
und WaitForMultipleObjects() auf 64 beschränkt, da man mit dem Event-Modell nicht mehr überwachen kann. Ansätze wieWSAEventSelect()
sind für einfache Einsatzzwecke gut geeignet, jedoch ist Overlapped I/O die performanteste Methode, die Windows bietet, da die anderen Ansätze gerade in den Bereichen von tausenden parallelen Verbindungen nicht mehr ausreichend skalieren.2.2 Grundlagen
Damit ein Programm Overlapped I/O verwenden kann, müssen die Sockets mit einem speziellen Flag erstellt werden. Die neue Winsock 2-Funktion für diese Zwecke heißt
WSASocket()
.WSASocket()
funktioniert so ähnlich wie die bekanntesocket()
-Funktion:SOCKET socket = INVALID_SOCKET; // TCP-Socket erstellen, mit Overlapped-Semantik if((socket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, 0, 0, WSA_FLAG_OVERLAPPED) == INVALID_SOCKET) // Fehler /* I/O */ if(closesocket(socket) == SOCKET_ERROR) // Fehler
Die ersten 3 Parameter sind identisch mit denen von
socket()
. Der 4. ist ein Zeiger auf eineWSAPROTOCOL_INFO
-Struktur. Setzt man den Parameter auf 0, wird Winsock selbstständig nach einem geeigneten Layered Service Provider in den Protokoll-Stacks suchen, der die angegebene Adressfamilie, Protokollfamilie und den angegebenen Socket-Typ unterstützt. Der 5. Parameter ist ebenfalls optional: mit ihm kann man den Socket in eine bestimmte Socketgruppe legen oder eine neue erstellen. Der letzte Parameter nimmt keinen oder mehrere Flags (ODER-verknüpft) entgegen. Für Overlapped-I/O ist dabei dasWSA_FLAG_OVERLAPPED
-Flag von Nöten.
Anmerkung: Nur, weil es sich jetzt hierbei um einen Overlapped-Socket handelt und asynchrone Operationen mit ihm möglich sind, heißt das noch nicht, dass das ein non-blocking Socket ist. Funktionen wierecv()
werden weiterhin blockieren, sofern man den Socket nicht explizit auf non-blocking gestellt hat.
Natürlich muss auch am Ende des Programmes der Socket wieder freigegeben werden, wofür immer nochclosesocket()
zuständig ist.Als nächster Schritt werden für alle Formen von Ein- und Ausgabe
OVERLAPPED
-Objekte benötigt:WSAOVERLAPPED overlapped = { 0 }; // Auf 0 initialisieren
Da die Overlapped-Struktur an sich kein Handle oder vergleichbares ist, muss sie auch nicht manuell freigegeben werden (solange man sie nicht auf dem Heap alloziiert). Mit diesen beiden Komponenten kann jetzt die eigentliche I/O gestartet werden.
2.3 Asynchrone I/O-Operationen
Die Winsock 2-Äquivalente von
recv()
undsend()
heißenWSARecv
() undWSASend
(). Fürrecvfrom()
undsendto()
finden sich dazu ebenfallsWSARecvFrom()
undWSASendTo()
.
Das sind nur 4 von vielen anderen Funktionen, die Overlapped I/O unterstützen. In anderen Abschnitten (2.5) wird auf weitere Overlapped-Funktionen eingegangen.Im Gegensatz zu den "alten" API-Funktionen, denen man "rohen" Speicher als Puffer übergeben hatte, erwarten diese Funktionen einen Zeiger auf eine oder mehrere
WSABUF
-Struktur(en).
Die beiden Member, buf und len, sind selbsterklärend:const int buffer_size = 1024; TCHAR storage[buffer_size] = { 0 }; WSABUF buffer; buffer.len = size * sizeof(TCHAR); // Größe des Puffers, in Byte buffer.buf = reinterpret_cast<char*>(storage); // Speicherplatz
Die Funktion
WSASend()
könnte dann in etwa so aussehen:if(SOCKET_ERROR == WSASend(socket, &buffer, // Zeiger auf einen WSABUF-Puffer (o. Array) 1, // Anzahl der Elemente im WSABUF-Array 0, // versendete Bytes (Parameter nicht erforderlich) 0, // Flags - hier nicht erforderlich &overlapped, // WSAOVERLAPPED-Struktur 0)) // Completion-Routine - später mehr ;) // Fehler
Anstatt nur einen Puffer zu benutzen, kann man auch ein ganzes Array erstellen und den Zeiger per Array-To-Pointer-Decay übergeben. Der 3. Parameter gibt dann an, wie viele Puffer zu füllen sind (dieses Verfahren, I/O auf mehreren Puffern durchzuführen, nennt man Scatter/Gather I/O).
Der 4. Parameter (
DWORD bytes_transferred
) ist für Overlapepd I/O unbedeutend, da die Funktion nicht dann zurückkehrt, wenn die Operation abgeschlossen wurde, sodass die Anzahl der transferrierten Bytes hier noch nicht feststeht. Diese Angabe wird später bei der Notification gegeben, sodass dieser Parameter Verwendung findet, wenn manWSASend()
ohne Overlapped I/O durchführt. Der 5. Flag-Parameter kann wieder ein oder mehrere Flag(s) enthalten.
Der 6. Parameter ist für alle Overlapped-I/O-Funktionen gleich, er findet sich bei jeder dieser Funktionen. Hier kann ein gültiger Zeiger auf einOVERLAPPED
-Objekt angegeben werden, damit die Operation asynchron abläuft, d.h. nicht blockiert, sondern sofort zurückkehrt. Tut man dies nicht (Übergabe von 0), so wird die Funktion ähnlich wiesend()
blockieren, auch wenn der Socket mit dem FlagWSA_FLAG_OVERLAPPED
erstellt wurde.
Der letzte Parameter ist ein Funktionszeiger auf eine Completion-Routine. Dabei handelt es sich um eine Funktion (genauer: ein Callback), welche aufgerufen wird, sobald die Operation komplett (completed) ist. Siehe dazu 2.4.Die Funktion
WSARecv()
funktioniert genauso: man gibt den Socket an, einen oder mehrere Puffer, die Anzahl der Puffer, einen Zeiger, um die gesendeten Bytes zu ermitteln, Flags (hier sind Flags verbindlich, ein Parameter (DWORD-Pointer) muss angegeben werden) und ggf. dasOVERLAPPED
-Objekt (wenn die Empfangsoperation asynchron ablaufen soll, sonst blockiertWSASend()
) und zu guter Letzt die optionale Completion-Routine (später mehr dazu).Wenn man diesen Funktionen einen Zeiger auf ein
OVERLAPPED
-Objekt übergibt, wird die Funktion asynchron ablaufen. Konkret wird also die jeweilige Operation von Winsock übernommen und im Hintergrund ausgeführt, während die Funktion sofort zurückkehrt.
Der Rückgabewert der Funktion ist hierbei ähnlich wie bei den non-blocking SocketsSOCKET_ERROR
. Allerdings wird dabei implizit auch der letzte Fehlercode umgesetzt, der perWSAGetLastError()
abgefragt werden kann. Der neu gesetzte Fehlercode ist, sofern alles geklappt hat,WSA_IO_PENDING
(im Gegensatz zu non-blocking Sockets, wo er aufWSAEWOULDBLOCK
gesetzt wird). Der Code soll andeuten, dass die I/O-Operation gerade im Hintergrund läuft, aber noch nicht abgeschlossen (completed) wurde. Genau dieser Zustand findet sich auch imOVERLAPPED
-Objekt (der MemberInternal
), sobald es einer Overlapped-API-Funktion wie etwaWSARecv()
übergeben wurde.
Zusammenfassend sieht ein typischer Aufruf einer solchen I/O-Operation immer etwa so aus:if(WSARecv(...) == SOCKET_ERROR) if(WSAGetLastError() != WSA_IO_PENDING) // Ernster Fehler aufgetreten // Fehler behandeln else // Die Operation läuft nun im Hintergrund ab // Nachfolgender Code wird sofort ausgeführt
Der Vorteil dieser Möglichkeit liegt nun auf der Hand: Wir können beliebig viele Receive- oder Send-Operationen hintereinander starten und parallel ablaufen lassen, ohne zusätzliche Threads zu starten.
2.4 Notification-Modelle
Im vorigen Abschnitt wurden bereits einige Modelle für die Notification genannt:
WSAEventSelect()
mitWSAWaitForMultipleEvents()
WSAAsyncSelect()
mitGetMessage()
und der Fenster-Prozedur von Windowsselect()
- Completion-Routinen
Das erste Notification-Modell assoziiert zunächst mehrere Event-Handles mit einem Netzwerkevent (
WSAEventSelect()
), anschließend blockiert oder pollt das Programm mit WaitForMultipleObjects(Ex)() (o.WSAWaitForMultipleEvents()
) auf allen Event-Handles. Ein kleines Anwendungsbeispiel findet man in 2.5.2.1.Das
WSAAsyncSelect()
-Modell bewährt sich besonders dann, wenn man ein Programm schreibt, welches auf Window-Messages basiert bzw. mit Fenstern arbeitet. MitWSAAsyncSelect()
wird ebenfalls ein Event-Handle mit einem Netzwerkevent assoziiert, dabei wird allerdings auch ein Fenster-Handle (HWND
) übergeben. Damit wird dem Winsock SPI gesagt, dass bei einem Netzwerkevent eine Nachricht vom TypWM_SOCKET
an das angegebene Fenster geschickt werden soll. In der Fensterprozedur kann dann das Ergebnis der Operation über denlParam
undwParam
ausgewertet werden.Das
select()
-Modell ist das klassische Notification-Modell für Berkeley-Sockets. Es baut auf die Verwendung vonFD_SET
s auf und teilt diese Menge dabei inFD_SET
s für Lese-, Schreib-, oder Ausnahmefälle auf. Dabei kann man nach Wunsch entweder die Deskriptoren nach frei wählbaren Zeitintervallen pollen oder den Thread blockieren.2.4.1 Events und Overlapped-States
Die
OVERLAPPED
-Struktur stellt, wie in 2.1 erklärt, einen Member namenshEvent
zur Verfügung. Wie der Name schon andeuten lässt haben wir hier Platz für ein Event-Handle, sodass wir auf die Completion der Operation reagieren können, auf die dasOVERLAPPED
-Objekt assoziiert wurde (z.B. durch den Aufruf vonWSARecv()
).
Dennoch kümmert sich dieOVERLAPPED
-Struktur als POD-Typ nicht um seine Member, deshalb muss man sich selbst um die Events kümmern:WSAOVERLAPPED overlapped = { 0 }; // Event erstellen und in overlapped abspeichern if((overlapped.hEvent = WSACreateEvent()) == WSA_INVALID_EVENT) // Fehler /* I/O mit der OVERLAPPED-Struktur */ WSACloseEvent(overlapped.hEvent); // Event-Handle freigeben
Die
WSACreateEvent()
-Funktion ist gewissermaßen vonCreateEvent()
abgeleitet. Die Funktion gibt ein Event-Handle im non-signaled-Status zurück (das Ereignis ist noch nich eingetreten) und welches manuell zurückgesetzt werden muss, sobald es signalisiert wird (sich also im signaled-Status befindet).Wie reagiert man nun auf die Completion, besonders, wenn man mehrere Operationen und
OVERLAPPED
-Objekte hat?
Winsock stellt für die Auswertung von Overlapped I/O die API-FunktionWSAGetOverlappedResult()
zu Verfügung. Auf Wunsch ermöglicht diese Funktion das Blockieren des Threads, bis die Operation abgeschlossen ist. Dabei gibt diese Funktion die Anzahl der transferrierten Bytes, die übergebenen Flags und einen Fehlercode (bei Erfolg 0) zurück, sodass man die Operation auswerten kann. Die Funktion nutzt dabei den hEvent-Member: Solange der Status "non-signaled" ist, kann die Funktion blockieren, bis der Event-Status auf "signaled" wechselt. Diesen Wechsel interpretiert man folglich als "Operation ist abgeschlossen". Anhand dieser Funktion kann man nun z.B. auswerten, ob die Operation erfolgreich war oder scheiterte. Wenn also das Event signaled ist, kehrt die Funktion sofort zurück. Dieses Auslöser-Verhalten nennt man auch Level-Triggered.// Der Einfachheit halber wird hier auf die wichtige Überprüfung von Fehlercodes verzichtet und Parameter zum Teil weggelassen const int buffer_size = 512; SOCKET s = WSASocket(...); // Socket binden... listen(s); WSAOVERLAPPED overlapped = { 0 }; overlapped.hEvent = WSACreateEvent(); // Event mit Overlapped verbinden SOCKET client_socket = accept(...); // neue Verbindung eingehen for(;;) { WSABUF buffer; buffer.buf = reinterpret_cast<char*>(new TCHAR[buffer_size]); buffer.len = buffer_size * sizeof(TCHAR); // Asynchronen Befehl starten WSARecv(client_socket, &buffer, ..., &overlapped, 0); int ret = WSAWaitForMultipleEvents(1, // Anzahl der Event-Handles &overlapped.hEvent, // Event-Array FALSE, // warten, dass alle Event-Handles signaled sind o. nur eines? WSA_INFINITE, // wie lange soll gewartet werden? (Zeit in ms o. unendlich lange) FALSE); // alertable wait state o. nicht? WSAResetEvent(overlapped.hEvent); // Das Event vom signaled-Status auf non-signaled zurücksetzten DWORD bytes_transferred = 0, flags = 0; // OVERLAPPED auswerten WSAGetOverlappedResult(client_socket, // das Socket-Handle &overlapped, // das zur Operation zugehörige Overlapped &bytes_transferred, // Anzahl der versendeten/empfangenen Bytes 0, // nicht noch einmal warten &flags); // Flags if(!bytes_transferred) // Client hat sich getrennt // Empfangene Daten auswerten, Verbindung trennen o.ä. ... // Overlapped aufräumen und Event-Handle sichern HANDLE tmp = overlapped.hEvent; std::memset(&overlapped, 0, sizeof overlapped); overlapped.hEvent = tmp; delete buffer.buf; // Puffer freigeben }
In diesem Codeausschnitt wird ein weiterer API-Call getätigt:
WSAWaitForMultipleEvents()
. Ähnlich wieWaitForMultipleObjectsEx()
übergibt man dieser Funktion ein Array von (Event-)Handles und dessen Größe. Der 3. BOOL-Parameter (fWaitAll
) legt fest, ob die Funktion solange blockieren soll, bis alle übergebenen Handles auf signaled gewechselt sind (TRUE) oder ob es genügt, wenn ein einziges signaled-Event-Handle aus dem Array auf signaled wechselt. Der nächste Parameter legt dann die Zeit in ms fest, wie lange die Funktion blockieren soll, bevor sie zurückkehrt. Eine Zeitangabe ermöglicht so das Pollen der Handles (die Funktion gibt dannWSA_WAIT_TIMEOUT
zurück, sobald die Zeit abgelaufen ist), die Übergabe vonWSA_INFINITE
blockiert "für immer".Der letzte Parameter entscheidet, ob die Funktion den Thread in einen alertable wait state versetzt werden soll. Eine Angabe von FALSE bedeutet, dass der Thread in einen gewöhnliches wait state (also normales Blockieren) gesetzt wird.
Der alertable wait state ist ein Blockierungszustand, der neben dem Wechseln eines oder mehrerer Events auf signaled auch dann abgebrochen werden kann, wenn eine Completion-Routine (oder ein APC) aufgerufen wird (später mehr), sodass die Funktion sofortWSA_IO_COMPLETION
zurückgibt, auch wenn das/die übergebene(n) Event(s) (noch) nicht eingetreten ist/sind. Der Wartezustand ist somit von außen "alarmierbar", sodass man das Blockieren abbrechen kann.
Ein anderer, möglicher Rückgabewert dieser Funktion ist ein Arrayindex. Zieht man das MakroWSA_WAIT_EVENT_0
vom Rückgabewert der Funktion ab, erhält man den Index von genau dem Event-Handle im übergebenen Event-Array, welches auf signaled gewechselt ist (sodass man weiß, welches Event-Handle signalisiert wurde bzw. welche Operation abgeschlossen ist, was später mitWSAGetOverlappedResult()
genauer untersucht wird). Der Rückgabewert ist natürlich nur dann sinnvoll, wenn derfWaitAll
-Parameter aufFALSE
steht, andernfalls sind alle Events signaled, sobald die Funktion zurückkehrt, außer wenn der letzte Parameter der Funktion aufTRUE
gesetzt wurde, also der Thread im alertable wait state ist. Dann kann die Funktion auch hier vorzeitig zurückkehren undWSA_WAIT_IO_COMPLETION
zurückgeben (wenn eine Completion-Routine aufgerufen wurde). In diesem Falle muss man die Funktion erneut aufrufen.
Sollte irgendein Fehler unterlaufen sein, wird die FunktionWSA_WAIT_FAILED
zurückgeben. Genauer kann man die Fehlerursache dann mitWSAGetLastError()
untersuchen.Wozu nun der Call von
WSAWaitForMultipleEvents()
, wenn wir nur ein einziges Event-Handle behandeln, obwohlWSAGetOverlappedResult()
genausogut warten könnte? (4. Parameter auf TRUE)- Wenn man will, kann
WSAWaitForMultipleEvents()
pollen, währendWSAGetOverlappedResult()
immer blockiert. WSAWaitForMultipleEvents()
stellt einen alertable wait state zur Verfügung,WSAGetOverlappedResult()
nicht.- Wenn man mehrere Sockets gleichzeitig behandeln will (wie etwa bei einem komplexeren Server), kann
WSAGetOverlappedResult()
nur auf ein einziges Event imOVERLAPPED
-Objekt warten. Wenn der Thread z.B. auf die Ankunft von Daten des Clients A perWSAGetOverlappedResult()
wartet, müssen Client C, D, E, etc. ebenfalls darauf warten, bis Client A etwas gesendet hat: eine Katastrophe!
Ein Server kann in mehreren Threads die eingehenden Verbindungen annehmen und behandeln, wobei ein zentraler Thread mit
WSAWaitForMultipleEvents()
alle I/O-Operationen auf allen Sockets koordinieren kann. Für diesen Einsatz eignet sichWSAWaitForMultipleEvents()
bestens.Dennoch ist dieses Notification-Modell beschränkt:
WSAWaitForMultipleEvents()
kann nur maximalWSA_MAXIMUM_WAIT_EVENTS
Event-Handles behandeln (meist 64), sodass also nur maximal 64 Operationen gleichzeitig laufen können. Für einen High-Performance-Server ist dies deshalb keine Option.Zusammenfassend sieht also die Event-Notification von Overlapped-Operationen so aus:
- Event-Handle(s) mit
WSACreateEvent()
erstellen und demhEvent
-Member eines o. mehrererOVERLAPPED
-Objekte(s) zuweisen - Overlapped I/O-Call tätigen und
OVERLAPPED
-Objekt(e) übergeben (z.B.WSASend()
) - Mit
WSAWaitForMultipleEvents()
darauf warten, dass ein Event auf signaled wechselt (Completion der Operation) - Das Ergebnis der Operation mit
WSAGetOverlappedResult()
auswerten OVERLAPPED
-Objekt auf 0 setzten und Event-Handle perWSAResetEvent()
auf non-signaled setzten (hEvent
muss dann neu zugewiesen werden)- wiederhole von Schritt 2
2.4.2 Completion-Routinen
Der Ansatz über Events hatte einen erheblichen Nachteil: man kann nur 64 Operationen überwachen. Deshalb bietet Winsock eine weitere Möglichkeit an, den Thread von der Completion einer Operation zu informieren: Completion-Routinen.
Der letzte Parameter der Funktionen wie z.B.WSARecv()
ist ein Funktionszeiger auf eine Completion-Routine. Dabei handelt es sich, wie oben erläutert, um einen Callback-Mechanismus. Das heißt, dass diese Funktion ausgerufen wird, sobald die Operation abgeschlossen ist. Die Funktion wird dabei vom "System" asynchron aufgerufen, weshalb es sich hierbei um einen Asynchronous Procedure Call handelt.
Nachdem der Thread also aus seinem Wartezustand erwacht (die Operation also abgeschlossen wurde), wird als erstes die Liste der APCs (also der Completion-Routinen), die jeder Thread hat, abgearbeitet, bevor der folgende Code ausgeführt wird. Der Thread muss also, wie auch bei der Event-basierten Notification, warten. Dabei spielen Event-Handles keine Rolle mehr: Winsock wird duch einen Interrupt den Thread wecken, nicht durch das Umschalten eines Events auf "signaled". Daher muss nun auch derhEvent
-Member desOVERLAPPED
-Objektes nicht mehr besetzt werden.
Allerdings muss die Blockade für Windows auch dann abbrechbar sein, wenn es keine Events mehr gibt, weshalb es den alertable wait state gibt (wie im vorigen Abschnitt erläutert). Die FunktionWSAWaitForMultipleEvents()
stellte diesen Zustand zur Verfügung, da jetzt aber keine Event-Handles mehr vorhanden sind, hat diese Funktion gewissermaßen ausgedient (wenn man von einem Dummy-Event-Handle absieht). Eine Alternative istSleepEx
: Der erste Parameter ist die Zeit in ms, wie lange der Thread blockiert werden soll (Angabe vonWSA_INFINITE
möglich), der zweite ist eineBOOL
-Variable, welche aufTRUE
gesetzt den Thread in den alertable wait state versetzt. Sobald irgendeine Overlapped-Operation abgeschlossen ist, wird der Thread also geweckt und die Completion-Routine aufgerufen. Ihr wird dabei dasOVERLAPPED
-Objekt, die Zahl der transferrierten Bytes, Flags und der Fehlercode als Parameter übergeben, sodass man weiß, welche Operation abgeschlossen ist und ob sie erfolgreich war. Der Funktionsprototyp sieht so aus:void CALLBACK CompletionROUTINE( DWORD dwError, // Fehlercode, bei Erfolg 0 DWORD cbTransferred, // Anzahl der transferrierten Bytes LPWSAOVERLAPPED lpOverlapped, // Zeiger auf die Overlapped-Struktur DWORD dwFlags // Flags, die z.B. WSASend() übergeben wurden );
Hier ein Beispiel für eine Server-Anwendung:
// Aus Einfachheit globale Variablen const int buffer_size = 512; TCHAR buffer[buffer_size]; SOCKET accept_socket = INVALID_SOCKET; // Completion-Routine void CALLBACK on_received(DWORD error, DWORD bytes_transferred, WSAOVERLAPPED* overlapped, DWORD flags) { if(error) // Fehler if(!bytes_transferred) // Verbindung wurde getrennt, Socket schließen // Empfangene Daten auswerten } int main() { SOCKET listen_socket = WSASocket(...); // bind() & listen() ... // Neue Verbindung eingehen accept_socket = accept(listen_socket, 0, 0); for(;;) { // Puffer vorbereiten WSABUF buffer; buffer.buf = reinterpret_cast<char*>(buffer); buffer.len = buffer_size * sizeof(TCHAR); // Overlapped-Call vorbereiten DWORD bytes_transferred = 0, flags = 0; WSAOVERLAPPED overlapped = { 0 }; if(WSARecv(accept_socket, &buffer, 1, &bytes_transferred, &flags, &overlapped, on_received) == SOCKET_ERROR) if(WSAGetLastError() != WSA_IO_PENDING) // Fehler SleepEx(WSA_INFINITE, TRUE); // 0 - Timeout, WAIT_IO_COMPLETION - Operation abgeschlossen, Thread ist jetzt im alertable wait state // Completion-Routine wird jetzt bearbeitet std::memset(buffer, 0, buffer_size * sizeof(TCHAR)); } }
Möchte man in diesem Beispiel mehrere Sockets behandeln, könnte z.B. das blockierende
accept()
in einen anderen Thread ausgelagert werden, sodass es immer neue Verbindungen eingehen kann, während der Hauptthread die I/O vornimmt. Die Benutzung von einem Event kann dann den I/O-Thread signalisieren, dass ein neuer Socket verbunden wurde und dass auf ihm gelesen werden soll.Der Vorteil bei diesem Modell ist, dass man nicht auf maximal 64 parallele Operationen pro Thread beschränkt ist (was alles andere als skalierbar ist).
Allerdings haben die Completion-Routines auch Nachteile: viele Windows-Extensions für Winsock (z.B.AcceptEx()
) bieten nicht die Möglichkeit, Completion-Routinen zu nutzen, sodass die u.U. in einer Anwendung verschiedenen Notification-Modelle genutzt werden müssen (z.B. perWSAWaitForMultipleEvents()
). Viel gravierender jedoch ist die Tatsache, dass APCs wie die Completion-Routinen den Thread einfrieren können: Angenommen, ein Client sendet Daten an den Server, sodass die Completion-Routine vonWSARecv()
greift. Innerhalb der Routine wird, falls der Client noch mehr senden möchte, eine neueWSARecv()
-Operation gestartet. Sofern immernoch Daten auf dem Socket liegen, werden immer weiter neue APCs eingetragen. Und da Windows zu allererst die APCs abarbeitet, bevor der Thread aus dem alertable wait state zurückkehrt, kann man ihn u.U. so außer Gefecht setzten, solange der Client sendet.2.4.3 Exkurs: I/O-Completionports
Das wohl berüchtigste Notification-Modell von Windows ist der I/O-Completionport. Einfachste Notificationmodelle wie
select()
im non-blocking Mode funktionieren bei einer Single-Thread-Anwendung wunderbar. Äquivalentes gilt auch für Anwendungen, die aufWSAAsyncSelect()
und dem Message-System von Windows setzten.
Auch die Overlapped I/O funktioniert im Singlethread-Mode perfekt, gerade wenn es darum geht, sehr viele Verbindungen zu verwalten. In den gezeigten Beispielen müssten dennoch neue Threads gestartet werden, sofern man viele Verbindungen verwalten will, daaccept()
blockiert und es kein Overlapped-Interface für diese Funktion gibt. Alternativ wäre auch die Benutzung von non-blocking Sockets,WSAEventSelect()
mitFD_ACCEPT
undWSAWaitForMultipleEvents()
denkbar, um non-blocking I/O und Overlapped I/O zu kombinieren: man macht sich die Eigenschaft zu Nutze, dass non-blocking I/O und Overlapped I/O mit Event-Handles kombiniert werden können, man aber dank alertable wait state auch auf Completion-Routinen auf Seiten der Overlapped I/O setzten kann. Das macht die FunktionWSAWaitForMultipleEvents()
sehr praktisch.Auf modernerer Hardware sind viele Mehrkernprozessoren längst nicht mehr unüblich. Quadcore-Server sind keine Seltenheit, sogar Hexacore-Server sind mittlerweile mietbar. Um diese "reale Parallelität" ausnutzen zu können und möglichst alle Kerne zu belasten, sollte die Verwendung von I/O-Completionports in Betracht gezogen werden.
Das Grundprinzip basiert auf einer internen Message-Queue, in der die Completion-Notifications eingereiht sind (completion queue). Schließt also irgendeine Operation ab (wobei diese nicht Overlapped sein muss), wird intern ein Eintrag in die completion queue getätigt. Die Anwendung hat nun die Aufgabe, diese Nachrichten aus der Queue zu nehmen und auszuwerten.
Diese Aufgabe erfüllt sie in mehreren, sog. "Worker-Threads". Dieses Thread-Konglomerat befindet sich de facto in einem Wartezustand (blockiert also), bis Nachrichten in der Queue sind. Anschließend erwacht einer der Threads und bekommt alle Ergebnisse der Operation, etwa der Fehlercode, Flags, Anzahl der transferrierten Bytes etc. Wurden 2 Operationen gleichzeitig fertig, erwacht ein weiterer Worker-Thread aus seinem Wartezustand und arbeitet das Ergebnis ab, bevor er wieder wartet, bis neue Einträge in der Queue sind.
Dabei sind die Operationen nicht an einOVERLAPPED
-Objekt gebunden, sondern an das zu bearbeitene Handle, was keinesfalls immer ein Socket sein muss. Das Modell richtet sich also nach der Regel: "Sobald eine Operation auf diesem Handle abgeschlossen ist, wird eine Notification in die Queue eingereiht". Damit Winsock weiß, dass das Handle mit einer Completion-Queue verbunden ist, erstellt man in seiner Anwendung ein Handle, was dann mit dem Socket assoziiert wird. Dieses Handle nennt man I/O-Completionport.Eine genauere Erörterung und ein Beispiel folgen in Abschnitt 4. An dieser Stelle sei nur gesagt, dass dieses Notification-Modell im User-Mode das performanteste und am besten skalierende ist, was Windows bis dato bieten kann. Besonders wenn man auf Multicore-Architekturen arbeitet. Vereint mit Overlapped I/O stellt dieses Modell eine Architektur dar, die tausende Verbindungen parallel verwalten kann und dabei gut skaliert.
2.3 Windows-Erweiterungen
Um skalierbare TCP/IP-Entwicklung (besonders für Server) zu ermöglichen, ergänzte Microsoft diverse Zusatzfunktionen zu Winsock 2. Da sie nicht zum Standard-Repertoire von Winsock gehören, wurden die nachfolgend erläuterten Funktion nicht in
<WinSock2.h>
, sondern in<MSWSock.h>
deklariert. Ihre Definition findet sich in mswsock.dll, weshalb Anwendungen, die diese Funktionen nutzen wollen, ihre Programme gegen mswsock.lib linken müssten.
Dies ist jedoch problematisch, da nur 3 der Extensions aus mswsock.dll exportiert wurden. Deshalb ist es üblich, diese Funktionen dynamisch vorzuladen, sodass man auch reagieren kann, falls das Zielsystem diese Funktionen nicht bietet und Alternativen bereitstellt. Die Funktionen werden dann über Funktionszeiger perWSAIoctl()
geladen (Beispiel in 2.3.5).2.3.1 ConnectEx()
Bei
ConnectEx()
handelt es sich, wie der Name andeuten lässt, um eine Overlapped-Alternative vonconnect()
. Damit in einem einzelnen Thread mehrereconnect()
-Befehle gleichzeitig laufen können, musste man den Socket-Deskriptor bisher in den non-blocking Mode setzten. Kombiniert mit Overlapped I/O kommt man dann leider rasch in ein Notification-Gewimmel mit Event-Handles etc.
Um auch Connect-Befehle einheitlich per Overlapped-I/O steuern zu können, bietet WinsockConnectEx()
an:BOOL PASCAL ConnectEx( __in SOCKET s, // der zu verbindene Socket (muss explizit an lokales Interface gebunden sein) __in const struct sockaddr *name, // Netzwerkadresse des Ziel-Hosts __in int namelen, // Größe der Adress-Struktur in Bytes __in_opt PVOID lpSendBuffer, // zu versendene Initial-Nachricht __in DWORD dwSendDataLength, // Größe der Nachricht in Bytes __out LPDWORD lpdwBytesSent, // versendete Daten __in LPOVERLAPPED lpOverlapped // OVERLAPPED-Objekt ); typedef void (*LPFN_CONNECTEX)( );
Die ersten 3 Parameter verhalten sich genauso wie bei
connect()
. Interessant ist jedoch der optionalelpSendBuffer
: Da in den meisten Netzwerkprotokollen der Client als erster eine Nachricht sendet, kann man gleich nach einem erfolgreichen Connect eine Nachricht senden. Möchte man mit einem Client viele Verbindungen gleichzeitig öffnen, erspart man sich somit einWSASend()
pro Verbindung und schiebt es mit einem Rutsch in die Kernel-Operation des Connects mit hinein, was durchaus einen Geschwindgkeitsvorteil bringen kann, falls es speziell darauf ankommt.
Da bei dieser Funktion (wie auch bei allen anderen Extensions) auf Performance und Skalierbarkeit geachtet wurde, kopiert die Funktion zuvor mitsetsockopt()
gesetzte Einstellungen nicht auf den verbundenen Socket, sodass man dies manuell nachholen muss. Dies tut man mitsetsockopt()
undSO_UPDATE_CONNECT_CONTEXT
(Beispiel unten).
Ein weiterer Schritt, derconnect()
automatisch getan hat, ist den Socket an eine lokale Adresse zu binden.ConnectEx()
macht das nicht, sodass man hier selber eine Adresse erstellen und mitbind()
den Socket an die Adresse binden muss:#include <MSWSock.h> // Socket-Handle erstellen SOCKET connect_socket = INVALID_SOCKET; if((connect_socket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, 0, 0, WSA_FLAG_OVERLAPPED)) == INVALID_SOCKET) // Fehler // Socket-Einstellungen per setsockopt() ändern LPFN_CONNECTEX connectex = 0; // Funktionszeiger auf ConnectEx() GUID connectex_id = WSAID_CONNECTEX; // GUID von ConnectEx() DWORD bytes = 0; if(SOCKET_ERROR == WSAIoctl(connect_socket, // Socket SIO_GET_EXTENSION_FUNCTION_POINTER, // Control-Code, um Extensions zu laden &connectex_id, // die GUID übergeben sizeof connectex_id, // Größe der GUID &connectex, // der Funktionszeiger sizeof connectex, // Größe des Zeigers &bytes, // Anzahl der zurückgegbenen Bytes 0, // OVERLAPPED-Objekt (kann hier ruhig blockieren) 0)) // Completion-Routine // Fehler // Socket wird an eine lokale IPv4-Adresse gebunden: sockaddr_in saddr = { 0 }; saddr.sin_family = AF_INET; saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); saddr.sin_port = 0; // Winsock sucht einen freien Port heraus if(bind(connect_socket, reinterpret_cast<sockaddr*>(&saddr), sizeof saddr) == SOCKET_ERROR) // Fehler sockaddr_in remote = { 0 }; // Remote-Adresse füllen, DNS-Namen auflösen etc. WSAOVERLAPPED overlapped = { 0 }; // Optional: Falls Event-Notification genutzt wird, muss dem OVERLAPPED-Objekt jetzt ein Event-Handle zugewiesen werden std::basic_string<TCHAR> message = _T("Hello, Server!\r\n"); DWORD bytes_sent = 0; if(FALSE == connectex(connect_socket, reinterpret_cast<sockaddr*>(&remote), sizeof remote, const_cast<TCHAR*>(message.c_str()), // leider ist der Parameter nicht konstant, obwohl der String nicht verändert wird message.length(), &bytes_sent, &overlapped)) // Fehler // Socket-Einstellungen sichern if(SOCKET_ERROR == setsockopt(connect_socket, SOL_SOCKET, SO_UPDATE_CONNECT_CONTEXT, 0, 0)) // Fehler
2.3.2 DisconnectEx()
Die
DisconnectEx()
-Funktion ist besonders dann interessant, wenn man einen Socket nur trennen und nicht gleich freigeben möchte. Das kann z.B. sehr nützlich sein, wenn ein Server mit einem Socket-Pool arbeitet, der die Sockets schon vorher erstellt, bevor sie mit Clients verbunden werden, um so Zeit zu sparen (sieheAcceptEx()
).
Der Prototyp der Funktion sieht so aus:BOOL DisconnectEx( __in SOCKET hSocket, // der zu trennende Socket __in LPOVERLAPPED lpOverlapped, // Overlapped I/O __in DWORD dwFlags, // siehe unten __in DWORD reserved // muss 0 sein, sonst WSAEINVAL );
Damit der Socket anschließend wieder verbunden werden kann, muss man der Funktion das Flag
TF_REUSE_SOCKET
übergeben. Andernfalls kann der Socket nicht mehr benutzt werden.
Jedoch ist dabei zu erwähnen, dass Sockets nach ihrer Trennung in einem TIME_WAIT-State versetzt werden, bevor sie wieder benutzt werden können. Dieser Zustand wird von TCP selbst definiert und ist dazu da, nach einer unerwarteten Trennung die TCP-Verbindung schnell wieder aufnehmen zu können, ohne gleich einen neuen 3-way-handshake durchzuführen.
Für gewöhnlich beträgt diese Zeit 120 Sekunden, sodass die Funktion ihre Completion auch erst nach 120 Sekunden bekommt. Der Wert, wie lange der TIME_WAIT-State dauern soll, ist in der Registry unterHKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\TCPIP\Parameters\TcpTimedWaitDelay
definiert.
Das Laden der Funktion funktioniert wie beiConnectEx
, nur die entsprechenden Makros müssen ersetzt werden.2.3.3 AcceptEx()
Die
AcceptEx()
-Funktion stellt eine sehr interessante Alternative zur Berkeley-Funktionaccept()
dar. Es ist die einzige Accept-Funktion, die Overlapped-I/O zur Verfügung stellt. Ihr Prototyp sieht wie folgt aus:BOOL AcceptEx( __in SOCKET sListenSocket, // der mit listen() assoziierte Socket __in SOCKET sAcceptSocket, // Neu: diese Accept-Funktion erstellt keinen neuen Socket, er muss übergeben werden __in PVOID lpOutputBuffer, // Empfangs-Puffer für Initial-Nachrichten (ähnlich wie bei ConnectEx()) __in DWORD dwReceiveDataLength, // Länge des Puffers abzüglich 2 mal der Größe der Adressstruktur + 16 __in DWORD dwLocalAddressLength, // Größe der lokalen Adresse des Verbindungs-Endpunktes + 16 __in DWORD dwRemoteAddressLength, // Größe der Peer-Adresse des Verbindungs-Endpunktes + 16 __out LPDWORD lpdwBytesReceived, // empfangene Bytes __in LPOVERLAPPED lpOverlapped // - wie immer - der Overlapped-Pointer );
AcceptEx()
ist das skalierbare Gegenstück zuWSAAccept()
(welches intern vonaccept()
aufgerufen wird).
Da es auf Performance optimiert wurde, muss man (wie auch bei allen Extensions) ein bisschen mehr Mühe aufwenden, um diese Funktion zu handhaben. Zunächst muss die Funktion wie alle anderen Extensions dynamisch aus MSWSock.dll geladen werden (perWSAID_ACCEPTEX
undLPFN_ACCEPTEX
mitWSAIoctl()
undSIO_GET_EXTENSION_FUNCTION_POINTER
).Anders als die "gewöhnlichen" Accept-Funktionen, welche am Ende einen verbundenen Deskriptor zurückgeben, verlangt diese als Parameter einen nicht verbundenen Socket-Deskriptor, der dann die Verbindung mit dem Client repräsentiert, sobald die Operation abgeschlossen ist. Durch diesen Umstand kann der Aufruf von
WSASocket()
schon zum Beginn der Anwendung verlagert werden. So ist es auch möglich, einen Socket-Pool zu verwalten, der gleich tausende von Deskriptoren am Anfang des Programmes erstellt, sodass die Zeit zum Erstellen des Sockets beim Eingehen der Verbindung gespart werden kann.
Der 3. Parameter (lpOutputBuffer
) ist gewissermaßen das Gegenstück zum Initialbuffer inConnectEx()
: Da in den meisten Anwendungsprotokollen als erstes der Server etwas empfängt, beinhaltetAcceptEx()
gleich eine Receive-Operation, um ein Request zu empfangen. Dadurch ist es möglich, ohne zwischen Usermode und Kernelmode zu wechseln, gleich 2 I/O-Operationen in einem Rutsch abzuhandeln, was besonders bei zeitkritischen Servern weitere Geschwindigkeitsvorteile bringen kann.
Die 2. Aufgabe dieses Puffers ist das Speichern der beiden Adressen, zwischen denen die Verbindung besteht. Wie man die Adressen aus dem Array extrahiert, wird im nächsten Abschnitt erläutert. Intern befindet sich am Anfang des Puffers die Adressen, im hinteren Teil werden die empfangenen Daten gespeichert.Anmerkung: Die Benutzung dieses Puffers zum Empfangen von Initialnachrichten hat aber auch Auswirkungen auf das Completion-Verhalten der Funktion, sodass eine potentielle Sicherheitslücke entstehen kann. Was das bedeutet und wie man dem zuvorkommt, wird im Abschnitt "Sicherheitsaspekte" näher erläutert.
Die übergebene Größe des Puffers darf nicht die Zahl an Bytes beinhalten, die für die Adressen im Puffer benutzt werden, weshalb am Ende diese Werte abgezogen werden müssen. Eine Angabe von 0 bedeutet, dass keine Initialnachricht empfangen werden soll.
Die beiden Parameter
dwLocalAddressLength
unddwRemoteAddressLength
legen die Größe der Adressstruktur fest, die jedoch aus gewissen Gründen beide mit mindestens 16 addiert werden müssen (sprich z.B.sizeof(sockaddr_in) + 16
), damit die Adressen in den Puffer geschrieben werden können. Die addierte Zahl dieser beiden Parameter muss im vorigen Parameter dann von der Puffergröße abgezogen werden.Die Funktion kehrt bei Angabe eines Overlapped-Objektes sofort zurück und verhält sich wie jede andere Overlapped-Operation. Interessant ist hier die Möglichkeit, mehrere Accept-Operationen gleichzeitig durchzuführen, um evtl. Wartezeiten zu minimieren (mehr im Abschnitt "Hinweise").
Ähnlich wie
ConnectEx()
übernimmt auch diese Funktion nicht die Socket-Optionen des Listening-Sockets. Diese werden genauso wie beiConnectEx()
mit der Funktionsetsockopt()
und der Socket-OptionSO_UPDATE_ACCEPT_CONTEXT
weiter vererbt (für ein Beispiel siehe "ConnectEx()
")2.3.3.1 GetAcceptExSockaddrs()
Um aus dem
lpOutputBuffer
derAcceptEx()
-Funktion die Adressen zu extrahieren, bedient man sich einer weiteren Extension:GetAcceptExSockaddrs()
. Diese Funktion wird wie immer aus MSWSock.dll mit den MakrosLPFN_GETACCEPTEXSOCKADDRS
undWSAID_GETACCEPTEXSOCKADDRS
geladen.
Der Prototyp hat folgenden Aufbau:void GetAcceptExSockaddrs( __in PVOID lpOutputBuffer, // der Puffer, der AcceptEx() übergeben wurde __in DWORD dwReceiveDataLength, // Länge des Puffers, identisch mit der von AcceptEx() __in DWORD dwLocalAddressLength, // Größe des Puffer-Platzes für die Adress-Struktur (wie AcceptEx() mit 16 addierte Größe der Adresse) __in DWORD dwRemoteAddressLength, // dito __out LPSOCKADDR *LocalSockaddr, // Zeiger auf einen Adress-Zeiger für lokale Adresse __out LPINT LocalSockaddrLength, // Zeiger auf die Größe der Adress-Struktur __out LPSOCKADDR *RemoteSockaddr, // das selbe für die Remote-Adresse __out LPINT RemoteSockaddrLength // dito );
Wichtig ist, dass die ersten 4 Parameter mit denen von
AcceptEx()
identisch sind, besonders die Addition mit 16. Als Ergebnis hat man nun die Adresse des Peers, der die Verbindungsoperation eingeleitet hat.2.3.3.2 Sicherheitsaspekte
Wie am Anfang erläutert wurde, ist die Anwendung von
AcceptEx()
mit einem Initialpuffer kritisch, sofern keine speziellen Vorkehrungen getroffen werden.
Falls die Größe des übergebenen Puffers größer als 0 ist (also wenn der Parameter, der die Größe des Puffers angibt, größer als 0 ist), wirdAcceptEx()
zusätzlich auf Initialdaten warten, als hätte man einenWSARecv()
-Call getätigt. Dadurch wird die Completion vonAcceptEx()
nicht etwa dann eintreten, sobald der Client verbunden ist, sondern erst dann, wenn er mindestens 1 Byte gesendet hat!
Dadurch könnte ein Angreifer zwar eine Verbindung aufbauen, sofern er jedoch keine Daten sendet, unterdrückt er die Completion, in der üblicherweise ein neuerAcceptEx()
-Call folgen würde. Das Resultat dieser Attacke verhindert somit weitere Verbindungen zu neuen Clients mit dem Server. Man nennt diese Methode "stale connection", also "veraltete" Verbindung.Aufgrund dieses Problems wurde leider häufig auf
AcceptEx()
und somit auf eine asynchrone Accept-Operation verzichtet (sofern man nicht non-blocking Sockets nutzt). Nebenbei: auch das C#-ÄquivalentBeginAccept()
, welches intern aufAcceptEx()
basiert, hat dieses Problem.
Die vom MSDN vorgeschlagene Lösung zu diesem Problem ist naheliegend: die Anwendung muss irgendwie alle offenen Verbindungen überprüfen und solche schließen, die einenAcceptEx()
-Call in seiner Completion blockieren.
Hierbei stellen sich nun jedoch 2 Fragen:- Woher weiß die Anwendung, wann
AcceptEx()
blockiert wird? - Wie findet sie heraus, welche Verbindung (also welcher Socket-Deskriptor) für diese Blockade verantwortlich ist?
Um das erste Problem zu lösen, muss man sich einer in vorigen Abschnitten schon erläuterten Technik zu Nutze machen: die Events.
Die Notification-Technik der Events in Verbindung mitWSAEventSelect()
undWSAWaitForMultipleEvents()
schafft Abhilfe. Um dieses Verfahren zu erläutern, ist es zunächst wichtig zu verstehen, wie Winsock bei einer Accept-Operation arbeitet:Sobald die Funktion
listen()
aufgerufen wurde, befindet sich der übergebene Socket im von TCP definierten Listening-Mode, er horcht also auf dem Port nach neuen Verbindungen. Ab diesem Punkt kann schon der 3-Way-Handshake bei einer Verbindungsanfrage durchgeführt werden. Der sich verbindene Peer wird eine erfolgreiche Verbindungsaufnahme bestätigen, auch wenn der Server (bis jetzt) keine Accept-Operation gestartet hat.
Derlisten()
-Funktion wird neben dem Socket-Deskriptor noch ein weiterer Parameter übergeben, und zwar den der Größe der Backlog-Queue. Diese Queue stellt eine Art Warteschlange zwischen dem erfolgreichen 3-Way-Handshake und dem Accept-Aufruf dar. Die größtmögliche Queue-Länge ist inSOMAXCONN
definiert. Sobald eine Accept-Funktion aufgerufen wurde, wird in der Anwendung ein neuer Socket imEstablished-Mode
erstellt und der Client aus der Backlog-Queue entfernt.
Der Grund für die Einführung dieser Technik ist die Tatsache, dass eine blockierende Anwendung mitaccept()
bei sehr vielen Clients schlecht skaliert und dementsprechend langsam ist. Wenn sich nun ein Client verbindet, aber der Server gerade noch mit einer anderen Verbindung zu tun hat, müsste die Verbindung nach einem gewissen Timeout abgelehnt werden. Um dem zuvor zu kommen, wird stattdessen jede Verbindung akzeptiert und ein Verweis in die Queue geschrieben, bis sich ein Accept-Call als "gnädig" erweist und die Verbindung tatsächlich etabliert. Erst wenn die Backlog-Queue voll ist, wird die nächste Verbindungsabfrage abgelehnt.
Sofern die Anwendung gleich nach demlisten()
-Aufruf eine Accept-Operation gestartet hat (unabhängig davon, ob sie blockierend/overlapped ist oder nicht), wird ein Client gar nicht erst in die Queue geschrieben, sondern sofort behandelt.
Das ist der Punkt, wie das eigentliche Problem mit den stale Clients undAcceptEx()
gelöst werden kann: wenn Verbindungen dieAcceptEx()
in ihrer Completion blockieren, indem sie keine Daten senden, werden neue Clients nicht mehr behandelt, trotzdem werden sie eine erfolgreiche Verbindung bestätigen, obwohl sie jetzt in der Backlog-Queue gelandet sind. Man kann also davon ausgehen, dass einAcceptEx()
-Call genau dann von einem Stale Client blockiert wird, wenn der erste Client in der Backlog-Queue landet, da er (bis jetzt) von keinem Accept-Call dort hinaus geholt wurde (der Fall, dass der Server tatsächlich überlastet sein könnte kann hier getrost vernachlässigt werden: Overlapped I/O skaliert wesentlich besser und meistens wird mit mehrerenAcceptEx()
-Calls gearbeitet, sodass kaum Verzögerungen auftreten sollten; später mehr).
Und hier schließt sich der Bogen mit dem Event-Modell undWSAEventSelect()
: Wenn man ein Event-Handle perWSAEventSelect()
mit dem EreignisFD_ACCEPT
assoziiert, wird das Event genau dann auf signaled umgestellt, wenn eine Verbindung in der Backlog-Queue gelandet ist (damit man weiß, wann ein Accept-Call nicht blockiert) - Bingo!Jetzt, wo die Anwendung weiß, dass kein
AcceptEx()
-Call zur Verfügung steht, muss sie Gegenmaßnahmen einleiten (um das oben genannte 2. Problem zu lösen). Der Server hat erste Clients in der Backlog-Queue und weiß, dass eine Verbindung denAcceptEx()
-Call blockiert. Auch wenn die Accept-Operation nicht abgeschlossen ist, ist der Status der Verbindung, die blockiert, auf "Established" (was sie im Grunde nach jedem erfolgreichen 3-Way-Handshake ist, auch wenn keine Accept-Operation gestartet wurde).
Die Funktiongetsockopt
mitSO_CONNECT_TIME
gibt die Zeit in Sekunden zurück, wie lange bereits die Verbindung besteht. Ist ein Socket nicht verbunden, ist die Zeit -1. Um die Verbindung zu finden, muss man nun mit einer Schleife jeden Socket-Deskriptor auf seine Verbindungsdauer überprüfen und testen, ob auf ihm schon Bytes gesendet/empfangen wurden (dafür eignet sich z.B. ein bool-Flag, was nach einer Completion mitAcceptEx()
- mit Initialbuffer - auf true gesetzt wird). Sofern eine Verbindung z.B. schon länger als 1 Sekunde offen ist, das Flag jedoch immer noch auf false steht, kann der Socket entweder mitclosesocket()
geschlossen oder die Verbindung mitDisconnectEx()
beendet werden - in beiden Fällen wirdAcceptEx()
dann mit einem Fehler komplettiert.Um zu überprüfen, wann das Event auf signaled gesetzt wird, muss man eine blockierende Funktion, z.B.
WSAWaitForMultipleEvents()
nutzen. Um dabei aber nicht die restliche Anwendung zu stören, lohnt es sich, für den Prozess derAcceptEx()
-Überwachung einen neuen Thread zu starten und ihn mitWSAWaitForMultipleEvents()
so lange schlafen zu legen, bis Clients in der Backlog-Queue landen.Beispielcode:
struct socket_info { SOCKET socket; bool something_transferred; // bei der ersten AcceptEx()-, WSASend()-, WSARecv()-Operation auf true setzten }; struct accepex_context { // deque eignet sich sehr gut zum hinzufügen/entfernen von Elementen am Ende der Struktur, besser als vector std::deque<socket_context> clients; HEVENT event_handle; lock lck; // Synchronisierbares Beispiel-Objekt zwecks Threadsicherheit, da die STL nicht threadsafe ist }; unsigned acceptex_watchdog(void*); SOCKET listen_socket = WSASocket(...); acceptex_context ac; // ... initialisieren ... ... // Erstelle ein neues Event für den Stale Client-Fall ac.event_handle = WSACreateEvent(); // Assoziiere es mit FD_ACCEPT, jetzt wird das Event auf signaled gesetzt, wenn ein Client in der Backlog-Queue landet if(WSAEventSelect(listen_socket, ac.event_handle, FD_ACCEPT) == SOCKET_ERROR) // irgendwas ist falsch gelaufen... HANDLE watchdog_thread; // Der blockierte Thread, der im Falle eines Angriffs AcceptEx() befreien soll if(!(watchdog_thread = _beginthreadex(0, 0, acceptex_watchdog, &ac, 0, 0) ) // Watchdog-Thread konnte nicht gestartet werden! ... unsigned acceptex_watchdog(void* param) { acceptex_context* ac_ptr = static_cast<acceptex_context*>(param); for(;;) { int ret = 0; // der letzte Parameter ist auf TRUE, sodass man per APC den Thread beenden kann if((ret = WSAWaitForMultipleEvents(1, ac_ptr->event_handle, TRUE, WSA_INFINITE, TRUE)) == WSA_WAIT_FAILED) // Fehler! if(ret == WSA_WAIT_IO_COMPLETION) // APC wurde aufgerufen --> Anwendung soll beendet werden --> Thread beenden return 0; mutex mtx(ac->lck); // Ab jetzt muss die deque synchronisiert werden for(std::deque<socket_info>::size_type n = 0; n < ac->clients.size(); ++n) { int time = 0, len = sizeof(int); getsockopt(ac->clients[n].socket, SOL_SOCKET, SO_CONNECT_TIME, reinterpret_cast<char*>(&time), &len); if(time > 2 && !ac->clients[n].something_transferred) { // Der Client ist seit über 2 Sekunden verbunden, hat aber noch nichts gesendet // jetzt könnte clossocket() o. DisconnectEx() folgen, verbunden mit dem Löschen des Deskriptors aus der deque // Option: IP-Adress-Filter einbauen, um Verbindung sofort zu kappen, die mehrmals schlecht durch so ein Verhalten auffiel ... // jetzt wird AcceptEx() mit einem Fehler completet haben, sodass ein neuer Call getätigt werden kann } } WSAResetEvent(ac_ptr->event_handle); } }
-
Ein allgemeines Beispiel für das Verhalten einer Anwendung ist kaum möglich, da hierbei schon viele designtechnische Aspekte (Threadsicherheit, RAII etc.) festgelegt wurden. Dieses Beispiel ist aber gut genug, um eine grundlegende Technik zur Vermeidung von Stale Clients zu demonstrieren.
Verbessern kann man den Code z.B., indem man imacceptex_context
nur diese Sockets zu speichert, welche sich gerade einem laufendenAcceptEx()
-Prozess befinden, wobei die Größe der Struktur nie verändert wird. Bei korrektem, indizierten Zugriff auf die Speicherstruktur (indem man stehts den Index speichert) kann so auf eine Synchronisation verzichtet und unnötiges Warten vermieden werden, da sich die Threads nie in die Quere kommen.2.3.3.3 [c]AcceptEx()[/c] vs. [c]WSAAccept()[/c]
Zugegeben, die vorigen Abschnitte waren etwas kompliziert und motivieren wohl kaum jemanden,
AcceptEx()
dem guten altenaccept()
(was intern aufWSAAccept()
setzt) vorzuziehen. Dem sei aber gesagt: höchste Performance hat seinen Preis.
Die Vorteile vonWSAAccept()
sind simplerer Natur:- Es ist einfacher: keine Stale-Clients, Events, Overlapped-I/O, Asynchrone Notifications etc.
- Man muss diese Extension nicht erst aus anderen DLLs laden.
- Sie haben "Eye-Candy"-Funktionen wie Conditional Accept (wobei man auf diese Möglichkeit aufgrund von SYN-Attacken verzichten sollte).
Es bleibt dabei: für simple Netzwerkanwendungen reicht non-blocking I/O mit Dingen wie
WSAAccept()
vollkommen aus. Für High-Perfomance und beste Skalierbarkeit gibt es Overlapped I/O und WinSock-Extensions.2.3.3.4 Hinweise
Es ist immer noch nicht alles zu
AcceptEx()
gesagt. Da es sich hierbei um eine Overlapped-Funktion handelt, ist es möglich, mehrere Accept-Operationen gleichzeitig im Hintergrund durchzuführen. Damit ist es möglich, die Backlog-Queue lange freizuhalten und gleichzeitig mehrere Verbindungsanfragen abzuarbeiten.
Die praktische Umsetzung ist denkbar simpel: mit einer Schleife werden n Calls getätigt. Die Anzahl n kann hierbei sehr variabel bleiben: je nach Server-Auslastung kann man in einer "Rush-Hour" n erhöhen, in ruhigeren Zeiten kann es zurückgesetzt werden.
Wie groß dieses n nun sein soll, kann man nicht pauschal sagen, da das zu sehr von der Hardware abhängig ist. Für optimale Performance müsste man deshalb verschiedene Größen ausprobieren und Zeitmessungen durchführen. Vorweg: es kann auch schädlich sein, zu viele Overlapped I/O-Operationen wieAcceptEx()
gleichzeitig laufen zu haben, da sie alle einen gewissen Teil an nicht-auslagerungsfähigen Speicher belegen. Das ist Speicher, der also nicht in die Auslagerungsdatei geschoben wird und deshalb schneller, wertvoller und begrenzt ist (wird fachlich nonpageable pool genannt und meistens von Treibern mit Low-IRQLs benutzt).3. Zusammenfassung
So, das war der erste von zwei Teilen über die WinSock2-API. In diesem Artikel wurde erläutert, was synchrone und asynchrone Kommunikation bedeutet, wo ihre Stärken liegen und wie man sie mit der Overlapped-Technologie umsetzt. Außerdem wurden kurz ein paar der Notifikationsmodelle, ebenso wie die I/O-Completionports vorgestellt. Als dritter Bereich wurden die wichtigsten WinSock-Extensions und ihr Umgang erläutert sowie mit den herkömmlichen Methoden verglichen.
Im 2. Teil dieser Artikelserie werden zunächst weitere WinSock-Extensions erläutert. Anschließend wird es eine ausführlichen Abschnitt über I/O-Completionports und deren Semantik geben. Zu guter Letzt wird man einen Überblick über Netzwerkprogrammierung finden, die von der verwendeten IP-Version (v4 oder v6) unabhängig ist, sodass man Server und Clients programmieren kann, die beide Protokolle gleichzeitig verstehen.Ich hoffe, dass der Artikel gut verständlich, anschaulich und für den einen oder anderen nützlich ist.
4. Quellen & Links
- MSDN - Ist zwar auf Englisch, aber hochinformativ und beleuchtet einzelne Details sehr genau
- Anthony Jones, Jim Ohlund: Network Programming for Microsoft Windows, Second Edition. Microsoft Press, Redmond (Washington), 2002 - Ein wunderbares Buch mit noch tieferen Einblicken in WinSock, Layered Service Providern, NetBIOS uvm.
- Ein nettes Tutorial - an das Buch oben angelehnt mit vielen Beispielen
-
Nett, hatte mich immer um die asynchronen Sockets gedrückt, aber jetzt hab ich ja einen guten Einstieg gefunden Alles verständlich erklärt, danke!
-
Hallo Jodocus,
wird Teil 2 jemals erscheinen jetzt wo GPC aufgegeben hat?
-
Muss mal schauen, wann ich Zeit habe. Momentan ist für Teil 2 gerade mal das Inhaltsverzeichnis fertig, deshalb kann es leider noch etwas dauern, bis ich was vorstellen kann.
Dass das Magazin ausgesetzt wurde, ist insofern nicht so schlimm. Ich kann den Artikel fertig machen, nur ist dann eben nicht mehr 100%ig gewährleistet, dass alles 100%ig richtig ist. Soll heißen, es gibt einfach etwas weniger Qualitätskontrolle.
-
Die Erweiterung die in Windows Server 8 kommen wird, sieht auch interessant aus:
Registered IO (RIO) – Winsock extension APIs that pins buffers in user space, which reduces CPU processing of each message