epoll- und Windows IO-Abschlussports: der praktische Unterschied

Einführung


In diesem Artikel werden wir versuchen zu verstehen, wie sich der Epoll-Mechanismus in der Praxis von den Abschlussports unterscheidet (Windows I / O Completion Port oder IOCP). Dies kann für Systemarchitekten interessant sein, die Hochleistungsnetzwerkdienste entwerfen, oder für Programmierer, die Netzwerkcode von Windows nach Linux oder umgekehrt portieren.

Beide Technologien sind sehr effektiv für die Handhabung einer großen Anzahl von Netzwerkverbindungen.

Sie unterscheiden sich von anderen Methoden in folgenden Punkten:

  • Es gibt keine Einschränkungen (mit Ausnahme der Gesamtsystemressourcen) für die Gesamtzahl der beobachteten Deskriptoren und Ereignistypen
  • Die Skalierung funktioniert recht gut. Wenn Sie bereits N Deskriptoren überwachen, nimmt der Wechsel zur Überwachung von N + 1 nur sehr wenig Zeit und Ressourcen in Anspruch
  • Es ist einfach genug, einen Thread-Pool zu verwenden, um Ereignisse parallel zu verarbeiten
  • Es macht keinen Sinn, einzelne Netzwerkverbindungen zu verwenden. Alle Vorteile zeigen sich ab 1000 Verbindungen

Um all das zu paraphrasieren: Beide Technologien wurden entwickelt, um Netzwerkdienste zu entwickeln, die viele eingehende Verbindungen von Clients verarbeiten. Gleichzeitig gibt es einen signifikanten Unterschied zwischen ihnen und bei der Entwicklung derselben Dienste ist es wichtig, diese zu kennen.

(Aktualisierung: Dieser Artikel ist eine Übersetzung )


Art der Benachrichtigungen


Der erste und wichtigste Unterschied zwischen Epoll und IOCP besteht darin, wie Sie über ein Ereignis informiert werden.

  • epoll sagt Ihnen, wann der Deskriptor bereit ist, etwas damit zu tun - " jetzt können Sie mit dem Lesen der Daten beginnen "
  • IOCP teilt Ihnen mit, wann der angeforderte Vorgang abgeschlossen ist. " Sie haben darum gebeten, die Daten zu lesen, und hier werden sie gelesen. "

Bei Verwendung der Epoll-App:

  • Legt fest, welche Operation mit einem Deskriptor ausgeführt werden soll (Lesen, Schreiben oder beides).
  • Legt die entsprechende Maske mit epoll_ctl fest
  • Ruft epoll_wait auf, das den aktuellen Thread blockiert, bis mindestens ein erwartetes Ereignis eintritt (oder das Timeout abläuft).
  • Iteriert über die empfangenen Ereignisse und zeigt auf den Kontext (aus dem Feld data.ptr).
  • Initiiert die Ereignisverarbeitung entsprechend ihrem Typ (Lesen, Schreiben oder beide Operationen)
  • Nach Abschluss des Vorgangs (was sofort geschehen sollte) wird weiterhin auf den Empfang / das Senden von Daten gewartet

Bei Verwendung der IOCP-Anwendung:

  • Initiiert eine Operation (ReadFile oder WriteFile) für einen Deskriptor unter Verwendung des nicht leeren OVERLAPPED-Arguments. Das Betriebssystem fügt die Anforderung, diese Operation auszuführen, sich selbst in der Warteschlange hinzu, und die aufgerufene Funktion wird sofort (ohne auf den Abschluss der Operation zu warten) zurückgegeben.
  • Ruft GetQueuedCompletionStatus () auf , der den aktuellen Thread blockiert, bis genau eine der zuvor hinzugefügten Anforderungen abgeschlossen ist. Wenn mehrere abgeschlossen sind, wird nur einer ausgewählt.
  • Es verarbeitet die empfangene Benachrichtigung über den Abschluss des Vorgangs mit dem Abschlussschlüssel und einem Zeiger auf OVERLAPPED.
  • Wartet weiterhin auf den Empfang / das Senden von Daten

Der Unterschied in der Art der Benachrichtigungen macht es möglich (und ziemlich trivial), IOCP mit epoll zu emulieren. Zum Beispiel macht das Wine- Projekt genau das. Das Gegenteil ist jedoch nicht so einfach. Selbst wenn Sie erfolgreich sind, führt dies wahrscheinlich zu einem Leistungsverlust.

