Leitfaden für Multi-Processing-Netzwerkservermodelle

Veröffentlicht: 2022-03-11

Als jemand, der seit einigen Jahren Code für Hochleistungsnetzwerke schreibt (meine Doktorarbeit befasste sich mit dem Thema eines Cache-Servers für verteilte Anwendungen, die an Multicore-Systeme angepasst sind), sehe ich viele Tutorials zu diesem Thema, die jede Diskussion völlig verfehlen oder auslassen der Grundlagen von Netzwerkservermodellen. Dieser Artikel ist daher als hoffentlich nützlicher Überblick und Vergleich von Netzwerkservermodellen gedacht, mit dem Ziel, etwas von dem Geheimnis des Schreibens von Hochleistungsnetzwerkcode zu lüften.

Welches Netzwerkservermodell soll ich wählen?

Dieser Artikel richtet sich an „Systemprogrammierer“, dh Backend-Entwickler, die mit den Low-Level-Details ihrer Anwendungen arbeiten und Netzwerkservercode implementieren. Dies geschieht normalerweise in C++ oder C, obwohl heutzutage die meisten modernen Sprachen und Frameworks eine anständige Low-Level-Funktionalität mit unterschiedlichen Effizienzstufen bieten.

Ich gehe davon aus, dass es nur natürlich ist, die Software anzupassen, um diese Kerne so gut wie möglich zu nutzen, da es einfacher ist, CPUs durch Hinzufügen von Kernen zu skalieren. Somit stellt sich die Frage, wie Software auf Threads (oder Prozesse) aufgeteilt werden kann, die parallel auf mehreren CPUs ausgeführt werden können.

Ich gehe auch davon aus, dass dem Leser bewusst ist, dass „Parallelität“ grundsätzlich „Multitasking“ bedeutet, also mehrere Instanzen von Code (ob gleicher oder unterschiedlicher Code spielt keine Rolle), die gleichzeitig aktiv sind. Parallelität kann auf einer einzigen CPU erreicht werden, und vor der Neuzeit war dies normalerweise der Fall. Insbesondere kann Parallelität erreicht werden, indem schnell zwischen mehreren Prozessen oder Threads auf einer einzelnen CPU umgeschaltet wird. Auf diese Weise gelang es alten Single-CPU-Systemen, viele Anwendungen gleichzeitig auszuführen, und zwar auf eine Weise, die der Benutzer als gleichzeitig ausgeführte Anwendungen wahrnehmen würde, obwohl dies in Wirklichkeit nicht der Fall war. Parallelität hingegen bedeutet konkret, dass Code buchstäblich gleichzeitig von mehreren CPUs oder CPU-Kernen ausgeführt wird.

Partitionieren einer Anwendung (in mehrere Prozesse oder Threads)

Für den Zweck dieser Diskussion ist es größtenteils nicht relevant, ob wir über Threads oder vollständige Prozesse sprechen. Moderne Betriebssysteme (mit der bemerkenswerten Ausnahme von Windows) behandeln Prozesse fast so leicht wie Threads (oder in einigen Fällen haben Threads umgekehrt Funktionen erhalten, die sie so gewichtig wie Prozesse machen). Heutzutage liegt der Hauptunterschied zwischen Prozessen und Threads in den Möglichkeiten der prozess- oder threadübergreifenden Kommunikation und Datenfreigabe. Wo die Unterscheidung zwischen Prozessen und Threads wichtig ist, werde ich einen entsprechenden Hinweis machen, andernfalls können die Wörter „Thread“ und „Prozess“ in diesem Abschnitt sicher als austauschbar betrachtet werden.

Allgemeine Netzwerkanwendungsaufgaben und Netzwerkservermodelle

Dieser Artikel befasst sich speziell mit Netzwerkservercode, der notwendigerweise die folgenden drei Aufgaben implementiert:

  • Aufgabe Nr. 1: Aufbau (und Abbau) von Netzwerkverbindungen
  • Aufgabe Nr. 2: Netzwerkkommunikation (IO)
  • Aufgabe Nr. 3: Nützliche Arbeit; dh die Nutzlast oder der Grund, warum die Anwendung existiert

