select / poll / epoll: praktischer Unterschied

Beim Entwerfen von Hochleistungsnetzwerkanwendungen mit nicht blockierenden Sockets ist es wichtig zu entscheiden, welche Methode zur Überwachung von Netzwerkereignissen verwendet wird. Es gibt mehrere von ihnen, und jede ist auf ihre Weise gut und schlecht. Die Auswahl der richtigen Methode kann für die Architektur Ihrer Anwendung von entscheidender Bedeutung sein.

In diesem Artikel werden wir Folgendes berücksichtigen:

  • wähle ()
  • Umfrage ()
  • epoll ()
  • libevent

Mit select ()


Das alte, im Laufe der Jahre bewährte Hard Worker select () wurde in jenen Tagen erstellt, als „Sockets“ als „ Berkeley-Sockets “ bezeichnet wurden. Diese Methode war in der allerersten Spezifikation dieser Berkeley-Buchsen selbst nicht enthalten, da es damals noch kein Konzept für nicht blockierende E / A gab. Aber irgendwo in den 80ern erschien sie und wählte damit (). Seitdem hat sich an der Benutzeroberfläche nichts wesentlich geändert.

Um select () verwenden zu können, muss der Entwickler mehrere fd_set-Strukturen initialisieren und mit Deskriptoren und Ereignissen füllen, die überwacht werden müssen, und dann select () aufrufen. Ein typischer Code sieht ungefähr so ​​aus:

fd_set fd_in, fd_out; struct timeval tv; //   FD_ZERO( &fd_in ); FD_ZERO( &fd_out ); //        sock1 FD_SET( sock1, &fd_in ); //        sock2 FD_SET( sock2, &fd_out ); //       (select   ) int largest_sock = sock1 > sock2 ? sock1 : sock2; //    10  tv.tv_sec = 10; tv.tv_usec = 0; //  select int ret = select( largest_sock + 1, &fd_in, &fd_out, NULL, &tv ); //    if ( ret == -1 ) //  else if ( ret == 0 ) // ,    else { if ( FD_ISSET( sock1, &fd_in ) ) //    sock1 if ( FD_ISSET( sock2, &fd_out ) ) //    sock2 } 

Als select () entworfen wurde, hatte niemand damit gerechnet, dass wir in Zukunft Multithread-Anwendungen schreiben müssen, die Tausende von Verbindungen bedienen. Select () weist mehrere wesentliche Nachteile auf, die es für die Arbeit an solchen Systemen schlecht geeignet machen. Die wichtigsten sind:

  • select ändert die an ihn übergebenen fd_sets-Strukturen so, dass keine von ihnen wiederverwendet werden kann. Auch wenn Sie nichts ändern müssen (z. B. möchten Sie nach dem Empfang eines Datenelements mehr erhalten), müssen die Strukturen fd_sets neu initialisiert werden. Nun, oder kopieren Sie mit FD_COPY aus einem zuvor gespeicherten Backup. Und dies muss vor jedem ausgewählten Anruf immer wieder erfolgen.
  • Um genau herauszufinden, welcher Deskriptor das Ereignis generiert hat, müssen Sie alle manuell mit FD_ISSET abfragen. Wenn Sie 2000 Deskriptoren überwachen und das Ereignis nur für einen von ihnen aufgetreten ist (der nach dem Gesetz der Gemeinheit der letzte auf der Liste ist), verschwenden Sie eine Menge Prozessorressourcen.
  • Habe ich gerade 2000 Deskriptoren erwähnt? Ich war begeistert davon. select unterstützt nicht so viel. Zumindest unter normalem Linux mit dem üblichen Kernel. Die maximale Anzahl gleichzeitig beobachteter Deskriptoren wird durch die Konstante FD_SETSIZE begrenzt, die unter Linux starr gleich 1024 ist. Bei einigen Betriebssystemen können Sie einen Hack implementieren, indem Sie den FD_SETSIZE-Wert überschreiben, bevor Sie die Header-Datei sys / select.h einschließen. Dieser Hack ist jedoch nicht Teil eines gängigen Standards. Das gleiche Linux wird es ignorieren.
  • Sie können nicht mit Deskriptoren aus einer beobachtbaren Menge eines anderen Threads arbeiten. Stellen Sie sich einen Thread vor, der den obigen Code ausführt. Also startete es und wartet auf Ereignisse in seiner select (). Stellen Sie sich nun vor, Sie haben einen anderen Thread, der die Gesamtlast des Systems überwacht, und jetzt hat er entschieden, dass die Daten vom Socket sock1 nicht zu lange angekommen sind und es Zeit war, die Verbindung zu trennen. Da dieser Socket wiederverwendet werden kann, um neue Clients zu bedienen, ist es gut, ihn korrekt zu schließen. Aber der erste Thread beobachtet diesen Deskriptor gerade. Was passiert, wenn wir es trotzdem schließen? Oh, die Dokumentation hat eine Antwort auf diese Frage und es wird Ihnen nicht gefallen: "Wenn das mit select () beobachtete Handle von einem anderen Thread geschlossen wird, erhalten Sie ein undefiniertes Verhalten."
  • Das gleiche Problem tritt auf, wenn versucht wird, einige Daten über sock1 zu senden. Wir werden nichts senden, bis select seine Arbeit beendet hat.
  • Die Auswahl an Ereignissen, die wir überwachen können, ist sehr begrenzt. Um beispielsweise festzustellen, dass ein Remote-Socket geschlossen wurde, sollten Sie zum einen die Ereignisse des Eintreffens von Daten überwachen und zum anderen versuchen, diese Daten zu lesen (read gibt 0 für den geschlossenen Socket zurück). Dies kann immer noch als akzeptabel bezeichnet werden, wenn Daten von einem Socket gelesen werden (0 lesen - der Socket ist geschlossen). Was ist jedoch, wenn unsere aktuelle Aufgabe darin besteht, Daten an diesen Socket zu senden und derzeit keine Daten von diesem zu lesen?
  • select belastet Sie unnötig, den „größten Deskriptor“ zu berechnen und als separaten Parameter zu übergeben

