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

Veröffentlicht: 2022-03-11

Das Verständnis des Input/Output (I/O)-Modells Ihrer Anwendung kann den Unterschied ausmachen zwischen einer Anwendung, die mit der Last fertig wird, der sie ausgesetzt ist, und einer Anwendung, die angesichts realer Anwendungsfälle zusammenbricht. Vielleicht ist Ihre Anwendung zwar klein und bedient keine hohen Lasten, aber es spielt möglicherweise eine weit geringere Rolle. Aber wenn die Verkehrslast Ihrer Anwendung zunimmt, kann die Arbeit mit dem falschen E/A-Modell Sie in eine Welt voller Verletzungen bringen.

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 I/O-Landschaft und sehen, was wir ausspionieren können.

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

I/O-Grundlagen: Eine schnelle Auffrischung

Um die mit E/A verbundenen Faktoren zu verstehen, müssen wir zunächst die Konzepte auf Betriebssystemebene betrachten. Während es unwahrscheinlich ist, dass Sie sich mit vielen dieser Konzepte direkt befassen müssen, beschäftigen Sie sich die ganze Zeit indirekt mit ihnen über die Laufzeitumgebung Ihrer Anwendung. Und die Details sind wichtig.

Systemaufrufe

Erstens haben wir Systemaufrufe, die wie folgt beschrieben werden können:

  • Ihr Programm (im „Benutzerland“, wie sie sagen) muss den Kernel des Betriebssystems bitten, in seinem Namen eine I/O-Operation durchzuführen.
  • Ein „syscall“ ist das Mittel, mit dem Ihr Programm den Kernel auffordert, etwas zu tun. Die Einzelheiten, wie dies implementiert wird, variieren zwischen den Betriebssystemen, aber das Grundkonzept ist dasselbe. Es wird eine bestimmte Anweisung geben, die die Kontrolle von Ihrem Programm auf den Kernel überträgt (wie ein Funktionsaufruf, aber mit einer speziellen Soße speziell für den Umgang mit dieser Situation). Im Allgemeinen blockieren Systemaufrufe, was bedeutet, dass Ihr Programm darauf wartet, 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.) aus und antwortet auf den Systemaufruf. In der realen Welt muss der Kernel möglicherweise eine Reihe von Dingen tun, um Ihre Anforderung zu erfüllen, darunter das Warten auf die Bereitschaft des Geräts, das Aktualisieren seines internen Status usw., aber als Anwendungsentwickler ist Ihnen das egal. Das ist die Aufgabe des Kernels.

Syscalls-Diagramm

Blockierende vs. nicht blockierende Anrufe

Nun, ich habe oben gerade gesagt, dass Systemaufrufe blockieren, und das ist im Allgemeinen wahr. Einige Aufrufe werden jedoch als „nicht blockierend“ kategorisiert, was bedeutet, dass der Kernel Ihre Anfrage entgegennimmt, sie irgendwo in eine Warteschlange oder einen Puffer stellt und dann sofort zurückkehrt, ohne auf die tatsächliche E/A zu warten. Es „blockiert“ also nur für einen sehr kurzen Zeitraum, gerade lange genug, um Ihre Anfrage einzureihen.

Einige Beispiele (von Linux-Systemaufrufen) könnten zur Verdeutlichung beitragen: - read() ist ein blockierender Aufruf - Sie übergeben ihm ein Handle, das angibt, welche Datei und ein Puffer, wohin die gelesenen Daten geliefert werden sollen, und der Aufruf kehrt zurück, wenn die Daten dort sind. Beachten Sie, dass dies den Vorteil hat, dass es schön und einfach ist. - epoll_create() , epoll_ctl() und epoll_wait() sind Aufrufe, mit denen Sie jeweils eine Gruppe von Handles erstellen können, auf die Sie hören können, Handler zu dieser Gruppe hinzufügen / entfernen und dann blockieren, bis es eine Aktivität gibt. Auf diese Weise können Sie eine große Anzahl von E/A-Vorgängen effizient mit einem einzigen Thread steuern, aber ich bin mir selbst voraus. Dies ist großartig, wenn Sie die Funktionalität benötigen, aber wie Sie sehen können, ist die Verwendung sicherlich komplexer.

