Essayer la précharge (PHP 7.4) et RoadRunner



Bonjour, Habr!

Nous écrivons et parlons souvent des performances PHP: comment nous les traitons en général, comment nous avons économisé 1 million de dollars lors du passage à PHP 7.0, et traduisons également divers documents sur ce sujet. Cela est dû au fait que l'audience de nos produits augmente, et la mise à l'échelle du backend PHP avec du fer est lourde de coûts importants - nous avons 600 serveurs avec PHP-FPM. Par conséquent, investir du temps dans l'optimisation est bénéfique pour nous.

Avant, nous parlions principalement des façons habituelles et déjà établies de travailler avec la productivité. Mais la communauté PHP est en alerte! JIT apparaîtra en PHP 8, la précharge apparaîtra en PHP 7.4 et des frameworks en dehors du framework de développement PHP de base seront développés, qui supposent que PHP fonctionne comme un démon. Il est temps d'expérimenter quelque chose de nouveau et de voir ce que cela peut nous apporter.

Étant donné que la sortie de PHP 8 est encore loin et que les cadres asynchrones sont mal adaptés à nos tâches (pourquoi - je le dirai ci-dessous), nous nous concentrerons aujourd'hui sur la précharge, qui apparaîtra dans PHP 7.4, et le cadre pour diaboliser PHP, RoadRunner.

Ceci est la version texte de mon rapport avec Badoo PHP Meetup # 3 . Vidéo de tous les discours que nous avons recueillis dans ce post .

PHP-FPM, Apache mod_php, et des méthodes similaires pour exécuter des scripts PHP et traiter des demandes (qui sont exécutées par la grande majorité des sites et des services; pour plus de simplicité, je les appellerai PHP «classiques») fonctionnent sur la base de rien-partagé au sens large du terme:

  • l'état n'est pas fouillé entre les travailleurs PHP;
  • l'état n'est pas fouillé entre les différentes requêtes.

Considérez ceci avec un exemple de script simple:

//  $app = \App::init(); $storage = $app->getCitiesStorage(); //   $name = $storage->getById($_COOKIE['city_id']); echo " : {$name}"; 

Pour chaque demande, le script est exécuté de la première à la dernière ligne: malgré le fait que l'initialisation, très probablement, ne différera pas de la demande à la demande et qu'elle peut potentiellement être effectuée une fois (économie de ressources), vous devez toujours la répéter pour chaque demande. Nous ne pouvons pas simplement prendre et enregistrer des variables (par exemple, $app ) entre les requêtes en raison des particularités du fonctionnement du PHP «classique».

À quoi cela ressemblerait-il si nous sortions du cadre du PHP «classique»? Par exemple, notre script pourrait s'exécuter indépendamment de la demande, initialiser et avoir une boucle de requête à l'intérieur, dans laquelle il attendrait la suivante, la traiterait et répéterait la boucle sans nettoyer l'environnement (ci-après j'appellerai cette solution «PHP comme un démon ").

 //  $app = \App::init(); $storage = $app->getCitiesStorage(); $cities = $storage->getAll(); //    while ($req = getNextRequest()) {    $name = $cities[$req->getCookie('city_id')];    echo " : {$name}"; } 

Nous pouvions non seulement nous débarrasser de l'initialisation répétée pour chaque demande, mais aussi enregistrer une fois la liste des villes dans la variable $cities et l'utiliser à partir de diverses demandes sans accéder à n'importe où sauf à la mémoire (c'est le moyen le plus rapide pour obtenir des données).

Les performances d'une telle solution sont potentiellement significativement supérieures à celles du PHP "classique". Mais généralement, l'augmentation de la productivité n'est pas gratuite - vous devez en payer le prix. Voyons ce que cela peut être dans notre cas.

Pour ce faire, compliquons un peu notre script et au lieu d'afficher la variable $name , nous remplirons le tableau:

 -  $name = $cities[$req->getCookie('city_id')]; +  $names[] = $cities[$req->getCookie('city_id')]; 

