poll(), epoll() etc.
-
Hallo!
Für einen Chat bin ich schon ziemlich lange am Überlegen für die Serverstruktur des Chats. Da der Chat in C++ ist, will ich ihn möglichst performant machen, unabhängig davon, wieviel Performance nachher wirklich gebraucht wird. Hier im Forum wird ja allgemein geraten, nicht jede Ein- und Ausgabe parallel in Extra-Threads zu machen, sondern Ein- und Ausgaben für mehrere Clients mittels Epoll/Poll zu bündeln. Theoretisch klingt das gleichzeitige Bearbeiten von vielen Verbindungen durch poll/epoll in einem Thread sehr gut, aber praktisch ergeben sich dabei folgende Fragen:
Die Ein- und Ausgabe-Anforderungen finden ja zu unterschiedlichen Zeitpunkten statt. Das heißt, ich müsste, wenn ich z.B. bis zu 500 IO-Aufgaben gleichzeitig/parallel in einem Thread bearbeiten will und keine Anfragen sichtbar verzögert werden sollen, die IO-Tasks in extrem kurzen Durchgängen in die Filedeskriptoren packen, kurz pollen, lesen/schreiben, die nächsten wartenden Tasks reinnehmen, neu in die Filedeskriptoren packen, wieder pollen, wieder lesen/schreiben usw. Da der Server auch das Frameset, den HTML-Code für die Frames und die (z.B. Smiley-) Grafiken ausgeben soll, habe ich mal getestet, dass z.B. HTML-Code mit mehreren Bildern sehr verzögert ausgegeben wird, wenn man auch nur 1/10 Sekunde für einen Poll-Durchgang benötigt.
1. Habe ich die Methode richtig verstanden oder ist es z.B. möglich, während der Server pollt, Filedeskriptoren in den laufenden Poll zu packen? Oder gibt es eine andere Lösung?
2. Kann ein Server, wenn er nach der obigen Methode z.B. 10000 Verbindungen gleichzeitig bedienen müsste, die er in Threads zu je 500 Verbindungen abarbeitet, überhaupt Poll-Timeouts von wenigen Millisekunden (sagen wir mal, so ab 10 Millisekunden) sinnvoll verarbeiten?
3. Oder ist es doch sinnvoller, pro Task einen Thread zu nehmen und nur die Chatausgaben, die an mehrere User gleichzeitig gehen, zu bündeln? (Hier würden ja die Aufgaben zum exakt gleichen Zeitpunkt vorliegen).
Ich hoffe, ich konnte das Problem gut schildern und ich hab noch nicht viel Erfahrung mit der Socketprogrammierung... Hat jemand gute Ratschläge?
Viele Grüße, Marc
-
unter der voraussetzung, dass du nur linux unterstützen willst, solltest du unbedingt epoll verwenden.
mit epoll erzeugst du einen file descriptor, der diese epoll instanz repräsentiert. über diesen file descriptor kannst du dann andere file descriptoren zu dieser epoll instanz hinzufügen. das geht sogar mit anderen epoll instanzen, sodass eine epoll instanz auf ereignisse einer anderen wartet.
wichtig ist, dass du zu jedem file descriptor noch eine kleine zusatzinformation angeben kannst. meistens gibst du einen pointer auf die verwaltungsstruktur, die zu diesem file descriptor passt, an. sobald sich am filedescriptor was ändert, bekommst du diesen pointer vom kernel zurückgeliefert. damit kannst du sehr bequem gleich auf alle verwaltungsinfos zu diesem file descriptor zugreifen.
du kannst bei epoll jederzeit file descriptoren hinzufügen und entfernen.
-
Ich verstehe das Problem nicht ganz, aber es klingt für mich irgendwie falsch. In der Regel wird poll (oder epoll) mit dem Timeout -1, also unendlich. Poll soll mich ja wecken, sobald sich etwas tut.
In einem Chat würde halt derjenige, der was zu sagen hat, den poll wecken und damit hast Du wieder die Kontrolle. Du kannst dann allen Teilnehmern die benötigten Daten schicken und wenn Du fertig bist, dann landest Du wieder im poll.
Willst Du mehrere CPUs bzw. Cores ausnutzen, benötigst Du natürlich Threads. Aber selbst da sollte genau ein Thread den Poll machen und wenn dann was zu tun ist, den anderen Threads über eine Queue einen Auftrag schicken.
Manchmal kann es notwendig sein, in den Poll aus einem anderen Thread heraus aufgrund anderer Ereignisse einzugreifen, um z. B. neue Filedeskriptoren hinzuzufügen. Wobei in deinem Fall neue Teilnehmer sich über den Listener Socket melden und damit den Poll aufwecken. Aber dennoch gibt es die Möglichkeit einen Poll zu unterbrechen. Das geht über eine Pipe. Du gibst dem Poll das lesende Ende einer Pipe mit und wenn Du den Poll wecken willst, schreibst Du ein einzelnes Byte auf das schreibende Ende der Pipe. Damit weckst Du auch deinen Poll-Thread.
-
Zu deinem "Geschwindigkeitsproblem":
Durch Threads hast du ja erstmal nicht mehr Rechenleistung, sondern auf einem Einprozessorsystem erstmal sogar weniger, denn die Threads müssen ja auch verwaltet werden.
Du darfst natürlich nicht so programmieren, als würdest du Threads benutzen, d.h. alle blockierenden Systemaufrufe (send, recv, etc.) darfst du nur aufrufen, wenn dir
poll
, vorher gesagt hat, dass die Aufrufe möglich sind, d.h. dass diese nicht blockieren. So hast du auch keine Verzögerungen und blockierst auch keine anderen Verbindungen, weil keiner deiner Aufrufe blockiert.Siehe non-blocking sockets.
-
Hey, vielen Dank ihr drei! Ihr habt genau das beschrieben, was ich gemeint hab!
Noch eine kleine Frage: Im Edge Triggered Modus bei epoll werden mir ja der Listen-Socket und die von TNT beschriebenen Pipe beim Aufwecken des Polls als readable ausgegeben. Wenn ich jetzt aus der Pipe/aus dem Request gelesen habe, stehen dann die File Deskriptoren automatisch wieder als non-readable im Set, damit epoll neu auf Veränderungen reagieren kann und würden sie auch wieder auf non-readable stehen, wenn nur ein Teil der Informationen ausgelesen worden wäre?
Viele Grüße + Danke,
Marc
-
edge triggered bedeutet, dass epoll den file descriptor nur zu dem zeitpunkt zurückgibt, zu dem er gerade daten empfangen hat. wenn du ihn wieder rein gibst, ohne daten gelesen zu haben, wird er nicht wieder sofort als verfügbar angegeben. das passiert erst wieder, sobald neue daten angekommen sind.
es ist deshalb sinnvoll, level triggered zu verwenden. immer, wenn daten vorhanden sind, wird dir epoll den file descriptor zurückgeben. auch wenn du ihn gerade ins epoll hinzugefügt hast und keine daten gerade gekommen sind.
das mit der pipe ist unnötig. wenn du dein programm beenden willst, entfernst du einfach alle file descriptoren aus dem epoll, beendet diese und dann den epoll file descriptor. das wird dir den thread, der das epolling macht, automatisch aufwecken.
kleine beschreibung, wie ich das verwende:
menge l an listening sockets.
menge c an connection sockets (die resultieren aus menge l).
epoll le für alle listening sockets.
epoll ce für alle connection sockets.
queue (liste an lesbaren connection sockets)
menge an handler threads, die die queue abarbeiten.listen thread:
epoll_wait(le, sockets)
für jedes socket aus sockets:
-> accept
-> erzeugen der verwaltungsinfos.
-> file descriptor non blocking setzen.
-> hinzufügen zu epoll ce (level-triggered und oneshot) mit pointer zu verwaltungsinfos.
wieder zu epoll_wait.connection thread:
epoll_wait(ce, sockets).
für jedes socket aus sockets:
-> verwaltungsinfos zu queue hinzufügen.
weiter bei epoll_wait.handler threads:
warten bis mindestens eine verwaltungsinformation in der queue ist und diese entfernen.
verbindung zu dieser verwaltungsinformation bearbeiten (lesen, schreiben, daten erzeugen, ...).
file descriptor wieder zu ce hinzufügen (level triggered, oneshot).das oneshot hilft dir darin, dass ein file descriptor für eine verbindung nur einmal aus dem epoll herauskommt. du kannst ihn dann wieder "aktivieren" mit epoll_ctl und der operation EPOLL_CTL_MOD.
die queue muss natürlich mit einem mutex und einer condition abgesichert werden. dazu brauchst du die funktionen aus pthread_mutex_* und pthread_cond_*.
das alles sollte dir einen performanten und vor allem effizienten server erzeugen.
-
Hi!
Danke für deine Antwort!
das mit der pipe ist unnötig. wenn du dein programm beenden willst, entfernst du einfach alle file descriptoren aus dem epoll, beendet diese und dann den epoll file descriptor.
Vom Prinzip her ist das fast, was ich will - mir gehts ja darum, den laufenden Poll zu wecken. Aber ich will dadurch einen oder mehrere zusätzliche File Deskriptoren in den laufenden Poll packen, ohne das gesamte FD-Set neu reintun zu müssen - nach dem Motto: neue File Deskriptoren rein und an alter Stelle weitergepollt... Da fällt mir, wie TNT schrieb, auch nur die Pipe ein, damit der Poll ein Ereignis bekommt und geweckt wird.
Viele Grüße, Stefan
-
du hast kein fdset bei epoll. jeder file descriptor liegt unabhängig der anderen im epoll drin. du kannst file descriptoren hinzufügen, entfernen oder modifizieren, während sie im epoll sind bzw. während ein thread bei epoll_wait wartet.
-
besserwisser schrieb:
du hast kein fdset bei epoll. jeder file descriptor liegt unabhängig der anderen im epoll drin. du kannst file descriptoren hinzufügen, entfernen oder modifizieren, während sie im epoll sind bzw. während ein thread bei epoll_wait wartet.
Kann man tatsächlich Dateideskriptoren hinzufügen oder entfernen während ein thread im epoll_wait ist? Ist das so dokumentiert? Ich habe nichts darüber gefunden.
-
Hallo nochmal!
Also einmal interessiert mich natürlich auch, ob man File Deskriptoren während des epoll_wait hinzufügen oder entfernen kann.
Dann:
besserwisser schrieb:
handler threads:
warten bis mindestens eine verwaltungsinformation in der queue ist und diese entfernen.
verbindung zu dieser verwaltungsinformation bearbeiten (lesen, schreiben, daten erzeugen, ...).
file descriptor wieder zu ce hinzufügen (level triggered, oneshot).Das bedeutet, man kann zum Schreiben und Lesen selbst zusätzliche Threads benutzen? Wenn das Schreiben und Lesen selbst ca. 30 Millisekunden dauert (bei größeren Daten entsprechend mehr), dann hätte man, würde man das Schreiben/Lesen im Poll-Thread miterledigen, ab einer bestimmten Menge eine spürbare Verzögerung des gesamten Servers. Wenn Threads in der Summe das Skript aber eher langsamer machen, kann man durch das Benutzen von Threads dann dieses Problem trotzdem lösen bzw. aufheben?
Viele Grüße, Marc
-
Marc21Ja schrieb:
Das bedeutet, man kann zum Schreiben und Lesen selbst zusätzliche Threads benutzen? Wenn das Schreiben und Lesen selbst ca. 30 Millisekunden dauert (bei größeren Daten entsprechend mehr), dann hätte man, würde man das Schreiben/Lesen im Poll-Thread miterledigen, ab einer bestimmten Menge eine spürbare Verzögerung des gesamten Servers. Wenn Threads in der Summe das Skript aber eher langsamer machen, kann man durch das Benutzen von Threads dann dieses Problem trotzdem lösen bzw. aufheben?
Nein! Darum geht es bei Threads nicht. Wie ich schon geschrieben habe, deine System-Calls dürfen nicht blockieren. Dann hast du auch keine Probleme. Größere Daten brauchen nicht länger zum senden, da du immer nur so viel in den Puffer schreibst, wie gerade gesendet werden kann, ohne das System zu blockieren. Also brauchst du um große Daten zu versenden mehrere Write-Events!
Threads benutzt du nur, damit du mehr Prozessor-Systeme ausnutzen kannst. D.h., wenn du so viele Verbindungen hast, dass deine Rechenleistung insgesamt nicht mehr ausreicht, kannst du Threads einsetzen und in dein System mehrere Prozessoren einbauen. Dann skaliert deine Anwendung auf mehrere Prozessoren.
-
tntnet schrieb:
Kann man tatsächlich Dateideskriptoren hinzufügen oder entfernen während ein thread im epoll_wait ist? Ist das so dokumentiert? Ich habe nichts darüber gefunden.
ich habe es selbst ausprobiert. man kann file descriptoren entfernen und hinzufügen. es steht in der doku aber leider nicht explizit drin. es gibt auch ein kleines problem dabei: epoll_wait wartet auch auf 0 file descriptoren. dh, es bleibt stecken, wenn man alle descriptoren entfernt hat.
beim close des epoll habe ich mich getäuscht. epoll_wait wird nicht aufgeweckt, wenn man den epoll file desciptor schließt. ein signal könnte hier abhilfe schaffen.
-
ProgChild schrieb:
Größere Daten brauchen nicht länger zum senden, da du immer nur so viel in den Puffer schreibst, wie gerade gesendet werden kann, ohne das System zu blockieren. Also brauchst du um große Daten zu versenden mehrere Write-Events!
Hi!
Die Daten, mit denen ich das getestet habe, waren ca. 100 Bytes bis 20 KB groß. Die Sockets sind nichtblockierend und du hast eine Lese-/Sendezeit von ca. 7-25 Millisekunden, ich habs nachgemessen. Die Größe scheint eine Rolle dabei zu spielen, wobei der Puffer bei 20 KB noch nicht voll sein sollte. Natürlich ist die Bearbeitungszeit der HTTP-Requests und der Erstellung des HTML-/Javascript-Codes noch aufwendiger als das Übertragen der Daten, aber wenn du jetzt z.B. eine auszugebende HTML-Seite mit dem Code und vielen Bildgrafiken hast, müsste sich das bei vielen Verbindungen und Lese-/Schreibvorgängen bereits in spürbaren Verzögerungen auswirken.
Viele Grüße, Marc
-
Die Sockets sind nichtblockierend und du hast eine Lese-/Sendezeit von ca. 7-25 Millisekunden, ich habs nachgemessen.
Dann hast du einen extrem langsamen Computer oder du hast schlecht gemessen.
-
besserwisser schrieb:
tntnet schrieb:
Kann man tatsächlich Dateideskriptoren hinzufügen oder entfernen während ein thread im epoll_wait ist? Ist das so dokumentiert? Ich habe nichts darüber gefunden.
ich habe es selbst ausprobiert. man kann file descriptoren entfernen und hinzufügen. es steht in der doku aber leider nicht explizit drin. es gibt auch ein kleines problem dabei: epoll_wait wartet auch auf 0 file descriptoren. dh, es bleibt stecken, wenn man alle descriptoren entfernt hat.
beim close des epoll habe ich mich getäuscht. epoll_wait wird nicht aufgeweckt, wenn man den epoll file desciptor schließt. ein signal könnte hier abhilfe schaffen.
Ausprobieren ist uninteressant. Wenn es nicht dokumentiert ist, kann man sich nicht darauf verlassen.
Mit welcher Kernel-Version hast Du ausprobiert? Hast Du auch den Kernel ausprobiert, der nächstes Jahr released wird? Die Kernelentwickler könnten den Code überarbeiten und das nicht dokumentierte Verhalten ändern.
-
@tntnet
dann verwende es halt nicht. ist ja deine entscheidung. ich habe das bis jetzt noch nicht gebraucht, werde es aber sicher verwenden, wenn ich es brauche. jeder ist da halt anders.dnotify wurde durch inotify ersetzt. deiner logik nach dürfte man damit keine api verwenden, da es auch hier passieren kann, dass sie wieder in einem nächsten release verschwindet. das könnte natürlich auch mit epoll passieren. was sagst du jetzt? ganz auf epoll verzichten?
etwas zu programmieren bedeutet, sich auf sich ändernde externe bedingungen einstellen und anpassen zu müssen. das wird dir noch oft passieren, da es massenhaft wichtige externe projekte gibt, deren entwickler von release managment und api stabilität keinen plan haben (z.b. openssl).
sich auf etwas nicht explizit dokumentiertest einzulassen ist also gleichwertig damit, sich auf eine api überhaupt einzulassen. deshalb sehe ich hier kein problem.
man kann die fehlende dokumentierung auch so sehen, dass eben das, was dokumentiert ist (man kann file descriptoren hinzufügen und entfernen) eben bedingungslos gilt. muss eine dokumentation auch alle bedingungen angeben, die NICHT erfüllt sein müssen?
-
Hallo!
besserwisser schrieb:
wichtig ist, dass du zu jedem file descriptor noch eine kleine zusatzinformation angeben kannst. meistens gibst du einen pointer auf die verwaltungsstruktur, die zu diesem file descriptor passt, an. sobald sich am filedescriptor was ändert, bekommst du diesen pointer vom kernel zurückgeliefert. damit kannst du sehr bequem gleich auf alle verwaltungsinfos zu diesem file descriptor zugreifen.
Also okay - ich habe eine Liste mit den Tasks, jeder Task ist eine Struktur vom Typ "xyztask". Wenn der Kernel mir einen Zeiger auf die jeweilige Aufgabe gibt, kann ich nach Abarbeitung den Task bequem löschen, ohne die ganze Liste durchzulaufen. Aber wie gebe ich den Pointer praktisch im Event an? Bei der Zuweisung ev.data.ptr=neuertask bekomme ich immer die Fehlermeldung, dass ptr kein member vom Struct epoll_event sei. Müsste ich nicht dabei auch irgendwie den Typ ("xyztask*") mit angeben?
Dann muss ich ja als Zweites noch die Anzahl der maxevents angeben. Da ich ein Array mit den Events brauche, vermute ich, dass wenn ich einen größeren Wert nehme, der Kernel länger braucht, um die Events zu verwalten oder? Zumindest verbraucht das Skript bei vielen größeren Arrays auch mehr Speicher. Sollte man das als dynamisches Array machen? Und wenn ja, gibt es einen einfachen und sicheren Anhaltspunkt, wie groß maxevents jeweils sein muss?
Vielen Dank schonmal + viele Grüße,
Marc
-
zum einen ist es kein script, was du machst sondern ein programm. der unterschied ist, dass ein script interpretiert wird und nicht in nativen (oder byte) code umgewandelt wird.
zum anderen hat der compiler recht, dass ptr kein attribute von epoll_event ist. den typ musst du nicht angeben, da jeder pointer auf daten automatisch auch ein pointer auf void ist und ptr eben genau dsa ist. casten deshalb unnötig. bitte um mehr code, damit ich mir das problem genauer ansehen kann.
bei jeder rückkehr von epoll_wait wirst du 0 bis maxevents bekommen. diese werden an die stelle von "events", dem zweiten parameter, geschrieben. da du dort natürlich speicher vorbereitet haben musst, musst du per maxevents dem kernel mitteilen, wieviel speicher du dort hast. es ist also die maximale anzahl an events, die ein aufruf von epoll_event zurückgibt. vielleicht ist 16 hier eine gute zahl. oder auch 128. ich weiß es nicht. ich denke aber, dass das abhängig von der auslastung und art des servers ist. du musst sehen, welcher wert günstig ist. ich verwende immer 16.
der kernel wartet nicht ewig, bis er maxevents bekommen hat. dh, er wird dir meistens weniger als maxevents events zurückliefern. solange dein server weit unter maxevents events pro wait aufruf bleibt, kannst du das beibehalten. man sollte solche feinheiten nur tunen, wenn es wirklich nötig ist.
dynamisch musst du das array nur machen, wenn du es zur laufzeit ändern willst. wenn du das nicht willst, ist das array auch problemlos am stack möglich.
-
Hi!
Danke für deine Antwort!
besserwisser schrieb:
zum anderen hat der compiler recht, dass ptr kein attribute von epoll_event ist. den typ musst du nicht angeben, da jeder pointer auf daten automatisch auch ein pointer auf void ist und ptr eben genau dsa ist. casten deshalb unnötig. bitte um mehr code, damit ich mir das problem genauer ansehen kann.
Ich hab gemerkt, der Fehler lag bei mir - ich habe nicht beachtet, dass events.data eine Union ist und hatte data.fd und data.ptr zugewiesen. Bei der nächsten Zuweisung ist er dann offenbar davon ausgegangen, dass events.data vom Typ Integer (fd) ist. Jetzt macht er's korrekt, wie du es oben beschrieben hast.
besserwisser schrieb:
es ist also die maximale anzahl an events, die ein aufruf von epoll_event zurückgibt. vielleicht ist 16 hier eine gute zahl. oder auch 128. ich weiß es nicht. ich denke aber, dass das abhängig von der auslastung und art des servers ist. du musst sehen, welcher wert günstig ist. ich verwende immer 16.
Das hört sich gut an
. Ich weiß, dass ein Chat selten so voll ist, aber wenn man schon sowas programmiert: Nehmen wir mal an, man hätte zu einem bestimmten Zeitpunkt eine sehr hohe Auslastung mit mehreren tausend Verbindungen (manche Routinen müssen die alle gleichzeitig bedienen), bedeutet das, dass wenn mehr als 16 oder 128 gleichzeitige Ereignisse stattfinden, der Kernel dann 16/128 Ereignisse ausgibt und beim nächsten Aufruf von epoll_wait merkt, es sind noch nicht alle Ereignisse ausgegeben und sofort zurück kehrt, um mit jeder Rückkehr 16/128 der restlichen Ereignisse zu liefern?
Noch eine kleine Frage: Wenn ich einen fd schließe, muss ich nicht EPOLL_CTL_DEL aufrufen, sondern epoll entfernt den fd aus den Events automatisch, wenn ich den Socket mit close(c) z.B. aus einem anderen Thread schließe oder?
Viele Grüße + Danke,
Marc
-
besserwisser schrieb:
der kernel wartet nicht ewig, bis er maxevents bekommen hat. dh, er wird dir meistens weniger als maxevents events zurückliefern. solange dein server weit unter maxevents events pro wait aufruf bleibt, kannst du das beibehalten. man sollte solche feinheiten nur tunen, wenn es wirklich nötig ist.
http://pl.atyp.us/content/tech/servers.html -- siehe Context Switches
Man könnte die Serverarchitektur auch so machen: Anstatt einen Listener-Thread und/oder einen weiteren Thread für Epoll und die Workerthreads zu machen, wird um epoll_wait ein Lock gesetzt. So kommt immer nur ein Thread ins Lock hinein. Epoll_wait lässt man die maximale Zahl an events ausgeben, die man im Thread bearbeiten will. Anschließend gibt man den Lock frei und bearbeitet die events, während ein weiterer Thread, der epoll_wait aufgerufen hat, die nächsten Aufgaben abholt. Sind in einer Aufgabe weitere Polls auszuführen, lässt man vom nächsten Thread, der in den Lock kommt, die Tasks in die Events adden. So vermeidet man, dass zu viele Aufgaben durch Locking zwischen den Threads hin- und herverschoben werden.