Présentation | Comprendre le modèle d'entrées/sorties (E/S) d'une application fait la différence entre sa charge de traitement prévue et les scénarios d'utilisation brutaux du monde réel. Si l’application est relativement petite et ne supporte pas une charge élevée, elle peut avoir peu d’impact. Mais à mesure que la charge sur votre application augmente progressivement, l’adoption d’un mauvais modèle d’E/S peut vous laisser de nombreux pièges et cicatrices. |
Comme dans la plupart des scénarios où il existe plusieurs solutions, la question n’est pas de savoir laquelle est la meilleure, mais plutôt de comprendre comment faire les compromis. Faisons le tour du paysage des E/S et voyons ce que nous pouvons en voler.
Dans cet article, nous comparerons respectivement Node, Java, Go et PHP avec Apache, discuterons de la manière dont ces différents langages modélisent leurs E/S, les avantages et les inconvénients de chaque modèle, et tirerons quelques conclusions préliminaires. Si vous êtes préoccupé par les performances d'E/S de votre prochaine application Web, alors vous avez trouvé le bon article.
Bases des E/S : un aperçu rapideAfin de comprendre les facteurs étroitement liés aux E/S, nous devons d'abord revoir les concepts sous-jacents du système d'exploitation. Bien que vous n'abordiez pas directement la plupart de ces concepts, vous les avez traités indirectement via l'environnement d'exécution de l'application. Et le diable se cache dans les détails.
Appel systèmeTout d'abord, nous avons l'appel système, qui peut être décrit ainsi :
D'accord, je viens de dire plus haut que les appels système bloquent. De manière générale, c'est vrai. Cependant, certains appels sont classés comme « non bloquants », ce qui signifie que le noyau reçoit votre requête, la place dans une file d'attente ou un tampon quelque part, puis revient immédiatement sans attendre l'appel d'E/S réel. Donc, il « bloque » pendant une très courte période de temps, juste assez pour mettre votre demande en file d'attente.
Voici quelques exemples (appels système Linux) pour aider à expliquer : -read() est un appel bloquant - vous lui transmettez un descripteur de fichier et un tampon pour stocker les données lues, puis l'appel reviendra lorsque les données sont prêt. Notez que cette approche présente l’avantage de l’élégance et de la simplicité. -epoll_create() , epoll_ctl() et epoll_wait() respectivement, sont des appels qui vous permettent de créer un ensemble de handles à écouter, d'ajouter/supprimer des handles de cet ensemble, puis d'attendre qu'il y ait une activité. Juste bloqué. Cela vous permet de contrôler efficacement une série d’opérations d’E/S via un thread. C'est très bien si vous avez besoin de ces fonctionnalités, mais comme vous pouvez le constater, c'est certainement assez complexe à utiliser.
Il est important de comprendre ici l’ordre de grandeur de la différence temporelle. Si un cœur de processeur fonctionne à 3 GHz, sans optimisation, il exécute 3 milliards de boucles par seconde (ou 3 boucles par nanoseconde). Un appel système non bloquant peut prendre une période de l'ordre de 10 nanosecondes - ou "relativement quelques nanosecondes". Le blocage des appels qui reçoivent des informations sur le réseau peut prendre plus de temps, par exemple 200 millisecondes (0,2 seconde). Par exemple, en supposant que l’appel non bloquant a duré 20 nanosecondes, l’appel bloquant a duré 200 000 000 nanosecondes. Pour bloquer les appels, votre programme attend 10 millions de fois plus longtemps.
Le noyau propose deux méthodes : les E/S bloquantes ("Lire à partir de la connexion réseau et donnez-moi les données") et les E/S non bloquantes ("Dites-moi quand ces connexions réseau ont de nouvelles données"). Selon le mécanisme utilisé, le temps de blocage du processus appelant correspondant est évidemment différent.
PlanificationLa troisième chose critique est de savoir quoi faire lorsqu'un grand nombre de threads ou de processus commencent à se bloquer.
Pour nos besoins, il n'y a pas beaucoup de différence entre les threads et les processus. En fait, la différence la plus évidente en matière d'exécution est que les threads partagent la même mémoire, tandis que chaque processus possède son propre espace mémoire, ce qui fait que des processus distincts occupent souvent de grandes quantités de mémoire. Mais lorsque nous parlons de planification, cela se résume finalement à une liste d'événements (threads et processus) où chaque événement doit obtenir une tranche de temps d'exécution sur un cœur de processeur disponible. Si vous avez 300 threads en cours d'exécution et que vous utilisez 8 cœurs, vous devez répartir ce temps afin que chaque thread obtienne quelque chose en exécutant chaque cœur pendant une courte période, puis en passant au thread suivant. Ceci est réalisé par le « changement de contexte », qui permet au processeur de passer d'un thread/processus en cours d'exécution au suivant.
Ces changements de contexte ont un coût : ils prennent du temps. Lorsqu'il est rapide, cela prend probablement moins de 100 nanosecondes, mais il n'est pas rare de prendre 1 000 nanosecondes ou plus en fonction des détails d'implémentation, de la vitesse/architecture du processeur, du cache du processeur, etc.
Plus il y a de threads (ou de processus), plus il y a de changements de contexte. Lorsque nous parlons de milliers de threads et que chaque commutateur prend des centaines de nanosecondes, cela va être très lent.
Cependant, un appel non bloquant indique essentiellement au noyau "appelez-moi uniquement lorsque vous avez de nouvelles données ou qu'il y a un événement sur l'une de ces connexions". Ces appels non bloquants sont conçus pour gérer efficacement des charges d’E/S importantes et réduire les changements de contexte.
Lisez-vous encore cet article jusqu’à présent ? Car vient maintenant la partie amusante : regardons comment certains langages courants utilisent ces outils et tirons quelques conclusions sur les compromis entre facilité d'utilisation et performances... et d'autres commentaires intéressants.
Veuillez noter que même si les exemples présentés dans cet article sont triviaux (et incomplets, ne montrant que les parties pertinentes du code), l'accès aux bases de données, les systèmes de mise en cache externes (memcache, etc. tous) et ceux nécessitant des E/S. Tout finit par effectuer certaines opérations d'E/S sous-jacentes, qui ont le même impact que les exemples présentés. De même, pour les situations où les E/S sont qualifiées de « bloquantes » (PHP, Java), la lecture et l'écriture des requêtes et réponses HTTP sont elles-mêmes des appels bloquants : encore une fois, davantage d'E/S sont cachées dans l'O du système et ses accompagnements. les problèmes de performances doivent être pris en compte.
De nombreux facteurs doivent être pris en compte lors du choix d'un langage de programmation pour votre projet. Lorsque l’on considère uniquement les performances, il y a encore plus de facteurs à prendre en compte. Cependant, si vous craignez que votre programme soit principalement lié aux E/S et si les performances des E/S sont essentielles à votre projet, voici ce que vous devez savoir. L'approche « faire simple » : PHP.
Dans les années 90, beaucoup de gens portaient des chaussures Converse et écrivaient des scripts CGI en Perl. Puis PHP est apparu et de nombreuses personnes ont aimé l'utiliser, ce qui a facilité la création de pages Web dynamiques.
Le modèle utilisé par PHP est assez simple. Il existe quelques variantes, mais en gros, un serveur PHP ressemble à :
La requête HTTP provient du navigateur de l'utilisateur et accède à votre serveur Web Apache. Apache crée un processus distinct pour chaque requête, les réutilisant avec quelques optimisations pour minimiser le nombre de fois qu'il doit s'exécuter (la création de processus est relativement lente). Apache appelle PHP et lui dit d'exécuter le fichier .php correspondant sur le disque. Le code PHP s'exécute et effectue des appels d'E/S bloquants. Si file_get_contents() est appelé en PHP, il déclenchera l'appel système read() en coulisses et attendra que le résultat soit renvoyé.
Bien sûr, le code lui-même est simplement intégré dans votre page, et l'opération bloque :
<?php // 阻塞的文件I/O $file_data = file_get_contents('/path/to/file.dat'); // 阻塞的网络I/O $curl = curl_init('http://example.com/example-microservice'); $result = curl_exec($curl); // 更多阻塞的网络I/O $result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100'); ?>
À propos de la façon dont il s'intègre au système, comme ceci :
相当简单:一个请求,一个进程。I/O是阻塞的。优点是什么呢?简单,可行。那缺点是什么呢?同时与20,000个客户端连接,你的服务器就挂了。由于内核提供的用于处理大容量I/O(epoll等)的工具没有被使用,所以这种方法不能很好地扩展。更糟糕的是,为每个请求运行一个单独的过程往往会使用大量的系统资源,尤其是内存,这通常是在这样的场景中遇到的第一件事情。
注意:Ruby使用的方法与PHP非常相似,在广泛而普遍的方式下,我们可以将其视为是相同的。
多线程的方式:Java所以就在你买了你的第一个域名的时候,Java来了,并且在一个句子之后随便说一句“dot com”是很酷的。而Java具有语言内置的多线程(特别是在创建时),这一点非常棒。
大多数Java网站服务器通过为每个进来的请求启动一个新的执行线程,然后在该线程中最终调用作为应用程序开发人员的你所编写的函数。
在Java的Servlet中执行I/O操作,往往看起来像是这样:
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 阻塞的文件I/O InputStream fileIs = new FileInputStream("/path/to/file"); // 阻塞的网络I/O URLConnection urlConnection = (new URL("https://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // 更多阻塞的网络I/O out.println("..."); }
由于我们上面的doGet 方法对应于一个请求并且在自己的线程中运行,而不是每次请求都对应需要有自己专属内存的单独进程,所以我们会有一个单独的线程。这样会有一些不错的优点,例如可以在线程之间共享状态、共享缓存的数据等,因为它们可以相互访问各自的内存,但是它如何与调度进行交互的影响,仍然与前面PHP例子中所做的内容几乎一模一样。每个请求都会产生一个新的线程,而在这个线程中的各种I/O操作会一直阻塞,直到这个请求被完全处理为止。为了最小化创建和销毁它们的成本,线程会被汇集在一起,但是依然,有成千上万个连接就意味着成千上万个线程,这对于调度器是不利的。
一个重要的里程碑是,在Java 1.4 版本(和再次显著升级的1.7 版本)中,获得了执行非阻塞I/O调用的能力。大多数应用程序,网站和其他程序,并没有使用它,但至少它是可获得的。一些Java网站服务器尝试以各种方式利用这一点; 然而,绝大多数已经部署的Java应用程序仍然如上所述那样工作。
Java让我们更进了一步,当然对于I/O也有一些很好的“开箱即用”的功能,但它仍然没有真正解决问题:当你有一个严重I/O绑定的应用程序正在被数千个阻塞线程狂拽着快要坠落至地面时怎么办。
作为一等公民的非阻塞I/O:Node当谈到更好的I/O时,Node.js无疑是新宠。任何曾经对Node有过最简单了解的人都被告知它是“非阻塞”的,并且它能有效地处理I/O。在一般意义上,这是正确的。但魔鬼藏在细节中,当谈及性能时这个巫术的实现方式至关重要。
本质上,Node实现的范式不是基本上说“在这里编写代码来处理请求”,而是转变成“在这里写代码开始处理请求”。每次你都需要做一些涉及I/O的事情,发出请求或者提供一个当完成时Node会调用的回调函数。
在求中进行I/O操作的典型Node代码,如下所示:
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
可以看到,这里有两个回调函数。第一个会在请求开始时被调用,而第二个会在文件数据可用时被调用。
这样做的基本上给了Node一个在这些回调函数之间有效地处理I/O的机会。一个更加相关的场景是在Node中进行数据库调用,但我不想再列出这个烦人的例子,因为它是完全一样的原则:启动数据库调用,并提供一个回调函数给Node,它使用非阻塞调用单独执行I/O操作,然后在你所要求的数据可用时调用回调函数。这种I/O调用队列,让Node来处理,然后获取回调函数的机制称为“事件循环”。它工作得非常好。
然而,这个模型中有一道关卡。在幕后,究其原因,更多是如何实现JavaScript V8 引擎(Chrome的JS引擎,用于Node)1,而不是其他任何事情。你所编写的JS代码全部都运行在一个线程中。思考一下。这意味着当使用有效的非阻塞技术执行I/O时,正在进行CPU绑定操作的JS可以在运行在单线程中,每个代码块阻塞下一个。 一个常见的例子是循环数据库记录,在输出到客户端前以某种方式处理它们。以下是一个例子,演示了它如何工作:
var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i < rows.length; i++) { // 对每一行纪录进行处理 } response.end(...); // 输出结果 }) };
虽然Node确实可以有效地处理I/O,但上面的例子中的for 循环使用的是在你主线程中的CPU周期。这意味着,如果你有10,000个连接,该循环有可能会让你整个应用程序慢如蜗牛,具体取决于每次循环需要多长时间。每个请求必须分享在主线程中的一段时间,一次一个。
这个整体概念的前提是I/O操作是最慢的部分,因此最重要是有效地处理这些操作,即使意味着串行进行其他处理。这在某些情况下是正确的,但不是全都正确。
另一点是,虽然这只是一个意见,但是写一堆嵌套的回调可能会令人相当讨厌,有些人认为它使得代码明显无章可循。在Node代码的深处,看到嵌套四层、嵌套五层、甚至更多层级的嵌套并不罕见。
我们再次回到了权衡。如果你主要的性能问题在于I/O,那么Node模型能很好地工作。然而,它的阿喀琉斯之踵(
真正的非阻塞:Go在进入Go这一章节之前,我应该披露我是一名Go粉丝。我已经在许多项目中使用Go,是其生产力优势的公开支持者,并且在使用时我在工作中看到了他们。
也就是说,我们来看看它是如何处理I/O的。Go语言的一个关键特性是它包含自己的调度器。并不是每个线程的执行对应于一个单一的OS线程,Go采用的是“goroutines”这一概念。Go运行时可以将一个goroutine分配给一个OS线程并使其执行,或者把它挂起而不与OS线程关联,这取决于goroutine做的是什么。来自Go的HTTP服务器的每个请求都在单独的Goroutine中处理。
此调度器工作的示意图,如下所示:
这是通过在Go运行时的各个点来实现的,通过将请求写入/读取/连接/等实现I/O调用,让当前的goroutine进入睡眠状态,当可采取进一步行动时用信息把goroutine重新唤醒。
实际上,除了回调机制内置到I/O调用的实现中并自动与调度器交互外,Go运行时做的事情与Node做的事情并没有太多不同。它也不受必须把所有的处理程序代码都运行在同一个线程中这一限制,Go将会根据其调度器的逻辑自动将Goroutine映射到其认为合适的OS线程上。最后代码类似这样:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // 这里底层的网络调用是非阻塞的 rows, err := db.Query("SELECT ...") for _, row := range rows { // 处理rows // 每个请求在它自己的goroutine中 } w.Write(...) // 输出响应结果,也是非阻塞的 }
正如你在上面见到的,我们的基本代码结构像是更简单的方式,并且在背后实现了非阻塞I/O。
在大多数情况下,这最终是“两个世界中最好的”。非阻塞I/O用于全部重要的事情,但是你的代码看起来像是阻塞,因此往往更容易理解和维护。Go调度器和OS调度器之间的交互处理了剩下的部分。这不是完整的魔法,如果你建立的是一个大型的系统,那么花更多的时间去理解它工作原理的更多细节是值得的; 但与此同时,“开箱即用”的环境可以很好地工作和很好地进行扩展。
Go可能有它的缺点,但一般来说,它处理I/O的方式不在其中。
谎言,诅咒的谎言和基准对这些各种模式的上下文切换进行准确的定时是很困难的。也可以说这对你来没有太大作用。所以取而代之,我会给出一些比较这些服务器环境的HTTP服务器性能的基准。请记住,整个端对端的HTTP请求/响应路径的性能与很多因素有关,而这里我放在一起所提供的数据只是一些样本,以便可以进行基本的比较。
对于这些环境中的每一个,我编写了适当的代码以随机字节读取一个64k大小的文件,运行一个SHA-256哈希N次(N在URL的查询字符串中指定,例如.../test.php?n=100 ),并以十六进制形式打印生成的散列。我选择了这个示例,是因为使用一些一致的I/O和一个受控的方式增加CPU使用率来运行相同的基准测试是一个非常简单的方式。
Concernant les usages environnementaux, veuillez vous référer à ces points de référence pour plus de détails.
Tout d’abord, examinons quelques exemples de faible simultanéité. En exécutant 2 000 itérations, 300 requêtes simultanées et en hachant une seule fois par requête (N = 1), nous obtenons :
Le temps est le nombre moyen de millisecondes nécessaires pour terminer une requête parmi toutes les requêtes simultanées. Plus c'est bas, mieux c'est.
Il est difficile de tirer des conclusions à partir d'un seul graphique, mais cela me semble lié à des choses comme la connectivité et le calcul, on voit que le temps a plus à voir avec l'exécution générale du langage lui-même, donc plus avec les E/S . Notez que les langages considérés comme des « langages de script » (saisie arbitraire, interprétée dynamiquement) sont les plus lents.
Mais que se passe-t-il si vous augmentez N à 1000 et que vous avez toujours 300 requêtes simultanées - la même charge, mais 100 fois les itérations de hachage (augmentation significative de la charge CPU) :
Le temps est le nombre moyen de millisecondes nécessaires pour terminer une requête parmi toutes les requêtes simultanées. Plus c'est bas, mieux c'est.
Soudain, les performances de Node ont chuté de manière significative, car les opérations gourmandes en CPU dans chaque requête se bloquaient les unes les autres. Fait intéressant, dans ce test, PHP a obtenu de bien meilleurs résultats (par rapport aux autres langages) et a battu Java. (Il convient de noter qu'en PHP, où l'implémentation SHA-256 est écrite en C, le chemin d'exécution prend plus de temps dans cette boucle car cette fois nous effectuons 1000 itérations de hachage).
Essayons maintenant 5 000 connexions simultanées (et N = 1) - ou presque. Malheureusement, pour la plupart de ces environnements, le taux d’échec n’est pas significatif. Pour ce graphique, nous nous concentrerons sur le nombre total de requêtes par seconde. Plus c'est haut, mieux c'est :
Nombre total de requêtes par seconde. Plus c’est haut, mieux c’est.
Cette photo est complètement différente. C'est une supposition, mais il semble que pour les volumes de connexion élevés, la surcharge associée à la génération d'un nouveau processus par connexion et la mémoire supplémentaire associée à PHP + Apache semblent être le facteur majeur limitant les performances de PHP. Clairement, Go est ici le gagnant, suivi de Java et Node, et enfin de PHP.
ConclusionEn résumé, il est clair qu'à mesure que les langages évoluent, les solutions pour les grandes applications qui gèrent de grandes quantités d'E/S évoluent également.
Par souci d'équité, mettant de côté pour le moment la description de cet article, PHP et Java ont des implémentations d'E/S non bloquantes qui peuvent être utilisées dans les applications Web. Mais ces méthodes ne sont pas aussi courantes que les méthodes ci-dessus, et la surcharge opérationnelle associée à la maintenance du serveur à l'aide de cette méthode doit être prise en compte. Sans oublier que votre code doit être structuré de manière adaptée à ces environnements ; les applications web PHP ou Java « normales » ne subissent généralement pas de modifications significatives dans de tels environnements.
À titre de comparaison, si vous ne considérez que quelques facteurs importants qui affectent les performances et la facilité d'utilisation, vous pouvez obtenir :
Langue | Thread ou processus | E/S non bloquantes | Facilité d'utilisation | |
---|---|---|---|---|
PHP | Processus | Non | ||
Java | Thème | Disponible | Besoin d'un rappel | |
Node.js | Thème | Oui | Besoin d'un rappel | |
Allez | Thread (Goroutine) | Oui | Aucun rappel requis |
Les threads sont généralement plus efficaces en mémoire que les processus car ils partagent le même espace mémoire, contrairement aux processus. Combinés aux facteurs liés aux E/S non bloquantes, lorsque nous descendons dans la liste jusqu'au démarrage général en ce qui concerne l'amélioration des E/S, on peut voir au moins les mêmes facteurs que ceux considérés ci-dessus. Si je devais choisir un gagnant parmi les jeux ci-dessus, ce serait certainement Go.
Cela dit, en pratique, l'environnement dans lequel vous choisissez de construire votre application est étroitement lié à la familiarité de votre équipe avec ledit environnement et à la productivité globale qui peut être atteinte. Par conséquent, il n’est peut-être pas logique que chaque équipe se lance et commence à développer des applications et des services Web dans Node ou Go. En fait, la familiarité avec les développeurs ou les équipes internes est souvent citée comme la principale raison de ne pas utiliser un langage et/ou un environnement différent. En d’autres termes, les temps ont radicalement changé au cours des quinze dernières années.
J'espère que ce qui précède vous aidera à avoir une image plus claire de ce qui se passe dans les coulisses et vous donnera quelques idées sur la façon de gérer l'évolutivité réelle de votre application. Bonne entrée, bonne sortie !
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!