Server-seitige E/A-Leistung: Node vs. PHP vs. Java vs. Go

Okt 22, 2021
admin

Das Input/Output (I/O)-Modell Ihrer Anwendung zu verstehen, kann den Unterschied zwischen einer Anwendung bedeuten, die mit der Belastung zurechtkommt, der sie ausgesetzt ist, und einer, die angesichts realer Anwendungsfälle zusammenbricht. Solange Ihre Anwendung klein ist und keine hohe Last aufweist, spielt sie vielleicht keine große Rolle. Aber wenn die Verkehrslast Ihrer Anwendung zunimmt, kann die Arbeit mit dem falschen E/A-Modell sehr schmerzhaft sein.

Und wie in fast jeder Situation, in der mehrere Ansätze möglich sind, geht es nicht nur darum, welcher besser ist, sondern auch darum, die Kompromisse zu verstehen. Machen wir einen Spaziergang durch die E/A-Landschaft und sehen wir, was wir ausspähen können.

In diesem Artikel werden wir Node, Java, Go und PHP mit Apache vergleichen, diskutieren, wie die verschiedenen Sprachen ihre E/A modellieren, die Vor- und Nachteile jedes Modells, und schließen mit einigen rudimentären Benchmarks. Wenn Sie sich Gedanken über die E/A-Leistung Ihrer nächsten Webanwendung machen, ist dieser Artikel genau das Richtige für Sie.

E/A-Grundlagen: Eine kurze Auffrischung

Um die Faktoren zu verstehen, die mit E/A zu tun haben, müssen wir zunächst die Konzepte auf der Ebene des Betriebssystems durchgehen. Es ist zwar unwahrscheinlich, dass Sie mit vielen dieser Konzepte direkt zu tun haben, aber Sie haben ständig indirekt über die Laufzeitumgebung Ihrer Anwendung mit ihnen zu tun. Und auf die Details kommt es an.

Systemaufrufe

Zunächst haben wir Systemaufrufe, die wie folgt beschrieben werden können:

  • Ihr Programm (im „Benutzerland“, wie man sagt) muss den Kernel des Betriebssystems bitten, eine E/A-Operation in seinem Namen durchzuführen.
  • Ein „Syscall“ ist das Mittel, mit dem Ihr Programm den Kernel auffordert, etwas zu tun. Die Einzelheiten, wie dies implementiert wird, variieren von Betriebssystem zu Betriebssystem, aber das Grundkonzept ist das gleiche. Es wird eine bestimmte Anweisung geben, die die Kontrolle von Ihrem Programm an den Kernel überträgt (wie ein Funktionsaufruf, aber mit einigen Besonderheiten, die speziell für diese Situation gedacht sind). Im Allgemeinen sind Syscalls blockierend, d.h. Ihr Programm wartet darauf, dass der Kernel zu Ihrem Code zurückkehrt.
  • Der Kernel führt die zugrunde liegende E/A-Operation auf dem betreffenden physischen Gerät (Festplatte, Netzwerkkarte usw.) durch und antwortet auf den Syscall. In der realen Welt muss der Kernel möglicherweise eine Reihe von Dingen tun, um Ihre Anfrage zu erfüllen, z. B. warten, bis das Gerät bereit ist, seinen internen Zustand aktualisieren usw., aber als Anwendungsentwickler interessiert Sie das nicht. Das ist die Aufgabe des Kernels.

Blockierende vs. nicht-blockierende Aufrufe

Nun, ich habe oben gesagt, dass Syscalls blockierend sind, und das ist im allgemeinen Sinne wahr. Das bedeutet, dass der Kernel Ihre Anfrage entgegennimmt, sie irgendwo in eine Warteschlange oder einen Puffer stellt und dann sofort zurückkehrt, ohne auf die eigentliche E/A zu warten. Er „blockiert“ also nur für eine sehr kurze Zeitspanne, gerade lange genug, um Ihre Anfrage in die Warteschlange zu stellen.