Dans le cas de PHP «classique», aucun problème ne se posera - à la fin de la requête, la variable $name sera détruite et chaque requête suivante fonctionnera comme prévu. Dans le cas du démarrage de PHP en tant que démon, chaque requête ajoutera une autre ville à cette variable, ce qui entraînera une croissance incontrôlée du tableau jusqu'à ce que la mémoire soit épuisée sur la machine.

En général, non seulement la mémoire peut se terminer, mais d'autres erreurs peuvent survenir et entraîner la mort du processus. Avec de tels problèmes, PHP "classique" gère automatiquement. Dans le cas du démarrage de PHP en tant que démon, nous devons en quelque sorte surveiller ce démon, le redémarrer s'il se bloque.

Les erreurs de ce type sont désagréables, mais il existe des solutions efficaces pour les résoudre. C'est bien pire si, en raison d'une erreur, le script ne tombe pas, mais modifie de façon imprévisible les valeurs de certaines variables (par exemple, il efface le tableau $cities ). Dans ce cas, toutes les demandes ultérieures fonctionneront avec des données incorrectes.

Pour résumer, il est plus facile d'écrire du code pour PHP «classique» (PHP-FPM, Apache mod_php et similaires) - cela nous libère d'un certain nombre de problèmes et d'erreurs. Mais pour cela, nous payons avec des performances.

D'après les exemples ci-dessus, nous voyons que dans certaines parties du code, PHP dépense des ressources qui n'auraient pas pu être dépensées (ou gaspillées une fois) pour traiter chaque demande de la «classique». Ce sont les domaines suivants:

  • connexion de fichiers (inclure, exiger, etc.);
  • initialisation (framework, bibliothèques, conteneur DI, etc.);
  • demander des données à un stockage externe (au lieu de les stocker en mémoire).

PHP existe depuis de nombreuses années et peut même être devenu populaire grâce à ce modèle de travail. Pendant ce temps, de nombreuses méthodes de divers degrés de succès ont été développées pour résoudre le problème décrit. J'en ai mentionné quelques-uns dans mon précédent article . Aujourd'hui, nous allons nous attarder sur deux solutions assez nouvelles pour la communauté: la précharge et RoadRunner.

Précharge


Parmi les trois points énumérés ci-dessus, la précharge est conçue pour gérer la première surcharge lors de la connexion de fichiers. À première vue, cela peut sembler étrange et dénué de sens, car PHP a déjà OPcache, qui a été créé juste à cet effet. Pour comprendre l'essence, profilons le réel à l'aide de perf , sur lequel OPcache est activé, avec un taux de réussite égal à 100%.



Malgré OPcache, nous voyons que persistent_compile_file prend 5,84% du temps d'exécution de la requête.

Afin de comprendre pourquoi cela se produit, nous pouvons regarder les sources de zend_accel_load_script . On peut voir d'eux que, malgré la présence d'OPcache, à chaque appel à include/require signatures des classes et des fonctions sont copiées de la mémoire partagée vers la mémoire du processus de travail, et divers travaux auxiliaires sont effectués. Et ce travail doit être fait pour chaque demande, car à la fin de celle-ci la mémoire du processus de travail est effacée.



Ceci est aggravé par le grand nombre d'appels inclus / requis que nous faisons habituellement en une seule demande. Par exemple, Symfony 4 comprend environ 310 fichiers avant d'exécuter la première ligne de code utile. Parfois, cela se produit implicitement: pour créer une instance de classe A, illustrée ci-dessous, PHP chargera automatiquement toutes les autres classes (B, C, D, E, F, G). Et surtout à cet égard, les dépendances de Composer qui déclarent des fonctions se démarquent: pour garantir que ces fonctions seront disponibles pendant l'exécution du code utilisateur, Composer est toujours obligé de les connecter indépendamment de leur utilisation, car PHP n'a pas de fonctions de chargement automatique et elles ne peuvent pas être chargé au moment de l'appel.

 class A extends \B implements \C {    use \D;    const SOME_CONST = \E::E1;    private static $someVar = \F::F1;    private $anotherVar = \G::G1; } 


Fonctionnement de la précharge


