Was ist ein Netzwerkdienst? Dies ist ein Programm, das eingehende Anforderungen über das Netzwerk akzeptiert und verarbeitet und möglicherweise Antworten zurückgibt.
Es gibt viele Aspekte, in denen sich Netzwerkdienste voneinander unterscheiden. In diesem Artikel konzentriere ich mich auf den Umgang mit eingehenden Anfragen.
Die Auswahl einer Anforderungsverarbeitungsmethode hat weitreichende Konsequenzen. Wie kann ein Chat-Dienst 100.000 gleichzeitigen Verbindungen standhalten? Welchen Ansatz wählen Sie, um Daten aus einem Strom schlecht strukturierter Dateien zu extrahieren? Eine falsche Wahl führt zu Zeit- und Energieverschwendung.
Der Artikel beschreibt Ansätze wie einen Pool von Prozessen / Threads, ereignisorientierte Verarbeitung, halb synchrones / halb asynchrones Muster und viele andere. Es werden zahlreiche Beispiele gegeben, die Vor- und Nachteile von Ansätzen, ihre Merkmale und Anwendungen werden berücksichtigt.
Einführung
Das Thema Abfrageverarbeitungsmethoden ist nicht neu, siehe zum Beispiel: eins , zwei . Die meisten Artikel betrachten dies jedoch nur teilweise. Dieser Artikel soll die Lücken füllen und eine konsistente Darstellung des Problems bieten.
Die folgenden Ansätze werden berücksichtigt:
- sequentielle Verarbeitung
- Anforderungsprozess
- Stream anfordern
- Prozess- / Thread-Pool
- ereignisorientierte Verarbeitung (Reaktormuster)
- halb synchron / halb asynchron
- Fördererbearbeitung
Es ist zu beachten, dass ein Dienst, der Anforderungen verarbeitet, nicht unbedingt ein Netzwerkdienst ist. Dies kann ein Dienst sein, der neue Aufgaben aus der Datenbank oder der Aufgabenwarteschlange empfängt. In diesem Artikel sind Netzwerkdienste gemeint, aber Sie müssen verstehen, dass die betrachteten Ansätze einen größeren Umfang haben.
TL; DR
Am Ende des Artikels befindet sich eine Liste mit einer kurzen Beschreibung der einzelnen Ansätze.
Sequentielle Verarbeitung
Eine Anwendung besteht aus einem einzelnen Thread in einem einzelnen Prozess. Alle Anfragen werden nur nacheinander bearbeitet. Es gibt keine Parallelität. Wenn mehrere Anforderungen gleichzeitig an den Dienst kommen, wird eine davon verarbeitet, der Rest wird in die Warteschlange gestellt.
Außerdem ist dieser Ansatz einfach zu implementieren. Es gibt keine Sperren und keinen Wettbewerb um Ressourcen. Das offensichtliche Minus ist die Unfähigkeit, mit einer großen Anzahl von Kunden zu skalieren.
Anforderungsprozess
Eine Anwendung besteht aus einem Kernprozess, der eingehende Anforderungen und Workflows akzeptiert. Für jede neue Anforderung erstellt der Hauptprozess einen Workflow, der die Anforderung verarbeitet. Die Skalierung nach Anzahl der Anforderungen ist einfach: Jede Anforderung erhält ihren eigenen Prozess.
Es gibt nichts Kompliziertes in dieser Architektur, aber es hat die Probleme Einschränkungen :
- Der Prozess verbraucht viele Ressourcen.
Versuchen Sie, 10.000 gleichzeitige Verbindungen zu PostgreSQL RDBMS herzustellen, und sehen Sie sich das Ergebnis an. - Prozesse haben keinen gemeinsamen Speicher (Standard). Wenn Sie Zugriff auf gemeinsam genutzte Daten oder einen gemeinsam genutzten Cache benötigen, müssen Sie den gemeinsam genutzten Speicher zuordnen (Linux mmap, munmap aufrufen) oder externen Speicher verwenden (memcahed, redis).
Diese Probleme hören keineswegs auf. Im Folgenden wird gezeigt, wie sie in PostgeSQL RDBMS verwaltet werden.
Vorteile dieser Architektur:
- Der Fall eines der Prozesse hat keine Auswirkungen auf die anderen. Beispielsweise wird durch einen Verarbeitungsfehler in seltenen Fällen nicht die gesamte Anwendung gelöscht, sondern nur die verarbeitete Anforderung
- Differenzierung von Zugriffsrechten auf Betriebssystemebene. Da der Prozess die Essenz des Betriebssystems darstellt, können Sie seine Standardmechanismen zum Abgrenzen von Zugriffsrechten auf Betriebssystemressourcen verwenden
- Sie können den laufenden Prozess im laufenden Betrieb ändern. Wenn beispielsweise ein separates Skript zum Verarbeiten einer Anforderung verwendet wird und der Verarbeitungsalgorithmus ersetzt wird, reicht es aus, das Skript zu ändern. Ein Beispiel wird unten betrachtet.
- Multicore-Maschinen effizient eingesetzt
Beispiele:
- PostgreSQL RDBMS erstellt für jede neue Verbindung einen neuen Prozess. Shared Memory wird verwendet, um mit allgemeinen Daten zu arbeiten. PostgreSQL kann den hohen Ressourcenverbrauch von Prozessen auf viele verschiedene Arten bewältigen. Wenn es nur wenige Kunden gibt (ein dedizierter Stand für Analysten), gibt es kein solches Problem. Wenn eine einzelne Anwendung auf die Datenbank zugreift, können Sie auf Anwendungsebene einen Datenbankverbindungspool erstellen. Wenn es viele Anwendungen gibt, können Sie pgbouncer verwenden
- sshd wartet bei jeder Verbindung auf eingehende Anfragen an Port 22 und Fork. Jede SSH-Verbindung ist eine Abzweigung des SSH-Dämons, der Benutzerbefehle nacheinander empfängt und ausführt. Dank dieser Architektur werden Ressourcen des Betriebssystems selbst verwendet, um Zugriffsrechte zu unterscheiden
- Ein Beispiel aus unserer eigenen Praxis. Es gibt einen Strom unstrukturierter Dateien, aus denen Sie Metadaten abrufen müssen. Der Hauptdienstprozess verteilt Dateien auf die Handlerprozesse. Jeder Handlerprozess ist ein Skript, das einen Dateipfad als Parameter verwendet. Die Dateiverarbeitung erfolgt in einem separaten Prozess. Aufgrund eines Verarbeitungsfehlers stürzt der gesamte Dienst nicht ab. Um den Verarbeitungsalgorithmus zu aktualisieren, reicht es aus, die Verarbeitungsskripte zu ändern, ohne den Dienst zu beenden.
Im Allgemeinen muss ich sagen, dass dieser Ansatz seine Vorteile hat, die seinen Umfang bestimmen, aber die Skalierbarkeit ist sehr begrenzt.
Stream anfordern
Dieser Ansatz ähnelt dem vorherigen. Der Unterschied besteht darin, dass Threads anstelle von Prozessen verwendet werden. Auf diese Weise können Sie den gemeinsam genutzten Speicher sofort verwenden. Die anderen Vorteile des vorherigen Ansatzes können jedoch nicht mehr genutzt werden, während der Ressourcenverbrauch ebenfalls hoch sein wird.
Vorteile:
- Standardmäßig gemeinsam genutzter Speicher
- Einfache Implementierung
- Effiziente Nutzung von Multi-Core-CPUs
Nachteile:
- Ein Stream verbraucht viele Ressourcen. Unter Unix-ähnlichen Betriebssystemen verbraucht ein Thread fast so viele Ressourcen wie ein Prozess
Ein Anwendungsbeispiel ist MySQL. Es sollte jedoch beachtet werden, dass MySQL einen gemischten Ansatz verwendet, sodass dieses Beispiel im nächsten Abschnitt erläutert wird.
Prozess- / Thread-Pool
Streams (Prozesse) erzeugen teuer und lang. Um keine Ressourcen zu verschwenden, können Sie denselben Thread wiederholt verwenden. Nachdem wir die maximale Anzahl von Threads zusätzlich begrenzt haben, erhalten wir einen Pool von Threads (Prozessen). Jetzt akzeptiert der Hauptthread eingehende Anforderungen und stellt sie in eine Warteschlange. Workflows nehmen Anforderungen aus der Warteschlange und verarbeiten sie. Dieser Ansatz kann als natürliche Skalierung der sequentiellen Verarbeitung von Anforderungen angesehen werden: Jeder Worker-Thread kann Flows nur sequentiell verarbeiten. Wenn Sie sie bündeln, können Sie Anforderungen parallel verarbeiten. Wenn jeder Stream 1000 U / min verarbeiten kann, bewältigen 5 Streams die Last nahe 5000 U / s (vorbehaltlich eines minimalen Wettbewerbs um gemeinsam genutzte Ressourcen).
Der Pool kann zu Beginn des Dienstes im Voraus erstellt oder schrittweise gebildet werden. Die Verwendung eines Thread-Pools ist häufiger als ermöglicht es Ihnen, gemeinsam genutzten Speicher anzuwenden.
Die Größe des Thread-Pools muss nicht begrenzt sein. Ein Dienst kann freie Threads aus dem Pool verwenden. Wenn keine vorhanden sind, erstellen Sie einen neuen Thread. Nach der Verarbeitung der Anforderung tritt der Thread dem Pool bei und wartet auf die nächste Anforderung. Diese Option ist eine Kombination aus einem Thread-on-Request-Ansatz und einem Thread-Pool. Ein Beispiel wird unten gegeben.
Vorteile:
- die Verwendung vieler CPU-Kerne
- Kostenreduzierung für die Erstellung eines Threads / Prozesses
Nachteile:
- Begrenzte Skalierbarkeit bei der Anzahl gleichzeitiger Clients. Die Verwendung des Pools ermöglicht es uns, denselben Thread ohne zusätzliche Ressourcenkosten mehrmals wiederzuverwenden, löst jedoch nicht das grundlegende Problem einer großen Anzahl von Ressourcen, die vom Thread / Prozess ausgegeben werden. Das Erstellen eines Chat-Dienstes, der mit diesem Ansatz 100.000 gleichzeitigen Verbindungen standhält, schlägt fehl.
- Die Skalierbarkeit wird durch gemeinsam genutzte Ressourcen eingeschränkt, z. B. wenn Threads gemeinsam genutzten Speicher verwenden, indem der Zugriff mithilfe von Semaphoren / Mutexen angepasst wird. Dies ist eine Einschränkung aller Ansätze, die gemeinsam genutzte Ressourcen verwenden.
Beispiele:
- Python-Anwendung, die mit uWSGI und nginx ausgeführt wird. Der uWSGI-Hauptprozess empfängt eingehende Anforderungen von nginx und verteilt sie auf die Python-Prozesse des Interpreters, der die Anforderungen verarbeitet. Die Anwendung kann auf jedem uWSGI-kompatiblen Framework geschrieben werden - Django, Flask usw.
- MySQL verwendet einen Thread-Pool: Jede neue Verbindung wird von einem der freien Threads aus dem Pool verarbeitet. Wenn keine freien Threads vorhanden sind, erstellt MySQL einen neuen Thread. Die Größe des Pools freier Threads und die maximale Anzahl von Threads (Verbindungen) sind durch die Einstellungen begrenzt.
Vielleicht ist dies einer der häufigsten Ansätze zum Aufbau von Netzwerkdiensten, wenn nicht der häufigste. Sie können gut skalieren und große U / min erreichen. Die Hauptbeschränkung des Ansatzes ist die Anzahl der gleichzeitig verarbeiteten Netzwerkverbindungen. Tatsächlich funktioniert dieser Ansatz nur dann gut, wenn die Anfragen kurz sind oder nur wenige Kunden.
Ereignisorientierte Verarbeitung (Reaktormuster)
Zwei Paradigmen - synchron und asynchron - sind ewige Konkurrenten voneinander. Bisher wurden nur synchrone Ansätze diskutiert, aber es wäre falsch, den asynchronen Ansatz zu ignorieren. Die ereignisorientierte oder reaktive Anforderungsverarbeitung ist ein Ansatz, bei dem jede E / A-Operation asynchron ausgeführt wird und am Ende der Operation ein Handler aufgerufen wird. In der Regel besteht die Verarbeitung jeder Anforderung aus vielen asynchronen Aufrufen, gefolgt von der Ausführung von Handlern. Zu jedem Zeitpunkt führt eine Single-Threaded-Anwendung den Code nur eines Handlers aus, aber die Ausführung der Handler verschiedener Anforderungen wechselt sich ab, sodass Sie viele parallele Anforderungen gleichzeitig (pseudo-parallel) verarbeiten können.
Eine vollständige Erörterung dieses Ansatzes würde den Rahmen dieses Artikels sprengen. Für einen tieferen Blick können Sie Reactor (Reactor) empfehlen. Was ist das Geheimnis der NodeJS-Geschwindigkeit? , Innerhalb von NGINX . Hier beschränken wir uns darauf, die Vor- und Nachteile dieses Ansatzes zu betrachten.
Vorteile:
- Effektive Skalierung durch rps und die Anzahl der gleichzeitigen Verbindungen. Ein reaktiver Dienst kann gleichzeitig eine große Anzahl von Verbindungen (Zehntausende) verarbeiten, wenn die meisten Verbindungen auf den Abschluss der E / A warten
Nachteile:
- Die Komplexität der Entwicklung. Das asynchrone Programmieren ist schwieriger als das synchrone Programmieren. Die Logik der Anforderungsverarbeitung ist komplexer, das Debuggen ist auch schwieriger als bei synchronem Code.
- Fehler, die zum Blockieren des gesamten Dienstes führen. Wenn die Sprache oder Laufzeit ursprünglich nicht für die asynchrone Verarbeitung ausgelegt ist, kann eine einzelne synchrone Operation den gesamten Dienst blockieren und die Möglichkeit einer Skalierung zunichte machen.
- Schwierig über CPU-Kerne zu skalieren. Bei diesem Ansatz wird ein einzelner Thread in einem einzelnen Prozess vorausgesetzt, sodass Sie nicht mehrere CPU-Kerne gleichzeitig verwenden können. Es ist zu beachten, dass es Möglichkeiten gibt, diese Einschränkung zu umgehen.
- Folgerung aus dem vorherigen Absatz: Dieser Ansatz lässt sich für Anforderungen, die CPU erfordern, nicht gut skalieren. Die Anzahl der RPS für diesen Ansatz ist umgekehrt proportional zur Anzahl der CPU-Operationen, die zur Verarbeitung jeder Anforderung erforderlich sind. Das Anfordern von CPU-Anforderungen negiert die Vorteile dieses Ansatzes.
Beispiele:
- Node.js verwendet das Out-of-Box-Reaktormuster. Weitere Informationen finden Sie unter Was ist das Geheimnis der NodeJS-Geschwindigkeit?
- nginx: Die Arbeitsprozesse von nginx verwenden das Reaktormuster, um Anforderungen parallel zu verarbeiten. Weitere Informationen finden Sie unter Inside NGINX.
- C / C ++ - Programm, das direkt Betriebssystemtools verwendet (epoll unter Linux, IOCP unter Windows, kqueue unter FreeBSD) oder das Framework (libev, libevent, libuv usw.) verwendet.
Halb synchron / halb asynchron
Der Name stammt von POSA: Patterns for Concurrent and Networked Objects . Im Original wird dieses Muster sehr weit ausgelegt, aber für die Zwecke dieses Artikels werde ich dieses Muster etwas enger verstehen. Half Sync / Half Async ist ein Anforderungsverarbeitungsansatz, bei dem für jede Anforderung ein einfacher Kontrollfluss (grüner Thread) verwendet wird. Ein Programm besteht aus einem oder mehreren Threads auf Betriebssystemebene. Das Programmausführungssystem unterstützt jedoch grüne Threads, die das Betriebssystem nicht sieht und nicht steuern kann.
Einige Beispiele , um die Überlegung genauer zu machen:
- Service in Go-Sprache. Die Go-Sprache unterstützt viele einfache Ausführungsthreads - Goroutine. Das Programm verwendet einen oder mehrere Betriebssystem-Threads, aber der Programmierer arbeitet mit Goroutinen, die transparent zwischen Betriebssystem-Threads verteilt sind, um Mehrkern-CPUs zu verwenden
- Python-Dienst mit Gevent-Bibliothek. Die Gevent-Bibliothek ermöglicht es dem Programmierer, grüne Threads auf Bibliotheksebene zu verwenden. Das gesamte Programm wird in einem einzigen Betriebssystem-Thread ausgeführt.
Im Wesentlichen soll dieser Ansatz die hohe Leistung des asynchronen Ansatzes mit der Einfachheit der Programmierung von synchronem Code kombinieren.
Bei diesem Ansatz arbeitet das Programm trotz der Illusion der Synchronität asynchron: Das Programmausführungssystem steuert die Ereignisschleife, und jede "synchrone" Operation ist tatsächlich asynchron. Wenn eine solche Operation aufgerufen wird, ruft das Ausführungssystem die asynchrone Operation unter Verwendung der Betriebssystemtools auf und registriert den Handler für den Abschluss der Operation. Wenn die asynchrone Operation abgeschlossen ist, ruft das Ausführungssystem den zuvor registrierten Handler auf, der das Programm zum Zeitpunkt des Aufrufs der "synchronen" Operation weiter ausführt.
Infolgedessen enthält der halb synchrone / halb asynchrone Ansatz sowohl einige Vor- als auch einige Nachteile des asynchronen Ansatzes. Der Umfang des Artikels erlaubt es uns nicht, diesen Ansatz im Detail zu betrachten. Für Interessierte empfehle ich Ihnen, das gleichnamige Kapitel im Buch POSA: Muster für gleichzeitige und vernetzte Objekte zu lesen.
Mit dem halb synchronen / halb asynchronen Ansatz selbst wird eine neue „Green Stream“ -Einheit eingeführt - ein einfacher Kontrollfluss auf der Ebene des Programm- oder Bibliotheksausführungssystems. Was mit grünen Fäden zu tun ist, entscheidet ein Programmierer. Es kann einen Pool grüner Threads verwenden und für jede neue Anforderung einen neuen grünen Thread erstellen. Der Unterschied zu OS-Threads / -Prozessen besteht darin, dass grüne Threads viel billiger sind: Sie verbrauchen viel weniger RAM und werden viel schneller erstellt. Auf diese Weise können Sie eine große Anzahl grüner Threads erstellen, z. B. Hunderttausende in der Sprache Go. Eine solch große Menge rechtfertigt die Verwendung des Green-Flow-on-Request-Ansatzes.
Vorteile:
- Es skaliert gut in rps und der Anzahl gleichzeitiger Verbindungen
- Code ist einfacher zu schreiben und zu debuggen als der asynchrone Ansatz
Nachteile:
- Da die Ausführung von Operationen tatsächlich asynchron ist, sind Programmierfehler möglich, wenn eine einzelne synchrone Operation den gesamten Prozess blockiert. Dies ist insbesondere in Sprachen zu spüren, in denen dieser Ansatz mithilfe einer Bibliothek implementiert wird, beispielsweise Python.
- Die Deckkraft des Programms. Bei Verwendung von Threads oder Betriebssystemprozessen ist der Programmausführungsalgorithmus klar: Jeder Thread / Prozess führt Operationen in der Reihenfolge aus, in der sie in den Code geschrieben sind. Bei Verwendung des halb synchronen / halb asynchronen Ansatzes können sich Operationen, die nacheinander in den Code geschrieben werden, unvorhersehbar mit Operationen abwechseln, die gleichzeitige Anforderungen verarbeiten.
- Ungeeignet für Echtzeitsysteme. Die asynchrone Verarbeitung von Anforderungen erschwert die Bereitstellung von Garantien für die Verarbeitungszeit jeder einzelnen Anforderung erheblich. Dies ist eine Folge des vorherigen Absatzes.
Abhängig von der Implementierung lässt sich dieser Ansatz gut über CPU-Kerne hinweg skalieren (Golang) oder überhaupt nicht skalieren (Python).
Dieser Ansatz sowie die asynchrone Methode ermöglichen es Ihnen, eine große Anzahl gleichzeitiger Verbindungen zu verarbeiten. Das Programmieren eines Dienstes mit diesem Ansatz ist jedoch einfacher, weil Code wird synchron geschrieben.
Förderbearbeitung
Wie der Name schon sagt, werden bei diesem Ansatz Anforderungen per Pipeline verarbeitet. Der Verarbeitungsprozess besteht aus mehreren OS-Threads, die in einer Kette angeordnet sind. Jeder Thread ist ein Glied in der Kette und führt eine bestimmte Teilmenge der Operationen aus, die zur Verarbeitung der Anforderung erforderlich sind. Jede Anforderung durchläuft nacheinander alle Glieder in der Kette, und unterschiedliche Glieder verarbeiten zu jedem Zeitpunkt unterschiedliche Anforderungen.
Vorteile:
- Dieser Ansatz lässt sich gut in rps skalieren. Je mehr Glieder in der Kette sind, desto mehr Anfragen werden pro Sekunde verarbeitet.
- Durch die Verwendung mehrerer Threads können Sie gut über CPU-Kerne skalieren.
Nachteile:
- Nicht alle Abfragekategorien sind für diesen Ansatz geeignet. Zum Beispiel wird es schwierig und unpraktisch sein, lange Abstimmungen mit diesem Ansatz zu organisieren.
- Die Komplexität der Implementierung und des Debuggens. Die sequentielle Verarbeitung so zu schlagen, dass die Produktivität hoch ist, kann schwierig sein. Das Debuggen eines Programms, in dem jede Anforderung nacheinander in mehreren parallelen Threads verarbeitet wird, ist schwieriger als das sequentielle Verarbeiten.
Beispiele:
- Ein interessantes Beispiel für die Verarbeitung von Förderbändern wurde im Bericht highload 2018 Die Entwicklung der Architektur des Handels- und Clearingsystems der Moskauer Börse beschrieben
Pipelining ist weit verbreitet, aber meistens sind die Links einzelne Komponenten in unabhängigen Prozessen, die Nachrichten austauschen, beispielsweise über eine Nachrichtenwarteschlange oder eine Datenbank.
Zusammenfassung
Eine kurze Zusammenfassung der betrachteten Ansätze:
- Synchrone Verarbeitung.
Ein einfacher Ansatz, der jedoch in Bezug auf die Skalierbarkeit sowohl in Bezug auf die Geschwindigkeit als auch in Bezug auf die Anzahl der gleichzeitigen Verbindungen sehr eingeschränkt ist. Es ist nicht möglich, mehrere CPU-Kerne gleichzeitig zu verwenden. - Ein neuer Prozess für jede Anfrage.
Hohe Kosten für die Erstellung von Prozessen. , . . ( , ). - .
, , . , . - /.
/. . rps . . . - - (reactor ).
rps . - , . CPU - Half sync/half async.
rps . CPU (Golang) (Python). , () . reactor , , reactor . - .
, . (, long polling ).
, .
: ? , ?
Referenzen
- :
- - :
- :
- Half sync/half async:
- :
- :