Es ist wichtig, die Größenordnung des Zeitunterschieds hier zu verstehen. Wenn ein CPU-Kern mit 3 GHz läuft, führt er ohne Optimierungen, die die CPU durchführen kann, 3 Milliarden Zyklen pro Sekunde (oder 3 Zyklen pro Nanosekunde) aus. Ein nicht blockierender Systemaufruf kann in der Größenordnung von 10 Sekunden von Zyklen dauern – oder „relativ wenige Nanosekunden“. Ein Anruf, der den Empfang von Informationen über das Netzwerk blockiert, kann viel länger dauern - sagen wir zum Beispiel 200 Millisekunden (1/5 Sekunde). Nehmen wir zum Beispiel an, der nicht blockierende Aufruf dauerte 20 Nanosekunden und der blockierende Aufruf 200.000.000 Nanosekunden. Ihr Prozess hat gerade 10 Millionen Mal länger auf den blockierenden Anruf gewartet.

Blockierende vs. nicht blockierende Syscalls

Der Kernel stellt die Mittel bereit, um sowohl I/O zu blockieren („Lese von dieser Netzwerkverbindung und gib mir die Daten“) als auch nicht-blockierende I/O („Sag mir, wenn eine dieser Netzwerkverbindungen neue Daten hat“). Und welcher Mechanismus verwendet wird, blockiert den Anrufprozess für dramatisch unterschiedliche Zeiträume.

Planung

Die dritte wichtige Sache ist, was passiert, wenn Sie viele Threads oder Prozesse haben, 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, da Threads denselben Speicher teilen und Prozesse jeweils ihren eigenen Speicherplatz haben, das Erstellen separater Prozesse dazu neigt, viel mehr Speicher zu beanspruchen. Aber wenn wir über Planung sprechen, läuft es wirklich darauf hinaus, eine Liste von Dingen (Threads und Prozesse gleichermaßen) zu erstellen, die jeweils einen Teil der Ausführungszeit auf den verfügbaren CPU-Kernen erhalten müssen. Wenn Sie 300 laufende Threads und 8 Kerne haben, auf denen sie ausgeführt werden, 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 übergeht. Dies geschieht durch einen „Kontextwechsel“, der die CPU dazu bringt, von der Ausführung eines Threads/Prozesses zum nächsten zu wechseln.

Diese Kontextwechsel sind mit Kosten verbunden – sie nehmen einige Zeit in Anspruch. 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, abhängig von den Implementierungsdetails, der Prozessorgeschwindigkeit/-architektur, dem CPU-Cache usw.

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

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

Bei mir bisher? Denn jetzt kommt der spaßige Teil: Schauen wir uns an, was einige populäre Sprachen mit diesen Tools machen, und ziehen wir einige Schlussfolgerungen über die Kompromisse zwischen Benutzerfreundlichkeit und Leistung … und andere interessante Leckerbissen.

Als Hinweis, während die in diesem Artikel gezeigten Beispiele trivial sind (und teilweise, wobei nur die relevanten Bits gezeigt werden); Datenbankzugriff, externe Caching-Systeme (Memcache usw.) und alles, was I/O erfordert, führt letztendlich zu einer Art I/O-Aufruf unter der Haube, der den gleichen Effekt wie die gezeigten einfachen Beispiele hat. Auch für die Szenarien, in denen die E/A als „blockierend“ beschrieben wird (PHP, Java), blockieren die Lese- und Schreibvorgänge von HTTP-Anforderungen und -Antworten selbst Aufrufe: Auch hier ist mehr E/A im System mit den damit verbundenen Leistungsproblemen verborgen berücksichtigen.

Es gibt viele Faktoren, die bei der Auswahl einer Programmiersprache für ein Projekt eine Rolle spielen. Es gibt sogar viele Faktoren, wenn Sie nur die Leistung betrachten. Wenn Sie jedoch befürchten, dass Ihr Programm hauptsächlich durch E/A eingeschränkt wird, wenn die E/A-Leistung für Ihr Projekt entscheidend ist, sollten Sie diese Dinge wissen.