Natürlich ist all das keine Neuigkeit. Betriebssystementwickler sind sich dieser Probleme seit langem bewusst und viele von ihnen wurden bei der Entwicklung der Abfragemethode berücksichtigt. An dieser Stelle fragen Sie sich vielleicht, warum wir uns jetzt überhaupt mit alter Geschichte befassen und gibt es heute Gründe, die alte Auswahl zu verwenden? Ja, es gibt zwei solche Gründe. Nicht die Tatsache, dass sie Ihnen irgendwann nützlich sein werden, aber warum nicht etwas über sie herausfinden?

Der erste Grund ist die Portabilität. select () ist seit einer Million Jahren bei uns. Egal, was der Dschungel der Hardware- und Softwareplattformen Ihnen bringt, wenn dort ein Netzwerk vorhanden ist, wird eine Auswahl getroffen. Es gibt möglicherweise keine anderen Methoden, aber die Auswahl ist fast garantiert. Und denken Sie nicht, dass ich jetzt in senile Senilität falle und erinnere mich an etwas wie Lochkarten und ENIAC, nein. In Windows XP gibt es beispielsweise keine modernere Abfragemethode. Aber wählen ist.

Der zweite Grund ist exotischer und hängt mit der Tatsache zusammen, dass select (theoretisch) mit Timeouts in der Größenordnung von einer Nanosekunde arbeiten kann (sofern die Hardware dies zulässt), während Poll und Epoll nur die Millisekundengenauigkeit unterstützen. Dies sollte auf normalen Desktops (oder sogar Servern), auf denen Sie noch keinen Hardware-Timer für die Genauigkeit von Nanosekunden haben, keine besondere Rolle spielen. Aber immer noch auf der Welt gibt es Echtzeitsysteme mit solchen Timern. Ich bitte Sie, wenn Sie die Firmware eines Kernreaktors oder einer Rakete schreiben - seien Sie nicht zu faul, um die Zeit in Nanosekunden zu messen. Weißt du, ich möchte leben.

Der oben beschriebene Fall ist wahrscheinlich der einzige, bei dem Sie wirklich keine Wahl haben, was Sie verwenden möchten (nur select ist geeignet). Wenn Sie jedoch eine reguläre Anwendung für die Arbeit mit normaler Hardware schreiben und mit einer angemessenen Anzahl von Sockets (Zehn, Hunderte - und nicht mehr) arbeiten, ist der Unterschied in der Abfrage- und Auswahlleistung nicht erkennbar, sodass die Auswahl auf anderen Faktoren basiert.