Ein paar Beispiele (von Linux-Systemaufrufen) könnten zur Klärung beitragen:- read() ist ein blockierender Aufruf – Sie übergeben ihm ein Handle, das angibt, welche Datei und welchen Puffer er mit den zu lesenden Daten versorgen soll, und der Aufruf kehrt zurück, wenn die Daten da sind. epoll_create(), epoll_ctl() und epoll_wait() sind Aufrufe, die es ermöglichen, eine Gruppe von Handles zu erstellen, die abgehört werden sollen, Handler aus dieser Gruppe hinzuzufügen oder zu entfernen und dann zu blockieren, bis eine Aktivität stattfindet. Auf diese Weise können Sie eine große Anzahl von E/A-Operationen mit einem einzigen Thread effizient steuern, aber ich greife hier zu weit vor. Das ist großartig, wenn Sie die Funktionalität benötigen, aber wie Sie sehen können, ist es sicherlich komplexer in der Anwendung.

Es ist wichtig, die Größenordnung des Unterschieds im Timing hier zu verstehen. Wenn ein CPU-Kern mit 3 GHz läuft, führt er, ohne auf Optimierungen einzugehen, die die CPU durchführen kann, 3 Milliarden Zyklen pro Sekunde aus (oder 3 Zyklen pro Nanosekunde). Ein nicht blockierender Systemaufruf kann in der Größenordnung von 10s von Zyklen dauern – oder „relativ wenige Nanosekunden“. Ein Aufruf, der blockiert, während Informationen über das Netz empfangen werden, könnte viel länger dauern – sagen wir zum Beispiel 200 Millisekunden (1/5 einer Sekunde). Und nehmen wir an, dass der nicht blockierende Aufruf 20 Nanosekunden und der blockierende Aufruf 200.000.000 Nanosekunden gedauert hat. Ihr Prozeß hat gerade 10 Millionen Mal länger auf den blockierenden Aufruf gewartet.

Der Kernel bietet die Möglichkeit, sowohl blockierende E/A („lese von dieser Netzwerkverbindung und gib mir die Daten“) als auch nichtblockierende E/A („sag mir, wenn eine dieser Netzwerkverbindungen neue Daten hat“) durchzuführen. Und je nachdem, welcher Mechanismus verwendet wird, wird der aufrufende Prozess für sehr unterschiedliche Zeitspannen blockiert.

Scheduling

Der dritte wichtige Punkt ist, was passiert, wenn man eine Menge Threads oder Prozesse hat, die anfangen zu blockieren.

Für unsere Zwecke gibt es keinen großen Unterschied zwischen einem Thread und einem Prozess. Im wirklichen Leben besteht der auffälligste leistungsbezogene Unterschied darin, dass Threads sich denselben Speicher teilen und Prozesse jeweils ihren eigenen Speicherplatz haben, so dass separate Prozesse viel mehr Speicher benötigen. Aber wenn wir über Scheduling sprechen, geht es eigentlich um eine Liste von Dingen (Threads und Prozesse gleichermaßen), die jeweils einen Teil der Ausführungszeit auf den verfügbaren CPU-Kernen erhalten müssen. Wenn Sie 300 Threads laufen lassen und 8 Kerne zur Verfügung haben, müssen Sie die Zeit so aufteilen, dass jeder seinen Anteil bekommt, wobei jeder Kern für eine kurze Zeit läuft und dann zum nächsten Thread wechselt. Dies geschieht durch einen „Kontextwechsel“, bei dem die CPU von der Ausführung eines Threads/Prozesses zum nächsten wechselt.

Diese Kontextwechsel sind mit Kosten verbunden – sie benötigen einige Zeit. In einigen schnellen Fällen kann es weniger als 100 Nanosekunden dauern, aber es ist nicht ungewöhnlich, dass es 1000 Nanosekunden oder länger dauert, je nach Implementierungsdetails, Prozessorgeschwindigkeit/Architektur, CPU-Cache usw.