Der „Keep It Simple“-Ansatz: PHP

In den 90er Jahren trugen viele Leute Converse-Schuhe und schrieben CGI-Skripte in Perl. Dann kam PHP, und so sehr manche Leute auch darüber schwadronieren, es machte das Erstellen dynamischer Webseiten viel einfacher.

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

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

Und natürlich wird der eigentliche Code einfach direkt in Ihre Seite eingebettet, und Operationen blockieren:

 <?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 in das System ist es so:

E/A-Modell PHP

Ganz einfach: ein Vorgang pro Anfrage. E/A-Aufrufe blockieren einfach. Vorteil? Es ist einfach und es funktioniert. Nachteil? Schlagen Sie es mit 20.000 Clients gleichzeitig zu und Ihr Server wird in Flammen aufgehen. Dieser Ansatz lässt sich nicht gut skalieren, da die vom Kernel bereitgestellten Tools zum Umgang mit hochvolumigen E/A (epoll usw.) nicht verwendet werden. Und um das Ganze noch schlimmer zu machen, neigt das Ausführen eines separaten Prozesses für jede Anfrage dazu, eine Menge Systemressourcen zu verbrauchen, insbesondere Speicher, der in einem Szenario wie diesem oft das erste ist, was Ihnen ausgeht.

Hinweis: Der für Ruby verwendete Ansatz ist dem von PHP sehr ähnlich, und im weitesten Sinne können sie für unsere Zwecke als gleich angesehen werden.

Der Multithread-Ansatz: Java

Java kommt also auf den Markt, ungefähr zu der Zeit, als Sie Ihren ersten Domainnamen gekauft haben, und es war cool, nach einem Satz einfach zufällig „dot com“ zu sagen. Und Java hat Multithreading in die Sprache eingebaut, was (insbesondere für die Zeit, als es erstellt wurde) ziemlich großartig ist.

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

I/O-Vorgänge in einem Java-Servlet sehen in der Regel so aus:

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

Da unsere doGet Methode einer Anfrage entspricht und in einem eigenen Thread ausgeführt wird, statt eines separaten Prozesses für jede Anfrage, der eigenen Speicher benötigt, haben wir einen separaten Thread. Dies hat einige nette Vorteile, wie die Möglichkeit, Status, zwischengespeicherte Daten usw. zwischen Threads auszutauschen, 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 in PHP getan wird Beispiel vorher. Jede Anforderung erhält einen neuen Thread und die verschiedenen E/A-Operationen blockieren innerhalb dieses Threads, bis die Anforderung vollständig verarbeitet ist. Threads werden zusammengefasst, um die Kosten für deren Erstellung und Zerstörung zu minimieren, aber Tausende von Verbindungen bedeuten dennoch Tausende von Threads, was schlecht für den Planer ist.

Ein wichtiger Meilenstein ist, dass Java in Version 1.4 (und erneut ein bedeutendes Upgrade in 1.7) die Möglichkeit erhielt, nicht blockierende E/A-Aufrufe durchzuführen. Die meisten Anwendungen, Web und andere, verwenden es nicht, aber zumindest ist es verfügbar. Einige Java-Webserver versuchen dies auf verschiedene Weise auszunutzen; Die überwiegende Mehrheit der bereitgestellten Java-Anwendungen funktioniert jedoch immer noch wie oben beschrieben.

E/A-Modell Java

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 Sie eine stark I/O-gebundene Anwendung haben, in die hineingehämmert wird den Boden mit vielen tausend blockierenden Fäden.

Nicht blockierende E/A als First Class Citizen: Node

Das beliebteste Kind auf dem Block, wenn es um bessere I/O geht, ist Node.js. Jedem, der auch nur die kürzeste Einführung in Node hatte, wurde gesagt, dass es „nicht blockierend“ ist und E/A effizient handhabt. Und das gilt im Allgemeinen. Aber der Teufel steckt im Detail und die Mittel, mit denen diese Hexerei erreicht wurde, sind wichtig, wenn es um die Leistung geht.