Abstimmung mit Umfrage ()


poll ist eine neuere Methode zum Abrufen von Sockets, die erstellt wurde, nachdem Benutzer versucht haben, große und stark ausgelastete Netzwerkdienste zu schreiben. Es ist viel besser gestaltet und leidet nicht unter den meisten Nachteilen der Auswahlmethode. In den meisten Fällen können Sie beim Schreiben moderner Anwendungen zwischen poll und epoll / libevent wählen.

Um poll verwenden zu können, muss ein Entwickler Mitglieder der pollfd-Struktur mit beobachtbaren Deskriptoren und Ereignissen initialisieren und dann poll () aufrufen.
Ein typischer Code sieht folgendermaßen aus:

 //   struct pollfd fds[2]; //  sock1      fds[0].fd = sock1; fds[0].events = POLLIN; //   sock2 -  fds[1].fd = sock2; fds[1].events = POLLOUT; //   10  int ret = poll( &fds, 2, 10000 ); //    if ( ret == -1 ) //  else if ( ret == 0 ) // ,    else { //  ,  revents      if ( pfd[0].revents & POLLIN ) pfd[0].revents = 0; //     sock1 if ( pfd[1].revents & POLLOUT ) pfd[1].revents = 0; //     sock2 } 

Die Umfrage wurde erstellt, um die Probleme der Auswahlmethode zu lösen. Mal sehen, wie sich herausstellte:

  • Die Anzahl der beobachteten Deskriptoren ist unbegrenzt, mehr als 1024 können überwacht werden
  • Die pollfd-Struktur wird nicht geändert, sodass sie zwischen den Aufrufen von poll () wiederverwendet werden kann. Sie müssen lediglich das Feld revents zurücksetzen.
  • Beobachtete Ereignisse sind besser strukturiert. Sie können beispielsweise bestimmen, ob ein Remoteclient getrennt wird, ohne Daten vom Socket lesen zu müssen.

Wir haben bereits über die Mängel der Umfragemethode gesprochen: Sie ist auf einigen Plattformen wie Windows XP nicht verfügbar. Seit Vista existiert es, heißt aber WSAPoll. Der Prototyp ist derselbe, sodass Sie für plattformunabhängigen Code eine Überschreibung schreiben können, z.

 #if defined (WIN32) static inline int poll( struct pollfd *pfd, int nfds, int timeout) { return WSAPoll ( pfd, nfds, timeout ); } #endif 

Nun, die Genauigkeit von Timeouts beträgt 1 ms, was sehr selten nicht ausreicht. Die Umfrage hat jedoch andere Nachteile:

  • Wie bei der Verwendung von select ist es unmöglich zu bestimmen, welche Deskriptoren die Ereignisse generiert haben, ohne alle beobachteten Strukturen vollständig zu durchlaufen und die darin enthaltenen Revents-Felder zu überprüfen. Schlimmer noch, es ist auch im Kernel des Betriebssystems implementiert.
  • Wie bei select gibt es keine Möglichkeit, die beobachteten Ereignisse dynamisch zu ändern

All dies kann jedoch für die meisten Clientanwendungen als relativ unbedeutend angesehen werden. Die Ausnahme sind wahrscheinlich nur P2P-Protokolle, bei denen jeder der Clients Tausenden von anderen zugeordnet werden kann. Diese Probleme können selbst von den meisten Serveranwendungen ignoriert werden. Daher sollte die Umfrage Ihre Standardeinstellung gegenüber der Auswahl sein, es sei denn, einer der beiden oben genannten Gründe schränkt Sie ein.

Mit Blick auf die Zukunft werde ich sagen, dass Umfragen in den folgenden Fällen sogar im Vergleich zu den moderneren Epolls (siehe unten) vorzuziehen sind:

  • Sie möchten plattformübergreifenden Code schreiben (epoll ist nur unter Linux verfügbar)
  • Sie müssen nicht mehr als 1000 Sockets überwachen (epoll gibt Ihnen in diesem Fall nichts Bedeutendes)
  • Sie müssen mehr als 1000 Sockets überwachen, aber die Verbindungszeit mit jedem von ihnen ist sehr gering (in diesen Fällen ist die Leistung von Poll und Epoll sehr gering - der Gewinn durch das Warten auf weniger Ereignisse in Epoll wird durch den Aufwand beim Hinzufügen / Entfernen dieser Sockets durchgestrichen).
  • Ihre Anwendung ist nicht dafür ausgelegt, Ereignisse von einem Thread zu ändern, während ein anderer auf sie wartet (oder Sie benötigen sie nicht).