Und je mehr Threads (oder Prozesse), desto mehr Kontextwechsel. Wenn wir von Tausenden von Threads und Hunderten von Nanosekunden für jeden sprechen, können die Dinge sehr langsam werden.

Nicht-blockierende Aufrufe sagen dem Kernel jedoch im Wesentlichen: „Ruf mich nur an, wenn du neue Daten oder Ereignisse auf einer dieser Verbindungen hast.“ Diese nicht-blockierenden Aufrufe wurden entwickelt, um große E/A-Lasten effizient zu handhaben und Kontextwechsel zu reduzieren.

Sind Sie so weit? Denn jetzt kommt der spaßige Teil: Schauen wir uns an, was einige populäre Sprachen mit diesen Werkzeugen machen, und ziehen wir einige Schlüsse über die Kompromisse zwischen Benutzerfreundlichkeit und Leistung … und andere interessante Leckerbissen.

Als Anmerkung: Die in diesem Artikel gezeigten Beispiele sind zwar trivial (und unvollständig, da nur die relevanten Teile gezeigt werden); Datenbankzugriff, externe Caching-Systeme (Memcache usw.) und alles, was E/A erfordert, wird am Ende irgendeine Art von E/A-Aufruf unter der Haube durchführen, der den gleichen Effekt wie die gezeigten einfachen Beispiele hat. In den Szenarien, in denen die E/A als „blockierend“ beschrieben wird (PHP, Java), sind die Lese- und Schreibvorgänge der HTTP-Anfrage und -Antwort selbst blockierende Aufrufe: Auch hier ist mehr E/A im System versteckt, was zu Leistungsproblemen führt.

Es gibt viele Faktoren, die bei der Wahl einer Programmiersprache für ein Projekt eine Rolle spielen. Es gibt sogar sehr viele Faktoren, wenn man nur die Leistung betrachtet. Aber wenn Sie sich Sorgen machen, dass Ihr Programm hauptsächlich durch E/A eingeschränkt wird, wenn die E/A-Leistung für Ihr Projekt entscheidend ist, dann sind dies Dinge, die Sie wissen müssen.

Der „Keep It Simple“-Ansatz: PHP

In den 90er Jahren trugen viele Leute Converse-Schuhe und schrieben CGI-Skripte in Perl. Dann kam PHP auf, und so sehr manche Leute auch darüber schimpfen mögen, es machte die Erstellung dynamischer Webseiten viel einfacher.

Das Modell, das PHP verwendet, ist ziemlich einfach. Es gibt einige Variationen davon, aber Ihr durchschnittlicher PHP-Server sieht so aus:

Eine HTTP-Anfrage kommt vom Browser eines Benutzers und trifft auf Ihren Apache-Webserver. Der Apache erstellt für jede Anfrage einen eigenen Prozess, mit einigen Optimierungen zur Wiederverwendung, um die Anzahl der Prozesse zu minimieren (das Erstellen von Prozessen ist relativ gesehen langsam).Der Apache ruft PHP auf und weist es an, die entsprechende .php-Datei auf der Festplatte auszuführen.Der PHP-Code wird ausgeführt und führt blockierende E/A-Aufrufe durch. Du rufst file_get_contents() in PHP auf und unter der Haube macht es read() Systemaufrufe und wartet auf die Ergebnisse.

Und natürlich ist der eigentliche Code einfach direkt in deine Seite eingebettet, und die Operationen sind blockierend:

<?php// blocking file I/O$file_data = file_get_contents('/path/to/file.dat');// blocking network I/O$curl = curl_init('http://example.com/example-microservice');$result = curl_exec($curl);// some more blocking network I/O$result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100');?>

In Bezug auf die Integration mit dem System sieht das so aus:

Ganz einfach: ein Prozess pro Anfrage. E/A-Aufrufe blockieren einfach. Der Vorteil? Es ist einfach und es funktioniert. Nachteil? Wenn 20.000 Clients gleichzeitig darauf zugreifen, geht Ihr Server in Flammen auf. Dieser Ansatz lässt sich nicht gut skalieren, da die vom Kernel bereitgestellten Tools zur Bewältigung großer E/A-Mengen (epoll usw.) nicht verwendet werden. Und um die Sache noch schlimmer zu machen, verbraucht das Ausführen eines separaten Prozesses für jede Anfrage eine Menge Systemressourcen, insbesondere Speicher, der in einem solchen Szenario oft das erste ist, was einem ausgeht.

Anmerkung: Der Ansatz, der für Ruby verwendet wird, ist dem von PHP sehr ähnlich, und in einer groben, allgemeinen, händischen Weise können sie für unsere Zwecke als gleich angesehen werden.

Der Multithreading-Ansatz: Java

Java kam also ungefähr zu der Zeit auf, als Sie Ihren ersten Domainnamen kauften und es cool war, nach einem Satz einfach „dot com“ zu sagen. Und Java hat Multithreading in die Sprache eingebaut, was (vor allem für die Zeit, in der es entwickelt wurde) ziemlich genial ist.

Die meisten Java-Webserver funktionieren, indem sie für jede eingehende Anfrage einen neuen Ausführungsstrang starten und dann in diesem Strang schließlich die Funktion aufrufen, die Sie als Anwendungsentwickler geschrieben haben.

Die Ausführung von E/A in einem Java-Servlet sieht in der Regel so aus:

