Concurrence et performance
PHP est synchrone : un processus exĂ©cute une requĂȘte, bloque Ă chaque appel dâentrĂ©e-sortie, et ce comportement est voulu. Il nây a pas de boucle dâĂ©vĂ©nements Ă alimenter, pas de mot-clĂ© async Ă Ă©crire, pas de goroutine Ă lancer, parce que la concurrence vient de lâextĂ©rieur du processus : le pool FPM fait tourner autant de copies de votre script que vous en avez configurĂ©es, chacune seule dans sa mĂ©moire, et le systĂšme dâexploitation les rĂ©partit sur les cĆurs.
Si vous venez de Node, cela ressemble Ă un retour en arriĂšre, mais la comparaison ne tient pas. Node a besoin dâune boucle dâĂ©vĂ©nements parce quâun seul processus sert toutes les connexions, si bien quâun seul appel bloquant les figerait toutes. PHP a donnĂ© Ă chaque requĂȘte son propre processus, donc un appel bloquant ne coĂ»te rien que quiconque puisse voir : une requĂȘte en base qui prend 40 millisecondes occupe un worker pendant 40 millisecondes, et les autres workers ne sâen aperçoivent pas.
Ce que vous nâavez pas
Il nây a pas de threads cĂŽtĂ© utilisateur. Une extension parallel existe pour les builds thread-safe (ZTS), et presque personne ne sâen sert. Le build standard est NTS, non thread-safe, parce que le modĂšle sans Ă©tat partagĂ© nâa jamais eu besoin de threads.
Il nây a pas non plus dâordonnanceur intĂ©grĂ©. Les Fibers (PHP 8.1) sont des coroutines Ă pile : une fonction peut se suspendre, et celui qui dĂ©tient la fiber peut la reprendre plus tard, sans que rien dans le langage ne dĂ©cide du moment de la reprise ni ne multiplexe les sockets. Une fiber est une brique de base, et les bibliothĂšques asynchrones sâen servent pour quâun code dâapparence ordinaire puisse cĂ©der la main au milieu dâun appel bloquant.
<?php
declare(strict_types=1);
$fiber = new Fiber(function (string $greeting): string {
$name = Fiber::suspend('who is there?');
return "$greeting, $name";
});
$question = $fiber->start('Hello'); // runs until suspend()
echo $question, PHP_EOL; // who is there?
$fiber->resume('Ada'); // runs to the return
echo $fiber->getReturn(), PHP_EOL; // Hello, Ada
Vous lirez du code comme celui-ci dans une bibliothĂšque, mais vous ne lâĂ©crirez pas dans une application. LâĂ©quivalent Python serait une coroutine Ă base de gĂ©nĂ©rateurs comme on en Ă©crivait avant asyncio, câest-Ă -dire le mĂ©canisme sans le runtime.
Quand vous avez vraiment besoin dâasynchrone
Certaines charges ne rentrent pas dans le modĂšle « une requĂȘte, un processus » : un serveur websocket qui tient dix mille connexions inactives, du long polling, un crawler qui fait cent appels HTTP sortants Ă la fois. Pour celles-lĂ , PHP a des runtimes asynchrones, et ce sont des bibliothĂšques, pas des fonctionnalitĂ©s du langage. Par ordre alphabĂ©tique : AMPHP, ReactPHP, et Swoole ou son fork OpenSwoole, qui est une extension. Les deux premiers sont du PHP pur bĂąti sur les fibers et la sĂ©lection de flux ; Swoole apporte sa propre boucle dâĂ©vĂ©nements en C.
Les runtimes worker de Comment PHP sâexĂ©cute, FrankenPHP et RoadRunner, sont une autre rĂ©ponse Ă une autre question : ils gardent votre application amorcĂ©e entre les requĂȘtes, toujours une requĂȘte Ă la fois par worker. Ils suppriment le coĂ»t de dĂ©marrage, mais ils ne rendent pas votre code concurrent.
Avant de vous tourner vers lâun dâeux, demandez-vous si le problĂšme est vraiment la concurrence, parce quâune application web classique nâen a jamais besoin et que dix workers FPM de plus ne coĂ»tent quâune ligne de configuration.
Le travail en arriĂšre-plan
Une requĂȘte dispose de trente secondes et doit envoyer une rĂ©ponse, donc tout ce qui prend plus longtemps, ou tout ce que lâutilisateur nâattend pas, doit sortir de la requĂȘte.
Lâidiome consiste Ă associer une file dâattente et un worker. La requĂȘte dĂ©pose un travail (une ligne dans une table, un message dans un broker) et rend la main. Un script CLI, lancĂ© par un superviseur de processus, boucle indĂ©finiment en tirant les travaux et en les exĂ©cutant. Il nâa ni limite de temps ni limite de mĂ©moire Ă moins que vous ne les fixiez, donc fixez-les : memory_limit dans lâini, et un compteur qui sort proprement aprĂšs quelques milliers de travaux pour que le superviseur relance un processus neuf. Une fuite mĂ©moire dans une boucle sans fin est le seul cas oĂč le nettoyage par requĂȘte de PHP ne vous sauve pas.
Cron couvre le cas planifiĂ©. La CLI a le reste de la boĂźte Ă outils : proc_open() lance un sous-processus avec des tubes, pcntl_fork() duplique le processus courant (CLI seulement, jamais sous FPM), et curl_multi_exec() effectue des requĂȘtes HTTP en parallĂšle sans la moindre bibliothĂšque :
<?php
declare(strict_types=1);
$urls = ['https://example.com/a', 'https://example.com/b', 'https://example.com/c'];
$multi = curl_multi_init();
$handles = [];
foreach ($urls as $url) {
$handle = curl_init($url);
curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($multi, $handle);
$handles[$url] = $handle;
}
do {
$status = curl_multi_exec($multi, $running);
if ($running) {
curl_multi_select($multi);
}
} while ($running && $status === CURLM_OK);
foreach ($handles as $url => $handle) {
echo $url, ': ', strlen((string) curl_multi_getcontent($handle)), " bytes\n";
curl_multi_remove_handle($multi, $handle);
}
Trois requĂȘtes partent en mĂȘme temps pour une seule attente, et câest tout le parallĂ©lisme dont la plupart des scripts auront jamais besoin.
OĂč passe le temps
LâinterprĂ©teur est rarement le goulot dâĂ©tranglement. Une requĂȘte passe son temps Ă attendre la base de donnĂ©es, le cache, le systĂšme de fichiers et dâautres services. Optimiser une boucle qui tourne en deux millisecondes pendant quâune requĂȘte SQL en prend quatre-vingts est lâerreur classique, et elle ne dĂ©pend pas du langage.
Le seul rĂ©glage qui compte est OPcache, dĂ©crit dans Comment PHP sâexĂ©cute. Assurez-vous quâil est actif et que opcache.memory_consumption est assez grand pour toute la base de code (opcache_get_status() vous le dit). Deux raffinements viennent par-dessus :
- Le préchargement (
opcache.preload=preload.php) compile une liste de fichiers une fois au dĂ©marrage de FPM et les garde liĂ©s en mĂ©moire, si bien que les classes nâont plus besoin dâautoloading du tout. Il faut un redĂ©marrage pour prendre en compte les changements, et câest pourquoi câest un rĂ©glage de production. - Le JIT compile les chemins chauds en code machine. Il accĂ©lĂšre le travail gourmand en CPU, parfois beaucoup, et accĂ©lĂšre trĂšs peu une requĂȘte web ordinaire. Activez-le (
opcache.jit=tracing,opcache.jit_buffer_size=64M), mesurez, gardez-le sâil a aidĂ©.
Lâautoloading a un coĂ»t, et Composer peut en supprimer lâessentiel. composer dump-autoload -o gĂ©nĂšre une carte de classes pour quâaucune recherche sur le systĂšme de fichiers nâait lieu par classe ; --classmap-authoritative va plus loin et ne touche jamais au systĂšme de fichiers pour une classe absente de la carte. Les deux ont leur place dans le script de dĂ©ploiement. realpath_cache_size dans lâini, quelques mĂ©gaoctets, Ă©vite Ă PHP de rĂ©soudre Ă nouveau les chemins Ă chaque requĂȘte.
Mesurer
Rien de ce qui précÚde ne vaut la peine avant une mesure. Le langage fournit les deux primitives :
<?php
declare(strict_types=1);
$numbers = range(1, 1_000_000);
$start = hrtime(true);
$doubled = array_map(fn (int $n): int => $n * 2, $numbers);
$mapTime = hrtime(true) - $start;
$start = hrtime(true);
$doubled = [];
foreach ($numbers as $n) {
$doubled[] = $n * 2;
}
$loopTime = hrtime(true) - $start;
printf("array_map: %.1f ms\n", $mapTime / 1e6);
printf("foreach: %.1f ms\n", $loopTime / 1e6);
printf("peak memory: %.1f MB\n", memory_get_peak_usage() / 1e6);
Les deux mesures tombent dans les dizaines de millisecondes pour un million dâĂ©lĂ©ments, avec un Ă©cart faible qui dĂ©pend de la version de PHP. La leçon nâest pas de savoir lequel gagne, mais quâun million dâitĂ©rations coĂ»te moins quâune seule requĂȘte SQL lente, ce qui vous laisse libre dâĂ©crire la version la plus lisible.
Pour un vrai profil, Xdebug a un mode profileur (xdebug.mode=profile) qui Ă©crit des graphes dâappels que votre Ă©diteur peut ouvrir, et des profileurs par Ă©chantillonnage existent sous forme dâextensions pour la production, oĂč le surcoĂ»t de Xdebug est inacceptable. Pointez lâun ou lâautre sur une requĂȘte lente et lisez le haut de la liste.
La mĂ©moire suit la mĂȘme rĂšgle. Une requĂȘte qui construit un tableau de cent mille lignes et meurt sur memory_limit a besoin dâun gĂ©nĂ©rateur, comme lâa montrĂ© Fonctions et closures, pas dâune limite plus haute. unset() libĂšre une variable, et le ramasse-miettes gĂšre seul les cycles de rĂ©fĂ©rences ; gc_collect_cycles() force une passe, quâun worker de longue durĂ©e peut appeler entre deux travaux.
Le piĂšge
La premiĂšre erreur consiste Ă importer un modĂšle de concurrence parce que le langage prĂ©cĂ©dent en avait besoin. Un runtime asynchrone sous une application CRUD ajoute une couche, un jeu de bibliothĂšques qui doivent ĂȘtre compatibles avec les fibers, et une classe de bugs (lâĂ©tat partagĂ© entre les requĂȘtes) que FPM rendait impossibles. Le gain est nul, parce que les requĂȘtes ne sâattendaient jamais les unes les autres.
La seconde consiste Ă optimiser sans chiffre. Lâoption JIT, la carte de classes ou la boucle réécrite sont autant dâhypothĂšses, et seule une mesure avec hrtime() sur le chemin lent, avant et aprĂšs, en fait des rĂ©sultats.
Le runtime, le langage et les outils sont maintenant couverts, et il reste un lecteur Ă servir : Revenir Ă PHP aprĂšs des annĂ©es sâadresse au dĂ©veloppeur dont le dernier PHP contenait encore mysql_query.