Le préchargement a un seul paramètre principal, opcache.preload, dans lequel le chemin d'accès au script PHP est transmis. Ce script sera exécuté une fois lors du démarrage de PHP-FPM / Apache /, etc., et toutes les signatures des classes, méthodes et fonctions qui seront déclarées dans ce fichier seront disponibles pour tous les scripts qui traitent les requêtes depuis la première ligne de leur exécution (important note: ceci ne s'applique pas aux variables et constantes globales - leurs valeurs seront remises à zéro après la fin de la phase de précharge). Vous n'avez plus besoin de faire des appels include / require et de copier les signatures de fonction / classe de la mémoire partagée vers la mémoire de processus: tous sont déclarés immuables et de ce fait, tous les processus peuvent se référer au même emplacement de mémoire qui les contient.

Habituellement, les classes et les fonctions dont nous avons besoin se trouvent dans des fichiers différents et il n'est pas pratique de les combiner en un seul script de préchargement. Mais cela n'a pas besoin d'être fait: comme le préchargement est un script PHP normal, nous pouvons simplement utiliser include / require ou opcache_compile_file () du script de préchargement pour tous les fichiers dont nous avons besoin. De plus, étant donné que tous ces fichiers seront chargés une seule fois, PHP pourra effectuer des optimisations supplémentaires qui ne pourraient pas être effectuées pendant que nous connections séparément ces fichiers au moment de la requête. PHP n'effectue des optimisations que dans le cadre de chaque fichier séparé, mais dans le cas du préchargement, pour tout le code chargé dans la phase de préchargement.

Précharge des références


Afin de démontrer en pratique les avantages de la précharge, j'ai pris un point de terminaison lié au processeur Badoo. Notre backend est généralement caractérisé par une charge liée au CPU. Ce fait est la réponse à la question de savoir pourquoi nous n'avons pas considéré les frameworks asynchrones: ils ne donnent aucun avantage dans le cas d'une charge liée au CPU et en même temps compliquent encore plus le code (il doit être écrit différemment), ainsi que pour travailler avec un réseau, un disque, etc. des pilotes asynchrones spéciaux sont requis.

Afin d'apprécier pleinement les avantages de la précharge, pour l'expérience, j'ai téléchargé avec elle tous les fichiers nécessaires au script testé au travail, et je l'ai chargé avec un semblant de charge de production normale en utilisant wrk2 - un analogue plus avancé d'Apache Benchmark, mais tout aussi simple .

Pour essayer le préchargement, vous devez d'abord mettre à niveau vers PHP 7.4 (nous avons maintenant PHP 7.2). J'ai mesuré les performances de PHP 7.2, PHP 7.4 sans précharge et PHP 7.4 avec précharge. Le résultat est une telle image:



Ainsi, la transition de PHP 7.2 à PHP 7.4 donne + 10% aux performances de notre point de terminaison, et la précharge donne encore 10% d'en haut.

Dans le cas de la précharge, les résultats dépendront grandement du nombre de fichiers connectés et de la complexité de la logique exécutable: si de nombreux fichiers sont connectés et que la logique est simple, la précharge donnera plus que s'il y a peu de fichiers et que la logique est complexe.

Les nuances de la précharge


Ce qui augmente la productivité a généralement un inconvénient. La précharge a beaucoup de nuances, que je donnerai ci-dessous. Ils doivent tous être pris en compte, mais un seul (premier) peut être fondamental.

Changer - redémarrer


Étant donné que tous les fichiers de préchargement sont compilés uniquement au démarrage, marqués comme immuables et non recompilés à l'avenir, la seule façon d'appliquer des modifications à ces fichiers est de redémarrer (recharger ou redémarrer) PHP-FPM / Apache /, etc.

Dans le cas du rechargement, PHP essaie de redémarrer le plus précisément possible: les requêtes des utilisateurs ne seront pas interrompues, mais néanmoins, pendant la phase de préchargement, toutes les nouvelles requêtes attendront qu'elle se termine. S'il n'y a pas beaucoup de code en préchargement, cela ne peut pas poser de problèmes, mais si vous essayez de télécharger l'application entière, cela entraîne une augmentation significative du temps de réponse lors du redémarrage.