Datenverfügbarkeit


Wenn Sie Daten lesen möchten, sollte Ihr Code eine Art Puffer haben, in dem Sie sie lesen möchten. Wenn Sie Daten senden möchten, sollte ein Puffer mit Daten vorhanden sein, die zum Senden bereit sind.

  • epoll ist überhaupt nicht besorgt über das Vorhandensein dieser Puffer und verwendet sie in keiner Weise
  • IOCP diese Puffer werden benötigt. Der springende Punkt bei der Verwendung von IOCP ist die Arbeit im Stil von "Lesen Sie mir 256 Bytes von diesem Socket in diesen Puffer". Wir haben eine solche Anfrage erstellt, sie an das Betriebssystem weitergeleitet und warten auf die Benachrichtigung über den Abschluss des Vorgangs (und berühren den Puffer zu diesem Zeitpunkt noch nicht!).

Ein typischer Netzwerkdienst arbeitet mit Verbindungsobjekten, die Deskriptoren und zugehörige Puffer zum Lesen / Schreiben von Daten enthalten. In der Regel werden diese Objekte zerstört, wenn der entsprechende Socket geschlossen wird. Dies führt zu Einschränkungen bei der Verwendung von IOCP.

IOCP fügt den Warteschlangenanforderungen zum Lesen und Schreiben von Daten hinzu. Diese Anforderungen werden in der Reihenfolge der Warteschlange ausgeführt (d. H. Einige Zeit später). In beiden Fällen müssen die übertragenen Puffer bis zum Abschluss der erforderlichen Vorgänge bestehen bleiben. Darüber hinaus kann man während des Wartens nicht einmal Daten in diesen Puffern ändern. Dies bringt wichtige Einschränkungen mit sich:

  • Sie können keine lokalen Variablen (auf dem Stapel platziert) als Puffer verwenden. Der Puffer muss validiert werden, bevor der Lese- / Schreibvorgang abgeschlossen ist, und der Stapel wird zerstört, wenn die aktuelle Funktion beendet wird
  • Sie können den Puffer nicht im laufenden Betrieb neu zuweisen (es stellte sich beispielsweise heraus, dass Sie mehr Daten senden müssen und den Puffer vergrößern möchten). Sie können nur einen neuen Puffer und eine neue Sendeanforderung erstellen
  • Wenn Sie so etwas wie einen Proxy schreiben und dieselben Daten gelesen und gesendet werden, müssen Sie zwei separate Puffer für sie verwenden. Sie können das Betriebssystem nicht auffordern, Daten in einem Puffer in einer Anforderung zu lesen, und in einer anderen Anforderung diese Daten direkt dorthin senden
  • Sie müssen sorgfältig überlegen, wie Ihre Verbindungsmanagerklasse die einzelnen Verbindungen zerstört. Sie sollten die volle Garantie haben, dass zum Zeitpunkt der Zerstörung der Verbindung keine einzige Anforderung zum Lesen / Schreiben von Daten mithilfe der Puffer dieser Verbindung besteht

Für IOCP-Vorgänge muss außerdem ein Zeiger auf eine OVERLAPPED-Struktur übergeben werden, die bis zum Abschluss des erwarteten Vorgangs weiterhin vorhanden sein muss (und nicht wiederverwendet werden darf). Dies bedeutet, dass Sie, wenn Sie Daten gleichzeitig lesen und schreiben müssen, nicht von der OVERLAPPED-Struktur erben können (eine Idee, die häufig in den Sinn kommt). Stattdessen müssen Sie die beiden OVERLAPPED-Strukturen in Ihrer eigenen Klasse speichern und eine davon an Leseanforderungen und die andere an Schreibanforderungen übergeben.

epoll verwendet keine vom Benutzercode an ihn übergebenen Puffer, daher haben all diese Probleme nichts damit zu tun.

Wartebedingungen ändern


Das Hinzufügen einer neuen Art von erwarteten Ereignissen (zum Beispiel haben wir auf die Gelegenheit gewartet, Daten aus dem Socket zu lesen, und jetzt wollten wir sie auch senden können) ist sowohl für epoll als auch für IOCP möglich und recht einfach. Mit epoll können Sie die Maske der erwarteten Ereignisse ändern (jederzeit, auch von einem anderen Thread aus), und mit IOCP können Sie einen anderen Vorgang starten, um auf einen neuen Ereignistyp zu warten.

