In diesem Artikel werden zunächst kurz die Grundkonzepte im Zusammenhang mit E/A vorgestellt, dann die E/A-Leistung von Node, PHP, Java und Go horizontal verglichen und Auswahlvorschläge gegeben. Lassen Sie uns es unten vorstellen, Freunde in Not können sich darauf beziehen.
Wenn Sie das Eingabe-/Ausgabemodell (I/O) einer Anwendung verstehen, können Sie besser verstehen, wie sie die Last ideal und realistisch verarbeitet. Möglicherweise ist Ihre Anwendung klein und muss keine hohe Last unterstützen, sodass Sie weniger berücksichtigen müssen. Allerdings kann die Verwendung des falschen I/O-Modells mit zunehmender Anwendungsverkehrslast schwerwiegende Folgen haben.
In diesem Artikel vergleichen wir Node, Java, Go und PHP mit Apache, diskutieren, wie verschiedene Sprachen I/O modellieren, die Vor- und Nachteile jedes Modells und einige grundlegende Leistungsmessungen. Wenn Sie sich mehr Gedanken über die E/A-Leistung Ihrer nächsten Webanwendung machen, hilft Ihnen dieser Artikel weiter.
Um die Faktoren im Zusammenhang mit I/O zu verstehen, müssen wir diese Konzepte zunächst auf Betriebssystemebene verstehen. Obwohl es unwahrscheinlich ist, dass Sie gleich zu Beginn mit allzu vielen Konzepten konfrontiert werden, werden Sie ihnen während des Betriebs der Anwendung immer begegnen, sei es direkt oder indirekt. Details sind wichtig.
Machen wir uns zunächst mit dem Systemaufruf vertraut, der wie folgt im Detail beschrieben wird:
Die Anwendung fordert den Betriebssystemkern auf, I/O-Vorgänge dafür durchzuführen.
Ein „Systemaufruf“ ist, wenn ein Programm den Kernel auffordert, eine Aktion auszuführen. Die Implementierungsdetails variieren zwischen den Betriebssystemen, das Grundkonzept ist jedoch dasselbe. Wenn ein „Systemaufruf“ ausgeführt wird, werden einige spezifische Anweisungen zur Steuerung des Programms an den Kernel übertragen. Im Allgemeinen blockieren Systemaufrufe, was bedeutet, dass das Programm wartet, bis der Kernel das Ergebnis zurückgibt.
Der Kernel führt E/A-Vorgänge auf niedriger Ebene auf physischen Geräten (Festplatten, Netzwerkkarten usw.) aus und reagiert auf Systemaufrufe. In der realen Welt muss der Kernel möglicherweise viele Dinge tun, um Ihre Anfrage zu erfüllen, einschließlich Warten darauf, dass das Gerät bereit ist, seinen internen Status aktualisieren usw., aber als Anwendungsentwickler müssen Sie sich nicht darum kümmern Darüber hinaus ist es Sache des Kernels.
Ich habe oben gesagt, dass Systemaufrufe im Allgemeinen blockierend sind. Einige Aufrufe sind jedoch „nicht blockierend“, was bedeutet, dass der Kernel die Anforderung in eine Warteschlange oder einen Puffer stellt und sofort zurückkehrt, ohne auf die eigentliche E/A zu warten. Es „blockiert“ also nur für kurze Zeit, die Warteschlange benötigt jedoch eine gewisse Zeit.
Um diesen Punkt zu veranschaulichen, hier ein paar Beispiele (Linux-Systemaufrufe):
read() ist ein blockierender Aufruf. Wir müssen ihm ein Dateihandle und einen Puffer übergeben, um die Daten zu speichern, und zurückgeben, wenn die Daten im Puffer gespeichert werden. Es hat den Vorteil, elegant und dennoch schlicht zu sein.
epoll_create(), epoll_ctl() und epoll_wait() können verwendet werden, um eine Gruppe von Handles zum Abhören zu erstellen, Handles in dieser Gruppe hinzuzufügen/zu entfernen und das Programm zu blockieren, bis auf dem Handle eine Aktivität stattfindet. Mit diesen Systemaufrufen können Sie eine große Anzahl von E/A-Vorgängen mit nur einem einzigen Thread effizient steuern. Diese Funktionen sind zwar sehr nützlich, ihre Verwendung ist jedoch recht komplex.
Hier ist es sehr wichtig, die Größenordnung des Zeitunterschieds zu verstehen. Wenn ein nicht optimierter CPU-Kern mit 3 GHz läuft, kann er 3 Milliarden Zyklen pro Sekunde ausführen (das sind 3 Zyklen pro Nanosekunde). Ein nicht blockierender Systemaufruf kann mehr als 10 Zyklen oder einige Nanosekunden dauern. Das Blockieren von Anrufen zum Empfangen von Informationen aus dem Netzwerk kann länger dauern, beispielsweise 200 Millisekunden (1/5 Sekunde).
Nehmen wir an, der nicht blockierende Anruf dauerte 20 Nanosekunden und der blockierende Anruf dauerte 200.000.000 Nanosekunden. Auf diese Weise muss der Prozess möglicherweise 10 Millionen Zyklen warten, um den Anruf zu blockieren.
Der Kernel bietet zwei Methoden: blockierende E/A („Daten aus dem Netzwerk lesen“) und nicht blockierende E/A („sagen Sie mir, wenn neue Daten auf der Netzwerkverbindung vorliegen“), und beide Mechanismen blockieren den Aufruf Prozess Die Dauer ist völlig unterschiedlich.
Der dritte sehr wichtige Punkt ist, was passiert, wenn es viele Threads oder Prozesse gibt, die zu blockieren beginnen.
Für uns gibt es keinen großen Unterschied zwischen Threads und Prozessen. In Wirklichkeit besteht der bedeutendste Unterschied in Bezug auf die Leistung darin, dass ein einzelner Prozess tendenziell mehr Speicher belegt, da Threads denselben Speicher teilen und jeder Prozess über seinen eigenen Speicherplatz verfügt. Wenn wir jedoch über die Planung sprechen, geht es in Wirklichkeit um die Erledigung einer Reihe von Dingen, und jede Sache erfordert eine bestimmte Ausführungszeit auf den verfügbaren CPU-Kernen.
Wenn Sie 8 Kerne haben, um 300 Threads auszuführen, müssen Sie die Zeit so aufteilen, dass jeder Thread seine Zeitscheibe erhält und jeder Kern für kurze Zeit ausgeführt wird und dann zum nächsten Thread wechselt. Dies geschieht über einen „Kontextschalter“, der es der CPU ermöglicht, von einem Thread/Prozess zum nächsten zu wechseln.
Dieser Kontextwechsel ist mit gewissen Kosten verbunden, das heißt, er nimmt eine gewisse Zeit in Anspruch. Wenn es schnell ist, kann es weniger als 100 Nanosekunden dauern, aber wenn die Implementierungsdetails, die Prozessorgeschwindigkeit/-architektur, der CPU-Cache und andere Software und Hardware unterschiedlich sind, ist es normal, dass es 1000 Nanosekunden oder länger dauert.
Je größer die Anzahl der Threads (oder Prozesse) ist, desto größer ist die Anzahl der Kontextwechsel. Wenn es Tausende von Threads gibt und jeder Thread Hunderte von Nanosekunden braucht, um zu wechseln, wird das System sehr langsam.
Allerdings sagt ein nicht blockierender Aufruf dem Kernel im Wesentlichen: „Ruft mich nur an, wenn neue Daten oder Ereignisse auf diesen Verbindungen eintreffen.“ Diese nicht blockierenden Aufrufe verarbeiten große E/A-Lasten effizient und reduzieren Kontextwechsel.
Es ist erwähnenswert, dass die Beispiele in diesem Artikel zwar klein sind, Datenbankzugriffe, externe Caching-Systeme (Memcache und dergleichen) und alles, was E/A erfordert, jedoch irgendwann eine Art E/A-Aufruf ausführen werden, nämlich den gleich wie Das Prinzip des Beispiels ist das gleiche.
Es gibt viele Faktoren, die die Wahl der Programmiersprache in einem Projekt beeinflussen. Auch wenn man nur die Leistung berücksichtigt, gibt es viele Faktoren. Wenn Sie jedoch befürchten, dass Ihr Programm in erster Linie E/A-gebunden ist und die Leistung ein wichtiger Faktor für den Erfolg oder Misserfolg Ihres Projekts ist, sollten Sie die folgenden Vorschläge berücksichtigen.
In den 90er Jahren trugen viele Leute Converse-Schuhe und schrieben CGI-Skripte mit Perl. Dann kam PHP und viele Leute mochten es und es machte es einfacher, dynamische Webseiten zu erstellen.
Das von PHP verwendete Modell ist sehr einfach. Obwohl es unmöglich ist, genau dasselbe zu sein, lautet das allgemeine Prinzip des PHP-Servers wie folgt:
Der Browser des Benutzers gibt eine HTTP-Anfrage aus und die Anfrage gelangt in den Apache-Webserver. Apache erstellt für jede Anfrage einen separaten Prozess und verwendet diese Prozesse mithilfe einiger Optimierungsmethoden wieder, um die auszuführenden Vorgänge zu minimieren (das Erstellen von Prozessen ist relativ langsam).
Apache ruft PHP auf und weist es an, eine bestimmte .php-Datei auf der Festplatte auszuführen.
PHP-Code beginnt mit der Ausführung und blockiert E/A-Aufrufe. Das file_get_contents(), das Sie in PHP aufrufen, ruft tatsächlich den Systemaufruf read() auf und wartet auf das zurückgegebene Ergebnis.
<?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'); ?>
Es ist ganz einfach: ein Vorgang pro Anfrage. E/A-Aufrufe blockieren. Was ist mit den Vorteilen? Einfach und doch effektiv. Was ist mit den Nachteilen? Bei 20.000 gleichzeitigen Clients wird der Server lahmgelegt. Dieser Ansatz ist schwer zu skalieren, da die vom Kernel bereitgestellten Tools zur Verarbeitung großer I/O-Mengen (Epoll usw.) nicht vollständig genutzt werden. Schlimmer noch: Das Ausführen eines separaten Prozesses für jede Anfrage beansprucht tendenziell viele Systemressourcen, insbesondere Speicher, der oft als erster erschöpft ist.
*Hinweis: Zu diesem Zeitpunkt ist die Situation von Ruby der von PHP sehr ähnlich.
Also erschien Java. Und Java verfügt über ein in die Sprache integriertes Multithreading, was besonders beim Erstellen von Threads großartig ist.
Die meisten Java-Webserver starten für jede Anforderung einen neuen Ausführungsthread und rufen dann die vom Entwickler geschriebene Funktion in diesem Thread auf.
Das Durchführen von E/A in einem Java-Servlet sieht oft so aus:
publicvoiddoGet(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 die obige doGet-Methode einer Anforderung entspricht und in einem eigenen Thread ausgeführt wird, anstatt in einem separaten Prozess, der unabhängigen Speicher erfordert, werden wir eine erstellen separater Thread. Jede Anfrage erhält einen neuen Thread und verschiedene E/A-Vorgänge werden in diesem Thread blockiert, bis die Anfrage verarbeitet wird. Die Anwendung erstellt einen Thread-Pool, um die Kosten für das Erstellen und Zerstören von Threads zu minimieren. Tausende von Verbindungen bedeuten jedoch Tausende von Threads, was für den Scheduler nicht gut ist.
Es ist erwähnenswert, dass Java Version 1.4 (erneut aktualisiert in Version 1.7) die Möglichkeit hinzufügt, nicht blockierende E/A-Aufrufe durchzuführen. Obwohl die meisten Anwendungen diese Funktion nicht nutzen, ist sie zumindest verfügbar. Einige Java-Webserver experimentieren mit dieser Funktion, aber die überwiegende Mehrheit der bereitgestellten Java-Anwendungen funktioniert immer noch nach den oben beschriebenen Prinzipien.
Java bietet viele sofort einsatzbereite E/A-Funktionen. Wenn Sie jedoch auf die Situation stoßen, eine große Anzahl blockierender Threads zu erstellen, um eine große Anzahl von E/A-Vorgängen auszuführen, bietet Java keine gute Lösung .
Derjenige, der bei E/A eine bessere Leistung erbringt und bei Benutzern beliebter ist, ist Node.js. Jeder, der sich auch nur ansatzweise mit Node auskennt, weiß, dass es „nicht blockierend“ ist und I/O effizient verarbeitet. Dies gilt im Allgemeinen. Aber es kommt auf die Details und die Art und Weise der Umsetzung an.
Wenn Sie einige Vorgänge mit E/A ausführen müssen, müssen Sie eine Anfrage stellen und eine Rückruffunktion angeben. Der Knoten ruft diese Funktion nach der Verarbeitung der Anfrage auf.
Typischer Code zum Ausführen von E/A-Vorgängen in einer Anfrage sieht so aus:
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
Wie oben gezeigt, gibt es hier zwei Rückruffunktionen. Die erste Funktion wird aufgerufen, wenn die Anfrage startet, und die zweite Funktion wird aufgerufen, wenn die Dateidaten verfügbar sind.
这样,Node就能更有效地处理这些回调函数的I/O。有一个更能说明问题的例子:在Node中调用数据库操作。首先,你的程序开始调用数据库操作,并给Node一个回调函数,Node会使用非阻塞调用来单独执行I/O操作,然后在请求的数据可用时调用你的回调函数。这种对I/O调用进行排队并让Node处理I/O调用然后得到一个回调的机制称为“事件循环”。这个机制非常不错。
然而,这个模型有一个问题。在底层,这个问题出现的原因跟V8 JavaScript引擎(Node使用的是Chrome的JS引擎)的实现有关,即:你写的JS代码都运行在一个线程中。请思考一下。这意味着,尽管使用高效的非阻塞技术来执行I/O,但是JS代码在单个线程操作中运行基于CPU的操作,每个代码块都会阻塞下一个代码块的运行。有一个常见的例子:在数据库记录上循环,以某种方式处理记录,然后将它们输出到客户端。下面这段代码展示了这个例子的原理:
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 }) };
虽然Node处理I/O的效率很高,但是上面例子中的for循环在一个主线程中使用了CPU周期。这意味着如果你有10000个连接,那么这个循环就可能会占用整个应用程序的时间。每个请求都必须要在主线程中占用一小段时间。
这整个概念的前提是I/O操作是最慢的部分,因此,即使串行处理是不得已的,但对它们进行有效处理也是非常重要的。这在某些情况下是成立的,但并非一成不变。
另一点观点是,写一堆嵌套的回调很麻烦,有些人认为这样的代码很丑陋。在Node代码中嵌入四个、五个甚至更多层的回调并不罕见。
又到了权衡利弊的时候了。如果你的主要性能问题是I/O的话,那么这个Node模型能帮到你。但是,它的缺点在于,如果你在一个处理HTTP请求的函数中放入了CPU处理密集型代码的话,一不小心就会让每个连接都出现拥堵。
在介绍Go之前,我透露一下,我是一个Go的粉丝。我已经在许多项目中使用了Go。
让我们看看它是如何处理I/O的吧。 Go语言的一个关键特性是它包含了自己的调度器。它并不会为每个执行线程对应一个操作系统线程,而是使用了“goroutines”这个概念。Go运行时会为一个goroutine分配一个操作系统线程,并控制它执行或暂停。Go HTTP服务器的每个请求都在一个单独的Goroutine中进行处理。
实际上,除了回调机制被内置到I/O调用的实现中并自动与调度器交互之外,Go运行时正在做的事情与Node不同。它也不会受到必须让所有的处理代码在同一个线程中运行的限制,Go会根据其调度程序中的逻辑自动将你的Goroutine映射到它认为合适的操作系统线程中。因此,它的代码是这样的:
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 }
如上所示,这样的基本代码结构更为简单,而且还实现了非阻塞I/O。
在大多数情况下,这真正做到了“两全其美”。非阻塞I/O可用于所有重要的事情,但是代码却看起来像是阻塞的,因此这样往往更容易理解和维护。 剩下的就是Go调度程序和OS调度程序之间的交互处理了。这并不是魔法,如果你正在建立一个大型系统,那么还是值得花时间去了解它的工作原理的。同时,“开箱即用”的特点使它能够更好地工作和扩展。
Go可能也有不少缺点,但总的来说,它处理I/O的方式并没有明显的缺点。
对于这些不同模型的上下文切换,很难进行准确的计时。当然,我也可以说这对你并没有多大的用处。这里,我将对这些服务器环境下的HTTP服务进行基本的性能评测比较。请记住,端到端的HTTP请求/响应性能涉及到的因素有很多。
我针对每一个环境都写了一段代码来读取64k文件中的随机字节,然后对其运行N次SHA-256散列(在URL的查询字符串中指定N,例如.../test.php?n=100)并以十六进制打印结果。我之所以选择这个,是因为它可以很容易运行一些持续的I/O操作,并且可以通过受控的方式来增加CPU使用率。
在这种存在大量连接和计算的情况下,我们看到的结果更多的是与语言本身的执行有关。请注意,“脚本语言”的执行速度最慢。
Plötzlich sinkt die Leistung des Knotens erheblich, da CPU-intensive Vorgänge in jeder Anforderung sich gegenseitig blockieren. Interessanterweise wurde die Leistung von PHP in diesem Test besser (im Vergleich zu den anderen), sogar besser als die von Java. (Es ist erwähnenswert, dass in PHP die Implementierung von SHA-256 in C geschrieben ist, der Ausführungspfad in dieser Schleife jedoch mehr Zeit benötigt, da wir dieses Mal 1000 Hash-Iterationen durchführen.)
Ich vermute, dass bei einer höheren Anzahl von Verbindungen die Anwendung neuer Prozesse und Speicher in PHP + Apache der Hauptfaktor zu sein scheint, der die PHP-Leistung beeinflusst. Offensichtlich ist Go dieses Mal der Gewinner, gefolgt von Java, Node und schließlich PHP.
Obwohl beim Gesamtdurchsatz viele Faktoren eine Rolle spielen und diese von Anwendung zu Anwendung stark variieren, ist die Leistung Ihrer Anwendung umso besser, je besser Sie die zugrunde liegenden Prinzipien und die damit verbundenen Kompromisse verstehen.
Zusammenfassend lässt sich sagen, dass sich mit der Weiterentwicklung der Sprachen auch die Lösungen für den Umgang mit großen I/O-lastigen Anwendungen weiterentwickeln.
Fairerweise muss man sagen, dass sowohl PHP als auch Java über nicht blockierende I/O-Implementierungen für Webanwendungen verfügen. Allerdings sind diese Implementierungen nicht so weit verbreitet wie die oben beschriebenen Methoden und es ist ein Wartungsaufwand zu berücksichtigen. Ganz zu schweigen davon, dass der Code der Anwendung so strukturiert sein muss, dass er für diese Umgebung geeignet ist.
Vergleichen wir mehrere wichtige Faktoren, die sich auf Leistung und Benutzerfreundlichkeit auswirken:
Sprache | Threads vs. Prozesse | Nicht blockierende E/A | Einfach zu verwenden |
---|---|---|---|
PHP | Prozess | Nein | - |
Java | Threads | Gültig | Erfordert. Rückruf |
Node.js | Threads | Ja | Erfordert Rückruf |
Go | Threads (Goroutinen) | Ja | Keine Rückrufe erforderlich |
Da Threads denselben Speicherplatz nutzen und Prozesse dies nicht tun, sind Threads normalerweise viel speichereffizienter als Prozesse. In der obigen Liste sind die I/O-bezogenen Faktoren von oben nach unten besser als die letzten. Wenn ich also im obigen Vergleich einen Gewinner auswählen müsste, wäre es definitiv der Go.
Das heißt, in der Praxis hängt die Umgebung, in der Sie Ihre Anwendung erstellen, eng mit der Vertrautheit Ihres Teams mit der Umgebung und der Gesamtproduktivität zusammen, die Ihr Team erreichen kann. Daher ist die Verwendung von Node or Go zur Entwicklung von Webanwendungen und -diensten möglicherweise nicht die beste Wahl für Teams.
Hoffentlich hilft Ihnen das oben Gesagte dabei, besser zu verstehen, was unter der Haube vor sich geht, und gibt Ihnen einige Vorschläge zum Umgang mit der Anwendungsskalierbarkeit.
Empfohlenes Lernen: php-Video-Tutorial
Das obige ist der detaillierte Inhalt vonNode-, PHP-, Java- und Go-serverseitiger I/O-Leistungswettbewerb, wer wird Ihrer Meinung nach gewinnen?. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!