Es gibt mehrere allgemeine Netzwerkservermodelle zum Partitionieren dieser Aufgaben über Prozesse hinweg; nämlich:

  • MP: Multiprozess
  • SPED: Einzelprozess, ereignisgesteuert
  • SEDA: Inszenierte ereignisgesteuerte Architektur
  • AMPED: Asymmetrischer ereignisgesteuerter Multiprozess
  • SYMPED: Symmetric Multi-Process Event-Driven

Dies sind die Modellnamen von Netzwerkservern, die in der akademischen Gemeinschaft verwendet werden, und ich erinnere mich, dass ich für zumindest einige von ihnen „in freier Wildbahn“ Synonyme gefunden habe. (Die Namen selbst sind natürlich weniger wichtig – der wirkliche Wert liegt darin, zu verstehen, was im Code vor sich geht.)

Jedes dieser Netzwerkservermodelle wird in den folgenden Abschnitten weiter beschrieben.

Das Multi-Prozess (MP)-Modell

Das MP-Netzwerkservermodell ist dasjenige, das jeder zuerst gelernt hat, insbesondere wenn er etwas über Multithreading gelernt hat. Im MP-Modell gibt es einen „Master“-Prozess, der Verbindungen akzeptiert (Task #1). Sobald eine Verbindung hergestellt ist, erstellt der Master-Prozess einen neuen Prozess und übergibt ihm den Verbindungs-Socket, sodass es einen Prozess pro Verbindung gibt. Dieser neue Prozess arbeitet dann normalerweise mit der Verbindung auf eine einfache, sequentielle, stufenweise Weise: Er liest etwas daraus (Aufgabe Nr. 2), führt dann einige Berechnungen durch (Aufgabe Nr. 3) und schreibt dann etwas darauf (Aufgabe Nr. 2 aufs Neue).

Das MP-Modell ist sehr einfach zu implementieren und funktioniert tatsächlich sehr gut, solange die Gesamtzahl der Prozesse ziemlich gering bleibt. Wie niedrig? Die Antwort hängt wirklich davon ab, was die Aufgaben Nr. 2 und Nr. 3 beinhalten. Nehmen wir als Faustregel an, dass die Anzahl der Prozesse oder Threads etwa das Doppelte der Anzahl der CPU-Kerne nicht überschreiten sollte. Sobald zu viele Prozesse gleichzeitig aktiv sind, neigt das Betriebssystem dazu, viel zu viel Zeit mit Thrashing zu verbringen (dh die Prozesse oder Threads auf den verfügbaren CPU-Kernen herumzujonglieren), und solche Anwendungen verbrauchen im Allgemeinen fast ihre gesamte CPU Zeit in „sys“- (oder Kernel-) Code, was wenig wirklich nützliche Arbeit leistet.

Vorteile: Sehr einfach zu implementieren, funktioniert sehr gut, solange die Anzahl der Verbindungen gering ist.

Nachteile: Neigt dazu, das Betriebssystem zu überlasten, wenn die Anzahl der Prozesse zu groß wird, und kann Latenz-Jitter aufweisen, da Netzwerk-E/A wartet, bis die Phase der Nutzlast (Berechnung) beendet ist.

Das Single Process Event-Driven (SPED)-Modell

Das SPED-Netzwerkservermodell wurde durch einige relativ neue hochkarätige Netzwerkserveranwendungen wie Nginx berühmt. Grundsätzlich erledigt es alle drei Aufgaben im selben Prozess und multiplext zwischen ihnen. Um effizient zu sein, erfordert es einige ziemlich fortgeschrittene Kernel-Funktionalitäten wie epoll und kqueue. In diesem Modell wird der Code von eingehenden Verbindungen und Daten „Ereignissen“ gesteuert und implementiert eine „Ereignisschleife“, die wie folgt aussieht:

  • Fragen Sie das Betriebssystem, ob es neue Netzwerk-„Ereignisse“ gibt (z. B. neue Verbindungen oder eingehende Daten).
  • Wenn neue Verbindungen verfügbar sind, stellen Sie sie her (Aufgabe Nr. 1)
  • Wenn Daten verfügbar sind, lesen Sie sie (Aufgabe Nr. 2) und handeln Sie danach (Aufgabe Nr. 3).
  • Wiederholen Sie dies, bis der Server beendet wird

All dies geschieht in einem einzigen Prozess und kann äußerst effizient durchgeführt werden, da Kontextwechsel zwischen Prozessen vollständig vermieden werden, was normalerweise die Leistung des MP-Modells beeinträchtigt. Die einzigen Kontextwechsel kommen hier von Systemaufrufen, und diese werden minimiert, indem nur auf die spezifischen Verbindungen eingewirkt wird, denen einige Ereignisse zugeordnet sind. Dieses Modell kann Zehntausende von Verbindungen gleichzeitig verarbeiten, solange die Nutzlastarbeit (Aufgabe Nr. 3) nicht übermäßig kompliziert oder ressourcenintensiv ist.

Dieser Ansatz hat jedoch zwei große Nachteile:

  1. Da alle drei Aufgaben nacheinander in einer einzelnen Schleifeniteration ausgeführt werden, wird die Nutzlastarbeit (Aufgabe Nr. 3) synchron mit allem anderen ausgeführt, was bedeutet, dass, wenn es lange dauert, eine Antwort auf die vom Client empfangenen Daten zu berechnen, alles andere stoppt, während dies geschieht, was potenziell große Schwankungen in der Latenz einführt.
  2. Es wird nur ein einziger CPU-Kern verwendet. Dies hat wiederum den Vorteil, dass die Anzahl der vom Betriebssystem erforderlichen Kontextwechsel absolut begrenzt wird, was die Gesamtleistung erhöht, aber den erheblichen Nachteil hat, dass alle anderen verfügbaren CPU-Kerne überhaupt nichts tun.

Aus diesen Gründen werden fortschrittlichere Modelle benötigt.

Vorteile: Kann sehr leistungsfähig und betriebssystemfreundlich sein (dh erfordert nur minimale Eingriffe in das Betriebssystem). Benötigt nur einen einzigen CPU-Kern.

Nachteile: Nutzt nur eine einzige CPU (unabhängig von der verfügbaren Anzahl). Wenn die Nutzlastarbeit nicht gleichmäßig ist, führt dies zu einer ungleichmäßigen Latenz von Antworten.

Das Staged Event-Driven Architecture (SEDA)-Modell

Das SEDA-Netzwerkservermodell ist etwas kompliziert. Es zerlegt eine komplexe, ereignisgesteuerte Anwendung in eine Reihe von Phasen, die durch Warteschlangen verbunden sind. Wenn es jedoch nicht sorgfältig implementiert wird, kann seine Leistung unter dem gleichen Problem leiden wie das MP-Gehäuse. Es funktioniert so:

  • Die Nutzlastarbeit (Aufgabe Nr. 3) wird in so viele Stufen oder Module wie möglich unterteilt. Jedes Modul implementiert eine einzelne spezifische Funktion (denken Sie an „Microservices“ oder „Microkernels“), die sich in einem eigenen separaten Prozess befindet, und diese Module kommunizieren miteinander über Nachrichtenwarteschlangen. Diese Architektur kann als Graph von Knoten dargestellt werden, wobei jeder Knoten ein Prozess ist und die Kanten Nachrichtenwarteschlangen sind.
  • Ein einzelner Prozess führt Task Nr. 1 aus (normalerweise nach dem SPED-Modell), der neue Verbindungen zu bestimmten Einstiegspunktknoten auslagert. Diese Knoten können entweder reine Netzwerkknoten sein (Aufgabe Nr. 2), die die Daten zur Berechnung an andere Knoten weitergeben, oder sie können auch die Nutzdatenverarbeitung (Aufgabe Nr. 3) implementieren. Normalerweise gibt es keinen „Master“-Prozess (z. B. einen, der Antworten sammelt und aggregiert und sie über die Verbindung zurücksendet), da jeder Knoten selbst antworten kann.

Theoretisch kann dieses Modell beliebig komplex sein, wobei der Knotengraph möglicherweise Schleifen, Verbindungen zu anderen ähnlichen Anwendungen aufweist oder wo die Knoten tatsächlich auf entfernten Systemen ausgeführt werden. In der Praxis kann es jedoch selbst bei gut definierten Nachrichten und effizienten Warteschlangen unhandlich werden, über das Verhalten des Systems als Ganzes nachzudenken und zu argumentieren. Der Nachrichtenübermittlungs-Overhead kann die Leistung dieses Modells im Vergleich zum SPED-Modell zerstören, wenn die Arbeit, die an jedem Knoten ausgeführt wird, kurz ist. Die Effizienz dieses Modells ist deutlich geringer als die des SPED-Modells und wird daher normalerweise in Situationen eingesetzt, in denen die Nutzlastarbeit komplex und zeitaufwändig ist.

Vorteile: Der Traum des ultimativen Softwarearchitekten: Alles ist in ordentliche, unabhängige Module unterteilt.

Nachteile: Die Komplexität kann allein durch die Anzahl der Module explodieren, und das Message Queuing ist immer noch viel langsamer als die direkte gemeinsame Speichernutzung.

Das Asymmetric Multi-Process Event-Driven (AMPED)-Modell

Der AMPED-Netzwerkserver ist eine zahmere, einfacher zu modellierende Version von SEDA. Es gibt nicht so viele verschiedene Module und Prozesse und nicht so viele Nachrichtenwarteschlangen. So funktioniert das:

  • Implementieren Sie die Aufgaben Nr. 1 und Nr. 2 in einem einzigen „Master“-Prozess im SPED-Stil. Dies ist der einzige Prozess, der Netzwerk-E/A durchführt.
  • Implementieren Sie Aufgabe Nr. 3 in einem separaten „Worker“-Prozess (möglicherweise in mehreren Instanzen gestartet), der über eine Warteschlange mit dem Master-Prozess verbunden ist (eine Warteschlange pro Prozess).
  • Wenn Daten im „Master“-Prozess empfangen werden, finden Sie einen nicht ausgelasteten (oder inaktiven) Worker-Prozess und übergeben Sie die Daten an seine Nachrichtenwarteschlange. Der Master-Prozess wird vom Prozess benachrichtigt, wenn eine Antwort bereit ist, woraufhin er die Antwort an die Verbindung weiterleitet.

Wichtig dabei ist, dass die Payload-Arbeit in einer festen (meist konfigurierbaren) Anzahl von Prozessen durchgeführt wird, die unabhängig von der Anzahl der Verbindungen ist. Die Vorteile hier sind, dass die Nutzlast beliebig komplex sein kann und sich nicht auf die Netzwerk-E / A auswirkt (was gut für die Latenz ist). Es gibt auch eine Möglichkeit für erhöhte Sicherheit, da nur ein einziger Prozess Netzwerk-E/A durchführt.

Vorteile: Sehr saubere Trennung von Netzwerk-IO und Payload-Arbeit.

Nachteile: Verwendet eine Nachrichtenwarteschlange zum Hin- und Herreichen von Daten zwischen Prozessen, was je nach Art des Protokolls zu einem Engpass werden kann.

Das SYMPED-Modell (Symmetric Multi-Process Event-Driven)

Das SYMPED-Netzwerkservermodell ist in vielerlei Hinsicht der „heilige Gral“ der Netzwerkservermodelle, weil es so ist, als hätte man mehrere Instanzen unabhängiger SPED-„Worker“-Prozesse. Es wird implementiert, indem ein einzelner Prozess Verbindungen in einer Schleife akzeptiert und sie dann an die Worker-Prozesse weiterleitet, von denen jeder eine SPED-ähnliche Ereignisschleife hat. Dies hat einige sehr günstige Folgen:

  • CPUs werden für genau die Anzahl der erzeugten Prozesse belastet, die zu jedem Zeitpunkt entweder Netzwerk-E/A oder Payload-Verarbeitung durchführen. Es gibt keine Möglichkeit, die CPU-Auslastung weiter zu steigern.
  • Wenn die Verbindungen unabhängig sind (z. B. bei HTTP), gibt es keine Interprozesskommunikation zwischen den Worker-Prozessen.

Genau das tun neuere Versionen von Nginx; Sie erzeugen eine kleine Anzahl von Worker-Prozessen, von denen jeder eine Ereignisschleife ausführt. Um die Dinge noch besser zu machen, bieten die meisten Betriebssysteme eine Funktion, mit der mehrere Prozesse unabhängig voneinander auf eingehende Verbindungen an einem TCP-Port lauschen können, wodurch die Notwendigkeit eines bestimmten Prozesses für die Arbeit mit Netzwerkverbindungen entfällt. Wenn die Anwendung, an der Sie arbeiten, auf diese Weise implementiert werden kann, empfehle ich dies.

Vorteile: Strenge Obergrenze für die CPU-Auslastung mit einer steuerbaren Anzahl von SPED-ähnlichen Schleifen.

Nachteile: Da jeder der Prozesse eine SPED-ähnliche Schleife hat, kann die Latenz bei ungleichmäßiger Nutzlastarbeit wieder variieren, genau wie beim normalen SPED-Modell.

Einige Low-Level-Tricks

Neben der Auswahl des besten Architekturmodells für Ihre Anwendung gibt es einige Low-Level-Tricks, mit denen Sie die Leistung des Netzwerkcodes weiter steigern können. Hier ist eine kurze Liste von einigen der effektiveren:

  1. Vermeiden Sie die dynamische Speicherzuweisung. Schauen Sie sich zur Erklärung einfach den Code für die beliebten Speicherzuordner an – sie verwenden komplexe Datenstrukturen, Mutexe, und es steckt einfach so viel Code drin (jemalloc zum Beispiel ist ungefähr 450 KiB C-Code!). Die meisten der oben genannten Modelle können mit vollständig statischen (oder vorab zugewiesenen) Netzwerken und/oder Puffern implementiert werden, die nur bei Bedarf den Besitz zwischen Threads ändern.
  2. Nutzen Sie das Maximum, das das Betriebssystem bieten kann. Die meisten Betriebssysteme lassen zu, dass mehrere Prozesse auf einem einzelnen Socket lauschen, und implementieren Funktionen, bei denen eine Verbindung nicht akzeptiert wird, bis das erste Byte (oder sogar eine erste vollständige Anforderung!) auf dem Socket empfangen wird. Verwenden Sie sendfile(), wenn Sie können.
  3. Machen Sie sich mit dem von Ihnen verwendeten Netzwerkprotokoll vertraut! Beispielsweise ist es normalerweise sinnvoll, den Algorithmus von Nagle zu deaktivieren, und es kann sinnvoll sein, das Verweilen zu deaktivieren, wenn die (Wieder-)Verbindungsrate hoch ist. Erfahren Sie mehr über TCP-Überlastungskontrollalgorithmen und prüfen Sie, ob es sinnvoll ist, einen der neueren auszuprobieren.

Ich werde in einem zukünftigen Blogbeitrag mehr darüber sowie über zusätzliche Techniken und Tricks sprechen. Aber vorerst bietet dies hoffentlich eine nützliche und informative Grundlage in Bezug auf die architektonischen Entscheidungen zum Schreiben von Hochleistungsnetzwerkcode und ihre relativen Vor- und Nachteile.