Das Ändern oder Löschen erwarteter Ereignisse ist jedoch anders. Mit epoll können Sie die Bedingung weiterhin ändern, indem Sie epoll_ctl aufrufen (auch von anderen Threads). IOCP wird schwieriger. Wenn eine E / A-Operation geplant war, kann sie durch Aufrufen der Funktion CancelIo () abgebrochen werden. Schlimmer noch, nur derselbe Thread, der den ersten Vorgang gestartet hat, kann diese Funktion aufrufen. Alle Ideen zur Organisation eines separaten Kontrollflusses sind gegen diese Einschränkung verstoßen. Darüber hinaus können wir auch nach dem Aufruf von CancelIo () nicht sicher sein, dass der Vorgang sofort abgebrochen wird (möglicherweise wird er bereits ausgeführt, er verwendet die OVERLAPPED-Struktur und den übergebenen Puffer zum Lesen / Schreiben). Wir müssen noch warten, bis der Vorgang abgeschlossen ist (das Ergebnis wird von der Funktion GetOverlappedResult () zurückgegeben), und erst danach können wir den Puffer freigeben.

Ein weiteres Problem mit IOCP besteht darin, dass ein für die Ausführung geplanter Vorgang nicht mehr geändert werden kann. Beispielsweise können Sie die geplante ReadFile-Anforderung nicht ändern und angeben, dass Sie nur 10 Byte und nicht 8192 lesen möchten. Sie müssen den aktuellen Vorgang abbrechen und einen neuen starten. Dies ist kein Problem für epoll, das zu Beginn der Wartezeit keine Ahnung hat, wie viele Daten Sie zum Zeitpunkt der Benachrichtigung über die Fähigkeit zum Lesen von Daten lesen möchten.

Nicht blockierende Verbindung


Einige Implementierungen von Netzwerkdiensten (verwandte Dienste, FTP, p2p) erfordern ausgehende Verbindungen. Sowohl epoll als auch IOCP unterstützen eine nicht blockierende Verbindungsanforderung, jedoch auf unterschiedliche Weise.

Bei Verwendung von epoll ist der Code im Allgemeinen derselbe wie für select oder poll. Sie erstellen einen nicht blockierenden Socket, rufen connect () auf und warten auf eine Benachrichtigung über die Verfügbarkeit zum Schreiben.

Wenn Sie IOCP verwenden, müssen Sie die separate ConnectEx-Funktion verwenden, da der Aufruf von connect () die OVERLAPPED-Struktur nicht akzeptiert. Dies bedeutet, dass später keine Benachrichtigung über die Änderung des Socket-Status generiert werden kann. Der Verbindungsinitiierungscode unterscheidet sich also nicht nur vom Code mit epoll, sondern sogar vom Windows-Code mit select oder poll. Die Änderungen können jedoch als minimal angesehen werden.

Interessanterweise funktioniert accept () wie gewohnt mit IOCP. Es gibt eine AcceptEx-Funktion, deren Rolle jedoch völlig unabhängig von einer nicht blockierenden Verbindung ist. Dies ist kein "nicht blockierendes Akzeptieren", wie Sie vielleicht analog zu connect / ConnectEx denken.

Ereignisüberwachung


Oft kommen nach dem Auslösen eines Ereignisses sehr schnell zusätzliche Daten. Zum Beispiel hatten wir erwartet, dass Eingaben vom Socket mit epoll oder IOCP ankommen, wir haben ein Ereignis über die ersten paar Datenbytes erhalten, und genau dort, während wir sie lesen, kamen weitere hundert Bytes. Kann ich sie lesen, ohne die Ereignisüberwachung neu zu starten?

Die Verwendung von Epoll ist möglich. Sie erhalten das Ereignis "Jetzt kann etwas gelesen werden" - und Sie lesen alles, was aus dem Socket gelesen werden kann (bis Sie den EAGAIN-Fehler erhalten). Das Gleiche gilt für das Senden von Daten: Wenn Sie ein Signal empfangen, dass der Socket zum Senden von Daten bereit ist, können Sie etwas in das Socket schreiben, bis die Schreibfunktion EAGAIN zurückgibt.