En outre, un redémarrage (qu'il s'agisse de recharger ou de redémarrer) a une fonctionnalité importante - à la suite de cette action, OPcache est effacé. Autrement dit, toutes les demandes après cela fonctionneront avec un cache d'opcode froid, ce qui peut augmenter encore plus le temps de réponse.

Caractères indéfinis


Pour que la précharge charge une classe, tout ce dont elle dépend doit être défini jusqu'à ce point. Pour la classe ci-dessous, cela signifie que toutes les autres classes (B, C, D, E, F, G), la variable $someGlobalVar et la constante SOME_CONST doivent être disponibles avant de compiler cette classe. Comme le script de préchargement n'est que du code PHP normal, nous pouvons définir un chargeur automatique. Dans ce cas, tout ce qui est connecté avec d'autres classes sera automatiquement chargé par lui. Mais cela ne fonctionne pas avec des variables et des constantes: nous devons nous-mêmes nous assurer qu'elles sont définies au moment où cette classe est déclarée.

 class A extends \B implements \C {    use \D;    const SOME_CONST = \E::E1;    private static $someVar = \F::F1;    private $anotherVar = \G::G1;    private $varLink = $someGlobalVar;    private $constLink = SOME_CONST; } 

Heureusement, la précharge contient suffisamment d'outils pour comprendre si vous obtenez quelque chose ou non. Tout d'abord, ce sont des messages d'avertissement contenant des informations sur ce qui n'a pas pu être chargé et pourquoi:

 PHP Warning: Can't preload class MyTestClass with unresolved initializer for constant RAND in /local/preload-internal.php on line 6 PHP Warning: Can't preload unlinked class MyTestClass: Unknown parent AnotherClass in /local/preload-internal.php on line 5 

Deuxièmement, le préchargement ajoute une section distincte au résultat de la fonction opcache_get_status (), qui montre ce qui a été chargé avec succès dans la phase de préchargement:



Champ de classe / optimisation constante


Comme je l'ai écrit ci-dessus, la précharge résout les valeurs des champs / constantes de la classe et les enregistre. Cela vous permet d'optimiser le code: lors du traitement de la demande, les données sont prêtes et n'ont pas besoin d'être dérivées d'autres données. Mais cela peut conduire à des résultats non évidents, comme le montre l'exemple suivant:

 const.php: <?php define('MYTESTCONST', mt_rand(1, 1000)); 

 preload.php: <?php include 'const.php'; class MyTestClass {    const RAND = MYTESTCONST; } 

 script.php: <?php include 'const.php'; echo MYTESTCONST, ', ', MyTestClass::RAND; // 32, 154 

Le résultat est une situation contre-intuitive: il semblerait que les constantes devraient être égales, puisque l'une d'elles a été affectée à la valeur de l'autre, mais en réalité ce n'est pas le cas. Cela est dû au fait que les constantes globales, contrairement aux constantes / champs de classe, sont effacées de force après la fin de la phase de préchargement, tandis que les constantes / champs de classe sont résolus et enregistrés. Cela conduit au fait que lors de l'exécution de la demande, nous devons redéfinir la constante globale, ce qui permet d'obtenir une valeur différente.

Impossible de redéclarer someFunc ()


Dans le cas des classes, la situation est simple: généralement nous ne les connectons pas explicitement, mais utilisons un chargeur automatique. Cela signifie que si une classe est définie dans la phase de préchargement, l'autochargeur ne s'exécutera tout simplement pas pendant la demande et nous n'essaierons pas de connecter cette classe une deuxième fois.

La situation est différente avec les fonctions: nous devons les connecter explicitement. Cela peut conduire à une situation où, dans le script de préchargement, nous connecterons tous les fichiers nécessaires avec des fonctions, et pendant la demande, nous essaierons de le faire à nouveau (un exemple typique est le chargeur de démarrage Composer: il essaiera toujours de connecter tous les fichiers avec des fonctions). Dans ce cas, nous obtenons une erreur: la fonction a déjà été définie et ne peut pas être redéfinie.

Ce problème peut être résolu de différentes manières. Dans le cas de Composer, vous pouvez, par exemple, tout connecter dans la phase de préchargement, et pendant les requêtes ne connectez rien du tout lié à Composer. Une autre solution n'est pas de connecter directement des fichiers avec des fonctions, mais de le faire via un fichier proxy avec une vérification de function_exists (), comme, par exemple, Guzzle HTTP le fait .