Im Wesentlichen besteht der Paradigmenwechsel, den Node implementiert, darin, dass sie nicht im Wesentlichen sagen: „Schreiben Sie hier Ihren Code, um die Anfrage zu bearbeiten“, sondern „Schreiben Sie hier Code, 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, die Node aufruft, wenn sie fertig ist.

Ein typischer Node-Code zum Ausführen einer E/A-Operation in einer Anfrage sieht folgendermaßen 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. Der erste wird aufgerufen, wenn eine Anforderung beginnt, und der zweite wird aufgerufen, wenn die Dateidaten verfügbar sind.

Dies gibt Node im Grunde genommen die Möglichkeit, die E/A zwischen diesen Rückrufen effizient zu verarbeiten. Ein Szenario, in dem es noch relevanter wäre, wäre, wenn Sie einen Datenbankaufruf in Node durchführen, aber ich werde mich nicht mit dem Beispiel beschäftigen, weil es genau das gleiche Prinzip ist: Sie starten den Datenbankaufruf und geben Node eine Callback-Funktion führt die E/A-Operationen separat mit nicht blockierenden Aufrufen durch und ruft dann Ihre Rückruffunktion auf, wenn die angeforderten Daten verfügbar sind. Dieser Mechanismus, E/A-Aufrufe in die Warteschlange zu stellen und Node damit umgehen zu lassen und dann einen Rückruf zu erhalten, wird als „Ereignisschleife“ bezeichnet. Und es funktioniert ziemlich gut.

E/A-Modell Node.js

Allerdings hat dieses Modell einen Haken. Unter der Haube hat der Grund dafür viel mehr damit zu tun, wie die V8-JavaScript-Engine (Chromes JS-Engine, die von Node verwendet wird) implementiert ist 1 als alles andere. Der von Ihnen geschriebene JS-Code wird in einem einzigen Thread ausgeführt. Denken Sie einen Moment darüber nach. Das bedeutet, dass, während E/A mit effizienten, nicht blockierenden Techniken ausgeführt wird, Ihr JS, das CPU-gebundene Operationen ausführt, in einem einzigen Thread ausgeführt wird, wobei jeder Codeblock den nächsten blockiert. Ein gängiges Beispiel dafür, wo dies auftreten kann, ist das Schleifen von Datenbankeinträgen, um sie auf irgendeine Weise zu verarbeiten, bevor sie an den Client ausgegeben werden. Hier ist 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 handhabt, verwendet die for -Schleife im obigen Beispiel CPU-Zyklen in Ihrem einzigen Haupt-Thread. Das bedeutet, dass bei 10.000 Verbindungen diese Schleife Ihre gesamte Anwendung je nach Dauer zum Crawlen bringen kann. Jede Anfrage muss einen Zeitabschnitt nach dem anderen in Ihrem Haupt-Thread teilen.

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

Der andere Punkt ist, und obwohl dies nur eine Meinung ist, kann es ziemlich mühsam sein, einen Haufen verschachtelter Rückrufe zu schreiben, und einige argumentieren, dass es den Code erheblich schwieriger macht, ihm zu folgen. Es ist nicht ungewöhnlich, dass Callbacks vier, fünf oder sogar mehr Ebenen tief im Node-Code verschachtelt sind.

Wir sind wieder bei den Kompromissen. Das Node-Modell funktioniert gut, wenn Ihr Hauptleistungsproblem E/A ist. Die Achillesferse besteht jedoch darin, dass Sie in eine Funktion gehen können, die eine HTTP-Anforderung verarbeitet, und CPU-intensiven Code einfügen und jede Verbindung zum Kriechen bringen, wenn Sie nicht aufpassen.

Natürlich nicht blockierend: Go

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

Sehen wir uns an, wie es mit I/O umgeht. Ein Hauptmerkmal der Go-Sprache ist, dass sie einen eigenen Planer enthält. Anstatt dass jeder Ausführungs-Thread einem einzelnen Betriebssystem-Thread entspricht, arbeitet es mit dem Konzept von „Goroutinen“. Und die Go-Laufzeitumgebung kann eine Goroutine einem OS-Thread zuweisen und sie ausführen lassen oder sie anhalten und nicht mit einem OS-Thread verknüpfen, je nachdem, was diese Goroutine tut. Jede Anfrage, die vom HTTP-Server von Go eingeht, wird in einer separaten Goroutine behandelt.