Mit IOCP funktioniert dies nicht. Wenn Sie den Socket gebeten haben, 10 Byte Daten zu lesen oder zu senden, wird so viel gelesen / gesendet (auch wenn bereits mehr getan werden könnte). Für jeden nachfolgenden Block müssen Sie mithilfe von ReadFile oder WriteFile eine separate Anforderung stellen und dann warten, bis sie ausgeführt wird. Dies kann zu einer zusätzlichen Komplexität führen. Betrachten Sie das folgende Beispiel:

  1. Die Socket-Klasse hat eine Anforderung zum Lesen von Daten durch Aufrufen von ReadFile erstellt. Die Threads A und B warten auf das Ergebnis, indem sie GetOverlappedResult () aufrufen.
  2. Nachdem der Lesevorgang abgeschlossen war, erhielt Thread A eine Benachrichtigung und rief eine Socket-Klassenmethode auf, um die empfangenen Daten zu verarbeiten
  3. Die Socket-Klasse hat entschieden, dass diese Daten nicht ausreichen. Wir sollten Folgendes erwarten. Es wird eine weitere Leseanforderung gestellt.
  4. Diese Anfrage wird sofort ausgeführt (Daten sind bereits eingetroffen, das Betriebssystem kann sie sofort senden). Stream B empfängt eine Benachrichtigung, liest die Daten und übergibt sie an die Socket-Klasse.
  5. Derzeit wird die Funktion zum Lesen von Daten in der Socket-Klasse aus beiden Flows A und B aufgerufen, was entweder zum Risiko einer Datenbeschädigung (ohne Verwendung von Synchronisationsobjekten) oder zu zusätzlichen Pausen (bei Verwendung von Synchronisationsobjekten) führt.

Bei Synchronisationsobjekten ist dies in diesem Fall im Allgemeinen schwierig. Nun, wenn er alleine ist. Wenn wir jedoch 100.000 Verbindungen haben und jede von ihnen eine Art Synchronisationsobjekt hat, kann dies die Ressourcen des Systems ernsthaft beeinträchtigen. Und wenn Sie noch 2 behalten (im Falle einer Trennung von Verarbeitungsanfragen zum Lesen und Schreiben)? Noch schlimmer.

Die übliche Lösung besteht darin, eine Verbindungsmanagerklasse zu erstellen, die für den Aufruf von ReadFile oder WriteFile für die Verbindungsklasse verantwortlich ist. Dies funktioniert besser, macht den Code jedoch komplexer.

Schlussfolgerungen


Sowohl epoll als auch IOCP eignen sich (und werden in der Praxis verwendet) zum Schreiben von Hochleistungsnetzwerkdiensten, die eine große Anzahl von Verbindungen verarbeiten können. Die Technologien selbst unterscheiden sich in der Art und Weise, wie sie mit Ereignissen umgehen. Diese Unterschiede sind so bedeutend, dass es sich kaum lohnt, sie auf einer gemeinsamen Basis zu schreiben (die Menge desselben Codes ist minimal). Ich habe mehrmals versucht, beide Ansätze zu einer universellen Lösung zu bringen - und jedes Mal war das Ergebnis in Bezug auf Komplexität, Lesbarkeit und Unterstützung schlechter als bei zwei unabhängigen Implementierungen. Das universelle Ergebnis musste jedes Mal aufgegeben werden.

Wenn Sie Code von einer Plattform auf eine andere portieren, ist es normalerweise einfacher, den IOCP-Code für die Verwendung von epoll zu portieren, als umgekehrt.

Tipps:

  • Wenn Sie einen plattformübergreifenden Netzwerkdienst entwickeln möchten, sollten Sie mit einer Windows-Implementierung mit IOCP beginnen. Sobald alles fertig und debuggt ist, fügen Sie ein triviales Epoll-Backend hinzu.
  • Sie sollten nicht versuchen, die allgemeinen Klassen Connection und ConnectionMgr zu schreiben, die gleichzeitig die Epoll- und IOCP-Logik implementieren. Aus Sicht der Codearchitektur sieht es schlecht aus und führt zu einer Reihe von #ifdef aller Art mit unterschiedlicher Logik. Erstellen Sie besser Basisklassen und erben Sie separate Implementierungen von diesen. In den Basisklassen können Sie gegebenenfalls einige allgemeine Methoden oder Daten beibehalten.
  • Überwachen Sie genau die Lebensdauer von Objekten der Verbindungsklasse (oder wie auch immer Sie die Klasse nennen, in der die Puffer für empfangene / gesendete Daten gespeichert werden). Es sollte nicht zerstört werden, bis die geplanten Lese- / Schreibvorgänge mit seinen Puffern abgeschlossen sind.

Source: https://habr.com/ru/post/de415403/


All Articles