PHP 7.4 n'a pas encore officiellement publié (encore)


Cette nuance deviendra inutile après un certain temps, mais jusqu'à ce que la version PHP 7.4 ne soit pas encore officiellement publiée et que l'équipe PHP écrive explicitement dans les notes de publication: "Veuillez NE PAS utiliser cette version en production, c'est une version de test précoce." Au cours de nos expériences avec la précharge, nous avons rencontré plusieurs bugs, nous les avons corrigés nous-mêmes et avons même envoyé quelque chose en amont. Pour éviter les surprises, il vaut mieux attendre la sortie officielle.

Roadrunner


RoadRunner est un démon écrit en Go, qui, d'une part, crée des travailleurs PHP et les surveille (démarre / finit / redémarre si nécessaire), et d'autre part, accepte les demandes et les transmet à ces travailleurs. En ce sens, son travail n'est pas différent de celui de PHP-FPM (où il existe également un processus maître qui surveille les travailleurs). Mais il y a encore des différences. La clé est que RoadRunner ne réinitialise pas l'état du script après la fin de la requête.

Ainsi, si nous rappelons notre liste des ressources dépensées dans le cas du PHP «classique», RoadRunner vous permet de traiter tous les points (la précharge, comme nous le rappelons, ne concerne que le premier):

  • connexion de fichiers (inclure, exiger, etc.);
  • initialisation (framework, bibliothèques, conteneur DI, etc.);
  • demander des données à un stockage externe (au lieu de les stocker en mémoire).

L'exemple Hello World RoadRunner ressemble à ceci:

 $relay = new Spiral\Goridge\StreamRelay(STDIN, STDOUT); $psr7 = new Spiral\RoadRunner\PSR7Client(new Spiral\RoadRunner\Worker($relay)); while ($req = $psr7->acceptRequest()) {        $resp = new \Zend\Diactoros\Response();        $resp->getBody()->write("hello world");        $psr7->respond($resp); } 

Nous allons essayer notre point de terminaison actuel, que nous avons testé avec précharge, pour fonctionner sur RoadRunner sans modifications, le charger et mesurer les performances. Aucune modification - car sinon, la référence ne sera pas complètement honnête.

Essayons d'adapter l'exemple Hello World pour cela.

Premièrement, comme je l'ai écrit plus haut, nous ne voulons pas que le travailleur tombe en cas d'erreur. Pour ce faire, nous devons tout emballer dans un try..catch global. Deuxièmement, puisque notre script ne sait rien de Zend Diactoros, pour la réponse, nous aurons besoin de convertir ses résultats. Pour cela, nous utilisons des fonctions ob_. Troisièmement, notre script ne sait rien de la nature de la demande PSR-7. La solution consiste à remplir l'environnement PHP standard à partir de ces entités. Et quatrièmement, notre script s'attend à ce que la demande meure et l'état entier sera effacé. Par conséquent, avec RoadRunner, nous devrons effectuer ce nettoyage nous-mêmes.