Das Diagramm, wie der Scheduler funktioniert, sieht folgendermaßen aus:

E/A-Modell Go

Unter der Haube wird dies durch verschiedene Punkte in der Go-Laufzeitumgebung implementiert, die den E/A-Aufruf implementieren, indem sie die Anforderung zum Schreiben/Lesen/Verbinden usw. stellen, die aktuelle Goroutine in den Ruhezustand versetzen, mit der Information, die Goroutine wieder aufzuwecken wann weitere Maßnahmen ergriffen werden können.

Tatsächlich macht die Go-Laufzeit etwas, das dem von Node nicht sehr unähnlich ist, außer dass der Callback-Mechanismus in die Implementierung des I/O-Aufrufs eingebaut ist und automatisch mit dem Scheduler interagiert. Es leidet auch nicht unter der Einschränkung, dass Ihr gesamter Handler-Code im selben Thread ausgeführt werden muss. Go ordnet Ihre Goroutinen automatisch so vielen Betriebssystem-Threads zu, wie es basierend auf 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-blocking rows, 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 unserer Arbeit der der einfacheren Ansätze und erreicht dennoch eine nicht blockierende E/A unter der Haube.

In den meisten Fällen ist dies „das Beste aus beiden Welten“. Nicht blockierende E/A wird für alle wichtigen Dinge verwendet, aber Ihr Code sieht aus, als würde er blockieren, 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 vollständige Magie, und wenn Sie ein großes System bauen, lohnt es sich, die Zeit zu investieren, um mehr Details darüber zu verstehen, wie es funktioniert. Gleichzeitig funktioniert und skaliert die Umgebung, die Sie „out-of-the-box“ erhalten, recht gut.

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

Lügen, verdammte Lügen und Benchmarks

Es ist schwierig, genaue Zeiten für die Kontextumschaltung anzugeben, die mit diesen verschiedenen Modellen verbunden ist. Ich könnte auch argumentieren, dass es für Sie weniger nützlich ist. Stattdessen gebe ich Ihnen einige grundlegende Benchmarks, die die Gesamt-HTTP-Serverleistung dieser Serverumgebungen vergleichen. Denken Sie daran, dass viele Faktoren an der Leistung des gesamten HTTP-Anforderungs-/Antwortpfads von Ende zu Ende beteiligt sind, und die hier aufgeführten 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 N-mal darauf laufen zu lassen (wobei N in der Abfragezeichenfolge der URL angegeben ist, z. B. .../test.php?n=100 ) und geben Sie den resultierenden Hash in Hex aus. Ich habe mich dafür entschieden, weil es eine sehr einfache Möglichkeit ist, dieselben Benchmarks mit einigen konsistenten I/Os und einer kontrollierten Methode zur Erhöhung der CPU-Auslastung auszuführen.

Sehen Sie sich diese Benchmark-Hinweise für etwas mehr Details zu den verwendeten Umgebungen an.

Sehen wir uns zunächst einige Beispiele für geringe Parallelität an. Das Ausführen von 2000 Iterationen mit 300 gleichzeitigen Anfragen und nur einem Hash pro Anfrage (N = 1) ergibt Folgendes:

Mittlere Anzahl von Millisekunden zum Abschließen einer Anforderung über alle gleichzeitigen Anforderungen hinweg, N=1

Die Zeiten sind die durchschnittliche Anzahl von Millisekunden zum Abschließen einer Anforderung über alle gleichzeitigen Anforderungen hinweg. Weniger ist besser.

Es ist schwer, aus nur dieser einen Grafik eine Schlussfolgerung zu ziehen, aber das scheint mir, dass wir bei diesem Umfang an Verbindungen und Berechnungen mehr mit der allgemeinen Ausführung der Sprachen selbst zu tun haben, viel mehr mit dem E/A. Beachten Sie, dass die Sprachen, die als „Skriptsprachen“ gelten (lockeres Tippen, dynamische Interpretation), am langsamsten arbeiten.

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

Mittlere Anzahl von Millisekunden zum Abschließen einer Anfrage über alle gleichzeitigen Anfragen hinweg, N=1000