Abstimmung mit epoll ()


epoll ist die neueste und beste Methode, um auf Ereignisse unter Linux (und nur unter Linux) zu warten. Nun, es ist nicht so, dass das "neueste" direkt ist - es ist seit 2002 das Kernstück. Es unterscheidet sich von Umfrage und Auswahl dadurch, dass es eine API zum Hinzufügen / Entfernen / Ändern der Liste der beobachteten Deskriptoren und Ereignisse bereitstellt.

Die Verwendung von Epoll erfordert etwas gründlichere Vorbereitungen. Der Entwickler muss:

  • Erstellen Sie einen Epoll-Deskriptor, indem Sie epoll_create aufrufen
  • Initialisieren Sie die epoll_event-Struktur mit den erforderlichen Ereignissen und Zeigern auf Verbindungskontexte. Der "Kontext" hier kann alles sein, epoll übergibt diesen Wert nur in den zurückgegebenen Ereignissen
  • Rufen Sie epoll_ctl (... EPOLL_CTL_ADD) auf, um der Liste der Observablen ein Handle hinzuzufügen
  • Rufen Sie epoll_wait () auf, um auf Ereignisse zu warten (wir geben genau an, wie viele Ereignisse gleichzeitig empfangen werden sollen, z. B. 20). Im Gegensatz zu den vorherigen Methoden erhalten wir diese Ereignisse separat und nicht in den Eigenschaften der Eingabestrukturen. Wenn wir 200 Deskriptoren beobachten und 5 von ihnen neue Daten erhalten haben, gibt epoll_wait nur 5 Ereignisse zurück. Wenn 50 Ereignisse eintreten, werden die ersten 20 an uns zurückgeschickt und die restlichen 30 warten auf den nächsten Anruf. Sie gehen nicht verloren
  • Verarbeitete Ereignisse verarbeiten. Dies ist eine relativ schnelle Verarbeitung, da wir uns nicht die Deskriptoren ansehen, bei denen nichts passiert ist

Ein typischer Code sieht folgendermaßen aus:

 //   epoll.       ,      //    (    ,   ),        int pollingfd = epoll_create( 0xCAFE ); if ( pollingfd < 0 ) //  //   epoll_event struct epoll_event ev = { 0 }; //     .    ,   // epoll     . , ,       ev.data.ptr = pConnection1; //    ,     ev.events = EPOLLIN | EPOLLONESHOT; //     .        //      epoll_wait -    if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, pConnection1->getSocket(), &ev ) != 0 ) // report error //       20    struct epoll_event pevents[ 20 ]; //  10  int ready = epoll_wait( pollingfd, pevents, 20, 10000 ); //    if ( ret == -1 ) //  else if ( ret == 0 ) // ,    else { //     for ( int i = 0; i < ret; i++ ) { if ( pevents[i].events & EPOLLIN ) { //        ,   Connection * c = (Connection*) pevents[i].data.ptr; c->handleReadEvent(); } } } 

Beginnen wir mit den Fehlern von epoll - sie sind aus dem Code ersichtlich. Diese Methode ist schwieriger zu verwenden, Sie müssen mehr Code schreiben, es werden mehr Systemaufrufe ausgeführt.

Vorteile liegen auch auf der Hand:

  • epoll gibt nur eine Liste der Deskriptoren zurück, für die die beobachteten Ereignisse tatsächlich aufgetreten sind. Sie müssen nicht Tausende von Strukturen durchsuchen, um nach der zu suchen, möglicherweise nach der, in der das erwartete Ereignis funktioniert hat.
  • Sie können jedem beobachteten Ereignis einen sinnvollen Kontext zuordnen. Im obigen Beispiel haben wir dafür einen Zeiger auf ein Objekt der Verbindungsklasse verwendet - dies hat uns eine weitere mögliche Suche nach einem Array von Verbindungen erspart.
  • Sie können jederzeit Sockets zur Liste hinzufügen oder daraus entfernen. Sie können sogar beobachtete Ereignisse ändern. Alles wird korrekt funktionieren, dies wird offiziell unterstützt und dokumentiert.
  • Mit epoll_wait können Sie mehrere Threads starten, die auf Ereignisse aus derselben Warteschlange warten. Etwas, das mit select / poll in keiner Weise möglich ist.