Ainsi, la version initiale de Hello World se transforme en quelque chose comme ceci:

 while ($req = $psr7->acceptRequest()) {    try {        $uri = $req->getUri();        $_COOKIE = $req->getCookieParams();        $_POST = $req->getParsedBody();        $_SERVER = [            'REQUEST_METHOD' => $req->getMethod(),            'HTTP_HOST' => $uri->getHost(),            'DOCUMENT_URI' => $uri->getPath(),            'SERVER_NAME' => $uri->getHost(),            'QUERY_STRING' => $uri->getQuery(),            // ...        ];        ob_start();        // our logic here        $output = ob_get_contents();        ob_clean();               $resp = new \Zend\Diactoros\Response();        $resp->getBody()->write($output, 200);        $psr7->respond($resp);    } catch (\Throwable $Throwable) {        // some error handling logic here    }    \UDS\Event::flush();    \PinbaClient::sendAll();    \PinbaClient::flushAll();    \HTTP::clear();    \ViewFactory::clear();    \Logger::clearCaches();       // ... } 


Benchmarks RoadRunner


Eh bien, il est temps de lancer des benchmarks.



Les résultats ne répondent pas aux attentes: RoadRunner vous permet de niveler plus de facteurs causant des pertes de performances que la précharge, mais les résultats sont pires. Voyons pourquoi cela se produit, comme toujours, en exécutant perf pour cela.



Dans les résultats de perf, nous voyons phar_compile_file. En effet, nous incluons certains fichiers lors de l'exécution du script, et comme OPcache n'est pas activé (RoadRunner exécute les scripts en tant que CLI, où OPcache est désactivé par défaut), ces fichiers sont à nouveau compilés avec chaque demande.

Modifiez la configuration de RoadRunner - activez OPcache:





Ces résultats ressemblent déjà davantage à ce que nous attendions: RoadRunner a commencé à montrer plus de performances que la précharge. Mais peut-être pourrons-nous en obtenir encore plus!

Il ne semble rien de plus inhabituel avec perf - regardons le code PHP. La façon la plus simple de le profiler est d'utiliser phpspy : il ne nécessite aucune modification du code PHP - il suffit de l'exécuter dans la console. Faisons cela et construisons un graphique de flamme:



Puisque nous avons accepté de ne pas modifier la logique de notre application pour la pureté de l'expérience, nous nous intéressons à la branche stack associée aux travaux de RoadRunner:



La partie principale de cela se résume à appeler fread (), presque rien ne peut être fait avec cela. Mais nous voyons d'autres branches dans \ Spiral \ RoadRunner \ PSR7Client :: acceptRequest () , à l'exception de fread lui-même. Vous pouvez comprendre leur signification en regardant le code source:

    /**     * @return ServerRequestInterface|null     */    public function acceptRequest()    {        $rawRequest = $this->httpClient->acceptRequest();        if ($rawRequest === null) {            return null;        }        $_SERVER = $this->configureServer($rawRequest['ctx']);        $request = $this->requestFactory->createServerRequest(            $rawRequest['ctx']['method'],            $rawRequest['ctx']['uri'],            $_SERVER        );        parse_str($rawRequest['ctx']['rawQuery'], $query);        $request = $request            ->withProtocolVersion(static::fetchProtocolVersion($rawRequest['ctx']['protocol']))            ->withCookieParams($rawRequest['ctx']['cookies'])            ->withQueryParams($query)            ->withUploadedFiles($this->wrapUploads($rawRequest['ctx']['uploads'])); 

Il devient clair que RoadRunner essaie de créer un objet de requête compatible PSR-7 à l'aide d'un tableau sérialisé. Si votre framework fonctionne directement avec les objets de requête PSR-7 (par exemple, Symfony ne fonctionne pas ), cela est parfaitement justifié. Dans d'autres cas, le PSR-7 devient un lien supplémentaire avant que la demande ne soit convertie en ce avec quoi votre application peut fonctionner. Supprimons ce lien intermédiaire et examinons à nouveau les résultats:



Le script de test était assez facile, j'ai donc réussi à dégager une part significative des performances - + 17% par rapport au PHP pur (je me souviens que la précharge donne + 10% sur le même script).

Nuances de RoadRunner


En général, l'utilisation de RoadRunner est un changement plus sérieux que la simple inclusion de la précharge, donc les nuances ici sont encore plus importantes.

-, RoadRunner, , PHP- , , , : , , .

-, RoadRunner , «» — . / RoadRunner ; , , , , - .

-, endpoint', , , RoadRunner. .

Conclusion


, «» PHP, , preload RoadRunner.

PHP «» (PHP-FPM, Apache mod_php ) . - , . , , preload JIT.

, , , RoadRunner, .

, (: ):

  • PHP 7.2 — 845 RPS;
  • PHP 7.4 — 931 RPS;
  • RoadRunner — 987 RPS;
  • PHP 7.4 + preload — 1030 RPS;
  • RoadRunner — 1089 RPS.

Badoo PHP 7.4 , ( ).

RoadRunner , , , , .

Merci de votre attention!

Source: https://habr.com/ru/post/fr472528/


All Articles