Die Zeiten sind die durchschnittliche Anzahl von Millisekunden zum Abschließen einer Anforderung über alle gleichzeitigen Anforderungen hinweg. Weniger ist besser.

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

Versuchen wir es jetzt mit 5000 gleichzeitigen Verbindungen (mit N = 1) - oder so nah wie möglich daran. Leider war die Ausfallrate für die meisten dieser Umgebungen nicht unerheblich. Für dieses Diagramm sehen wir uns die Gesamtzahl der Anfragen pro Sekunde an. Je höher desto besser :

Gesamtzahl der Anfragen pro Sekunde, N=1, 5000 Anfragen/Sek

Gesamtzahl der Anfragen pro Sekunde. Höher ist besser.

Und das Bild sieht ganz anders aus. Es ist eine Vermutung, aber es sieht so aus, als ob bei hohem Verbindungsvolumen der Overhead pro Verbindung, der mit dem Spawnen neuer Prozesse und dem damit verbundenen zusätzlichen Speicher in PHP+Apache verbunden ist, ein dominierender Faktor zu werden scheint 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 Ihren Gesamtdurchsatz beeinflussen, vielfältig sind und auch von Anwendung zu Anwendung stark variieren, werden Sie umso besser dran sein, je mehr Sie über die Vorgänge unter der Haube und die damit verbundenen Kompromisse Bescheid wissen.

Zusammenfassend

Mit all dem oben Gesagten ist es ziemlich klar, dass sich mit der Weiterentwicklung der Sprachen auch die Lösungen für den Umgang mit umfangreichen Anwendungen, die viele I/Os ausführen, mitentwickelt haben.

Um fair zu sein, verfügen sowohl PHP als auch Java trotz der Beschreibungen in diesem Artikel über Implementierungen von nicht blockierender E/A, die für die Verwendung in Webanwendungen verfügbar sind. Diese sind jedoch nicht so verbreitet wie die oben beschriebenen Ansätze, und der damit verbundene Betriebsaufwand für die Wartung von Servern, die solche Ansätze verwenden, müsste 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 einer solchen Umgebung normalerweise nicht ohne wesentliche Änderungen ausgeführt.

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

Sprache Threads vs. Prozesse Nicht blockierende E/A Benutzerfreundlichkeit
PHP Prozesse Nein
Java Fäden Erhältlich Erfordert Rückrufe
Node.js Fäden Jawohl Erfordert Rückrufe
gehen Threads (Goroutinen) Jawohl Keine Rückrufe erforderlich


Threads sind im Allgemeinen viel speichereffizienter als Prozesse, da sie sich denselben Speicherplatz teilen, während Prozesse dies nicht tun. Wenn wir dies mit den Faktoren in Bezug auf nicht blockierende E/A kombinieren, können wir sehen, dass sich zumindest mit den oben betrachteten Faktoren das allgemeine Setup in Bezug auf E/A verbessert, wenn wir uns in der Liste nach unten bewegen. Wenn ich also einen Gewinner im obigen Wettbewerb auswählen müsste, wäre es sicherlich Go.

Trotzdem hängt die Wahl einer Umgebung, in der Sie Ihre Anwendung erstellen, in der Praxis eng mit der Vertrautheit Ihres Teams mit dieser Umgebung und der Gesamtproduktivität zusammen, die Sie damit erzielen können. Daher ist es möglicherweise nicht für jedes Team sinnvoll, einfach einzutauchen und mit der Entwicklung von Webanwendungen und -diensten in Node or Go zu beginnen. Tatsächlich wird die Suche nach Entwicklern oder die Vertrautheit Ihres internen Teams oft als Hauptgrund dafür genannt, keine andere Sprache und/oder Umgebung zu verwenden. Allerdings haben sich die Zeiten in den letzten fünfzehn Jahren oder so sehr verändert.

Hoffentlich hilft das Obige, ein klareres Bild davon zu zeichnen, was unter der Haube passiert, und gibt Ihnen einige Ideen, wie Sie mit der realen Skalierbarkeit für Ihre Anwendung umgehen können. Viel Spaß beim Ein- und Ausgeben!