Sie müssen sich aber auch daran erinnern, dass Epoll keine „vollständig verbesserte Umfrage“ ist. Es hat Nachteile gegenüber der Umfrage:

  • Das Ändern von Ereignisflags (z. B. das Wechseln von READ zu WRITE) erfordert einen zusätzlichen Systemaufruf epoll_ctl, während Sie für die Abfrage nur die Bitmaske ändern (vollständig im Benutzermodus). Das Umschalten von 5.000 Sockets vom Lesen zum Schreiben erfordert 5.000 Systemaufrufe und Kontextwechsel für epoll, während es für die Abfrage eine triviale Bitoperation in einer Schleife ist.
  • Für jede neue Verbindung müssen Sie accept () aufrufen, und epoll_ctl () sind zwei Systemaufrufe. Wenn Sie eine Umfrage verwenden, wird nur ein Anruf getätigt. Bei einer sehr kurzen Verbindungslebensdauer kann dies einen Unterschied machen.
  • epoll ist nur unter Linux verfügbar. Andere Betriebssysteme haben ähnliche Mechanismen, sind aber immer noch nicht vollständig identisch. Sie können keinen Code mit epoll schreiben, damit er beispielsweise auf FreeBSD erstellt und funktioniert.
  • Das Schreiben von hoch geladenem parallelem Code ist schwierig. Viele Anwendungen benötigen keinen so grundlegenden Ansatz, da ihr Lastniveau mit einfacheren Methoden leicht verarbeitet werden kann.

Daher sollte epoll nur verwendet werden, wenn alle der folgenden Bedingungen erfüllt sind:

  • Ihre Anwendung verwendet einen Thread-Pool, um Netzwerkverbindungen zu verarbeiten. Der Gewinn von epoll in einer Single-Threaded-Anwendung ist vernachlässigbar, und Sie sollten sich nicht um die Implementierung kümmern.
  • Sie erwarten eine relativ große Anzahl von Verbindungen (ab 1000). Bei einer kleinen Anzahl von beobachteten Steckdosen führt epoll nicht zu einem Leistungsgewinn, und wenn es buchstäblich einige Steckdosen gibt, kann es sogar langsamer werden.
  • Ihre Verbindungen leben relativ lange. In einer Situation, in der eine neue Verbindung nur wenige Datenbytes überträgt und genau dort geschlossen wird, funktioniert die Abfrage schneller, da weniger Systemaufrufe erforderlich sind, um sie zu verarbeiten.
  • Sie beabsichtigen, Ihren Code unter Linux und nur unter Linux auszuführen.

Wenn eines oder mehrere der Elemente fehlschlagen, sollten Sie die Verwendung von poll oder libevent in Betracht ziehen.

libevent


libevent ist eine Bibliothek, die die in diesem Artikel aufgeführten Abfragemethoden (sowie einige andere) in einer einheitlichen API zusammenfasst. Der Vorteil hierbei ist, dass Sie den Code nach dem Schreiben auf verschiedenen Betriebssystemen erstellen und ausführen können. Es ist jedoch wichtig zu verstehen, dass libevent nur ein Wrapper ist, in dem alle oben genannten Methoden mit all ihren Vor- und Nachteilen funktionieren. libevent erzwingt nicht, dass select mehr als 1024 Sockets abhört, und epoll ändert die Liste der Ereignisse nicht ohne einen zusätzlichen Systemaufruf. Daher ist es immer noch wichtig, die zugrunde liegenden Technologien zu kennen.

Die Notwendigkeit, verschiedene Abfragemethoden zu unterstützen, macht die API der Libevent-Bibliothek komplexer. Die Verwendung ist jedoch einfacher als das manuelle Schreiben von zwei verschiedenen Ereignisauswahl-Engines für beispielsweise Linux und FreeBSD (unter Verwendung von epoll und kqueue).

Erwägen Sie die Verwendung von libevent, wenn Sie zwei Ereignisse kombinieren:

  • Sie haben sich Auswahl- und Abfragemethoden angesehen und sie haben definitiv nicht für Sie funktioniert.
  • Sie müssen mehrere Betriebssysteme unterstützen

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


All Articles