public void doGet(HttpServletRequest request,HttpServletResponse response) throws ServletException, IOException{// blocking file I/OInputStream fileIs = new FileInputStream("/path/to/file");// blocking network I/OURLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection();InputStream netIs = urlConnection.getInputStream();// some more blocking network I/Oout.println("...");}

Da unsere obige doGet-Methode einer Anfrage entspricht und in einem eigenen Thread ausgeführt wird, haben wir statt eines separaten Prozesses für jede Anfrage, der seinen eigenen Speicher benötigt, einen eigenen Thread. Dies hat einige nette Vorteile, wie z.B. die Möglichkeit, den Status, zwischengespeicherte Daten usw. zwischen Threads zu teilen, da sie auf den Speicher des anderen zugreifen können, aber die Auswirkungen auf die Interaktion mit dem Zeitplan sind immer noch fast identisch mit dem, was im PHP-Beispiel zuvor gemacht wurde. Für jede Anfrage wird ein neuer Thread erstellt, und die verschiedenen E/A-Operationen werden innerhalb dieses Threads blockiert, bis die Anfrage vollständig bearbeitet ist. Threads werden gepoolt, um die Kosten für ihre Erstellung und Zerstörung zu minimieren, aber trotzdem bedeuten Tausende von Verbindungen auch Tausende von Threads, was schlecht für den Scheduler ist.

Ein wichtiger Meilenstein ist, dass Java in Version 1.4 (und ein weiteres bedeutendes Upgrade in 1.7) die Fähigkeit erlangt hat, nicht-blockierende E/A-Aufrufe durchzuführen. Die meisten Anwendungen, ob im Web oder anderswo, nutzen dies nicht, aber zumindest ist es verfügbar. Einige Java-Webserver versuchen, dies auf verschiedene Art und Weise zu nutzen; die große Mehrheit der eingesetzten Java-Anwendungen funktioniert jedoch immer noch wie oben beschrieben.

Java bringt uns näher und hat sicherlich einige gute Out-of-the-Box-Funktionen für I/O, aber es löst immer noch nicht wirklich das Problem, was passiert, wenn man eine stark I/O-gebundene Anwendung hat, die mit vielen Tausenden von blockierenden Threads in den Boden gestampft wird.

Non-blocking I/O as a First Class Citizen: Node

Das beliebte Kind im Block, wenn es um bessere E/A geht, ist Node.js. Jedem, der auch nur eine kurze Einführung in Node hatte, wurde gesagt, dass es „non-blocking“ ist und dass es I/O effizient handhabt. Und das ist im Allgemeinen auch richtig. Aber der Teufel steckt im Detail, und die Mittel, mit denen diese Hexerei erreicht wurde, spielen eine Rolle, wenn es um die Leistung geht.

Der Paradigmenwechsel, den Node vollzieht, besteht im Wesentlichen darin, dass man nicht mehr sagt: „Schreib deinen Code hier, um die Anfrage zu bearbeiten“, sondern „Schreib den Code hier, um mit der Bearbeitung der Anfrage zu beginnen.“ Jedes Mal, wenn Sie etwas tun müssen, das E/A beinhaltet, stellen Sie die Anfrage und geben eine Callback-Funktion an, die Node aufruft, wenn sie fertig ist.

Typischer Node-Code für die Durchführung einer E/A-Operation in einer Anfrage sieht so aus:

http.createServer(function(request, response) {fs.readFile('/path/to/file', 'utf8', function(err, data) {response.end(data);});});

Wie Sie sehen können, gibt es hier zwei Callback-Funktionen. Die erste wird aufgerufen, wenn eine Anfrage beginnt, und die zweite wird aufgerufen, wenn die Dateidaten verfügbar sind.

Damit wird Node die Möglichkeit gegeben, die E/A zwischen diesen Rückrufen effizient zu verarbeiten. Ein Szenario, in dem dies noch relevanter wäre, wäre ein Datenbankaufruf in Node, aber ich werde mir nicht die Mühe machen, das Beispiel zu erläutern, weil es genau das gleiche Prinzip ist: Sie starten den Datenbankaufruf und geben Node eine Callback-Funktion, es führt die E/A-Operationen separat mit nicht-blockierenden Aufrufen durch und ruft dann Ihre Callback-Funktion auf, wenn die angeforderten Daten verfügbar sind. Dieser Mechanismus, bei dem E/A-Aufrufe in eine Warteschlange gestellt werden und Node die Bearbeitung übernimmt und dann einen Rückruf erhält, wird „Ereignisschleife“ genannt. Und es funktioniert ziemlich gut.

Es gibt jedoch einen Haken an diesem Modell. Unter der Haube hat der Grund dafür viel mehr damit zu tun, wie die V8-JavaScript-Engine (die JS-Engine von Chrome, die von Node verwendet wird) implementiert ist als alles andere. Der JS-Code, den Sie schreiben, läuft in einem einzigen Thread. Denken Sie einen Moment darüber nach. Während E/A mit effizienten, nicht blockierenden Techniken durchgeführt wird, läuft Ihr JS-Code, der CPU-gebundene Operationen ausführt, in einem einzigen Thread, wobei jeder Codeblock den nächsten blockiert. Ein häufiges Beispiel dafür ist die Schleifenbildung über Datenbankdatensätze, um sie auf irgendeine Weise zu verarbeiten, bevor sie an den Client ausgegeben werden. Hier ein Beispiel, das zeigt, wie das funktioniert:

var handler = function(request, response) {connection.query('SELECT ...', function (err, rows) {if (err) { throw err };for (var i = 0; i < rows.length; i++) {// do processing on each row}response.end(...); // write out the results})};

Während Node die E/A effizient verarbeitet, verbraucht die for-Schleife im obigen Beispiel CPU-Zyklen in Ihrem einzigen Haupt-Thread. Das bedeutet, dass diese Schleife bei 10.000 Verbindungen Ihre gesamte Anwendung zum Stillstand bringen kann, je nachdem, wie lange sie dauert. Jede Anfrage muss sich einen Teil der Zeit in Ihrem Haupt-Thread teilen.

Die Prämisse dieses Konzepts ist, dass die E/A-Operationen der langsamste Teil sind und es daher am wichtigsten ist, diese effizient abzuwickeln, selbst wenn dies bedeutet, dass andere Verarbeitungen seriell durchgeführt werden. Das stimmt in einigen Fällen, aber nicht in allen.

Der andere Punkt ist, dass es ziemlich ermüdend sein kann, einen Haufen verschachtelter Rückrufe zu schreiben, und manche argumentieren, dass es den Code wesentlich schwerer macht, ihm zu folgen, und das ist nur eine Meinung. Es ist nicht ungewöhnlich, dass Rückrufe vier, fünf oder noch mehr Ebenen tief im Node-Code verschachtelt sind.

Wir sind wieder bei den Kompromissen angelangt. Das Node-Modell funktioniert gut, wenn Ihr Hauptleistungsproblem die E/A ist. Seine Achillesferse ist jedoch, dass man in eine Funktion gehen kann, die eine HTTP-Anfrage bearbeitet, und CPU-intensiven Code einfügen und jede Verbindung zum Kriechen bringen kann, wenn man nicht aufpasst.

Natürlich nicht-blockierend: Go

Bevor ich auf den Abschnitt über Go eingehe, ist es angebracht, dass ich offenbare, dass ich ein Go-Fanboy bin. Ich habe es für viele Projekte verwendet und ich bin ein offener Befürworter seiner Produktivitätsvorteile, und ich sehe sie bei meiner Arbeit, wenn ich es verwende.

Nachdem das gesagt ist, lassen Sie uns einen Blick darauf werfen, wie es mit I/O umgeht. Ein Hauptmerkmal der Sprache Go ist, dass sie einen eigenen Scheduler enthält. Statt dass jeder Ausführungsthread einem einzelnen OS-Thread entspricht, arbeitet sie mit dem Konzept der „Goroutines“. Die Go-Laufzeitumgebung kann eine Goroutine einem OS-Thread zuordnen und ausführen lassen oder sie aussetzen und nicht mit einem OS-Thread verknüpfen, je nachdem, was die Goroutine gerade tut. Jede Anfrage, die vom HTTP-Server von Go eingeht, wird in einer separaten Goroutine behandelt.

Das Diagramm, wie der Scheduler funktioniert, sieht wie folgt aus:

Unter der Haube wird dies durch verschiedene Punkte in der Go-Laufzeit implementiert, die den I/O-Aufruf implementieren, indem sie die Anfrage zum Schreiben/Lesen/Verbinden/etc. stellen, die aktuelle Goroutine in den Ruhezustand versetzen, mit der Information, die Goroutine wieder aufzuwecken, wenn weitere Maßnahmen ergriffen werden können.

In der Tat tut die Go-Laufzeit etwas, das dem, was Node tut, nicht sehr unähnlich ist, außer dass der Rückrufmechanismus in die Implementierung des E/A-Aufrufs eingebaut ist und automatisch mit dem Scheduler interagiert. Go ordnet Ihre Goroutines automatisch so vielen Betriebssystem-Threads zu, wie es aufgrund der Logik in seinem Scheduler für angemessen hält. Das Ergebnis ist Code wie dieser:

func ServeHTTP(w http.ResponseWriter, r *http.Request) {// the underlying network call here is non-blockingrows, err := db.Query("SELECT ...")for _, row := range rows {// do something with the rows,// each request in its own goroutine}w.Write(...) // write the response, also non-blocking}

Wie Sie oben sehen können, ähnelt die grundlegende Codestruktur dessen, was wir tun, den einfacheren Ansätzen und erreicht dennoch nicht-blockierende E/A unter der Haube.

In den meisten Fällen ist dies am Ende „das Beste aus beiden Welten“. Nicht-blockierende E/A wird für alle wichtigen Dinge verwendet, aber der Code sieht aus, als wäre er blockierend und ist daher tendenziell einfacher zu verstehen und zu warten. Die Interaktion zwischen dem Go-Scheduler und dem OS-Scheduler erledigt den Rest. Es ist keine komplette Magie, und wenn man ein großes System baut, lohnt es sich, die Zeit zu investieren, um mehr Details darüber zu verstehen, wie es funktioniert; aber gleichzeitig funktioniert die Umgebung, die man „out-of-the-box“ bekommt, und skaliert recht gut.

Go mag seine Fehler haben, aber im Allgemeinen gehört die Art und Weise, wie es I/O handhabt, nicht dazu.

Lügen, verdammte Lügen und Benchmarks

Es ist schwierig, genaue Zeitangaben zum Kontextwechsel zu machen, der mit diesen verschiedenen Modellen verbunden ist. Ich könnte auch argumentieren, dass dies für Sie weniger nützlich ist. Stattdessen gebe ich Ihnen einige grundlegende Benchmarks, die die Gesamtleistung des HTTP-Servers in diesen Serverumgebungen vergleichen. Bedenken Sie, dass viele Faktoren in die Leistung des gesamten End-to-End-HTTP-Anfrage-/Antwortpfads einfließen, und die hier präsentierten Zahlen sind nur einige Beispiele, die ich zusammengestellt habe, um einen grundlegenden Vergleich zu ermöglichen.

Für jede dieser Umgebungen habe ich den entsprechenden Code geschrieben, um eine 64k-Datei mit zufälligen Bytes einzulesen, einen SHA-256-Hash darauf N-mal durchzuführen (wobei N im Query-String der URL angegeben ist, z. B. .../test.php?n=100) und den resultierenden Hash in Hexadezimalwerten auszugeben. Ich habe mich dafür entschieden, weil es eine sehr einfache Möglichkeit ist, dieselben Benchmarks mit einigen konsistenten E/A auszuführen und die CPU-Auslastung kontrolliert zu erhöhen.

Siehe diese Benchmark-Notizen für etwas mehr Details zu den verwendeten Umgebungen.

Zunächst wollen wir uns einige Beispiele mit geringer Parallelität ansehen. Wenn wir 2000 Iterationen mit 300 gleichzeitigen Anfragen und nur einem Hash pro Anfrage (N=1) durchführen, erhalten wir folgendes Ergebnis:

Die Zeiten sind die durchschnittliche Anzahl von Millisekunden bis zur Fertigstellung einer Anfrage über alle gleichzeitigen Anfragen. Niedriger ist besser.

Es ist schwer, aus diesem einen Diagramm eine Schlussfolgerung zu ziehen, aber mir scheint, dass bei diesem Verbindungs- und Berechnungsvolumen die Zeiten eher mit der allgemeinen Ausführung der Sprachen selbst zu tun haben als mit der E/A. Beachten Sie, dass die Sprachen, die als „Skriptsprachen“ gelten (lose Typisierung, dynamische Interpretation), am langsamsten sind.

Was passiert aber, wenn wir N auf 1000 erhöhen, immer noch mit 300 gleichzeitigen Anfragen – dieselbe Last, aber 100x mehr Hash-Iterationen (deutlich mehr CPU-Last):

Die Zeiten sind die durchschnittliche Anzahl von Millisekunden, um eine Anfrage über alle gleichzeitigen Anfragen hinweg abzuschließen. Je niedriger, desto besser.

Plötzlich sinkt die Leistung von Node erheblich, weil sich die CPU-intensiven Operationen in jeder Anfrage gegenseitig blockieren. Interessanterweise wird die Leistung von PHP viel besser (im Vergleich zu den anderen) und übertrifft Java in diesem Test. (Es ist erwähnenswert, dass die SHA-256-Implementierung in PHP in C geschrieben ist und der Ausführungspfad viel mehr Zeit in dieser Schleife verbringt, da wir jetzt 1000 Hash-Iterationen durchführen).

Lassen Sie uns nun 5000 gleichzeitige Verbindungen (mit N=1) ausprobieren – oder so nahe daran, wie ich es erreichen konnte. Leider war bei den meisten dieser Umgebungen die Fehlerquote nicht unerheblich. Für dieses Diagramm betrachten wir die Gesamtzahl der Anfragen pro Sekunde. Je höher, desto besser:

Gesamtzahl der Anfragen pro Sekunde. Je höher, desto besser.

Und das Bild sieht ganz anders aus. Es ist nur eine Vermutung, aber es sieht so aus, als ob bei hohem Verbindungsaufkommen der Overhead pro Verbindung, der mit dem Spawnen neuer Prozesse und dem damit verbundenen zusätzlichen Speicher in PHP+Apache verbunden ist, zu einem dominanten Faktor wird und die Leistung von PHP beeinträchtigt. Go ist hier eindeutig der Gewinner, gefolgt von Java, Node und schließlich PHP.

Während die Faktoren, die mit dem Gesamtdurchsatz zu tun haben, zahlreich sind und auch von Anwendung zu Anwendung stark variieren, werden Sie umso besser dastehen, je mehr Sie verstehen, was unter der Haube vor sich geht und welche Kompromisse Sie eingehen müssen.

Zusammenfassung

Aus den obigen Ausführungen wird deutlich, dass sich mit der Entwicklung der Sprachen auch die Lösungen für den Umgang mit großen Anwendungen, die viel E/A benötigen, weiterentwickelt haben.

Fairerweise muss man sagen, dass sowohl PHP als auch Java, trotz der Beschreibungen in diesem Artikel, Implementierungen von nicht-blockierenden E/A für den Einsatz in Webanwendungen zur Verfügung haben. Diese sind jedoch nicht so verbreitet wie die oben beschriebenen Ansätze, und der damit verbundene betriebliche Aufwand für die Wartung von Servern, die solche Ansätze verwenden, muss berücksichtigt werden. Ganz zu schweigen davon, dass Ihr Code so strukturiert sein muss, dass er mit solchen Umgebungen funktioniert; Ihre „normale“ PHP- oder Java-Webanwendung wird in der Regel nicht ohne erhebliche Änderungen in einer solchen Umgebung laufen.

Zum Vergleich: Wenn wir einige wichtige Faktoren berücksichtigen, die sowohl die Leistung als auch die Benutzerfreundlichkeit beeinflussen, erhalten wir Folgendes:

Sprache Threads vs. Prozesse Nichtblockierende I/O Benutzerfreundlichkeit
PHP Prozesse Nein
Java Threads Verfügbar Erfordert Rückrufe
Node.js Threads Ja Erfordert Rückrufe
Go Threads (Goroutines) Ja Keine Rückrufe erforderlich

Threads sind im Allgemeinen viel speichereffizienter als Prozesse, da sie sich denselben Speicherplatz teilen, während Prozesse dies nicht tun. Kombiniert man dies mit den Faktoren, die sich auf nicht-blockierende E/A beziehen, kann man sehen, dass sich zumindest bei den oben genannten Faktoren die allgemeine Einstellung in Bezug auf E/A verbessert, je weiter man in der Liste nach unten geht. Wenn ich also einen Gewinner in diesem Wettbewerb wählen müsste, wäre es sicherlich Go.

Auch wenn es in der Praxis so ist, hängt die Wahl einer Umgebung, in der Sie Ihre Anwendung entwickeln, eng mit der Vertrautheit Ihres Teams mit dieser Umgebung und der Gesamtproduktivität, die Sie damit erreichen können, zusammen. Daher ist es vielleicht nicht für jedes Team sinnvoll, einfach loszulegen und mit der Entwicklung von Webanwendungen und -diensten in Node oder Go zu beginnen. In der Tat wird die Suche nach Entwicklern oder die Vertrautheit des eigenen Teams oft als Hauptgrund dafür angeführt, keine andere Sprache und/oder Umgebung zu verwenden. Abgesehen davon haben sich die Zeiten in den letzten fünfzehn Jahren sehr geändert.

Ich hoffe, dass die obigen Ausführungen dazu beitragen, ein klareres Bild davon zu zeichnen, was unter der Haube passiert, und Ihnen einige Ideen geben, wie Sie mit der Skalierbarkeit Ihrer Anwendung in der realen Welt umgehen können. Viel Spaß beim Eingeben und Ausgeben!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.