Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Panneau de gauche : un Ă©lĂ©phant jongleur qui maintient de nombreuses balles en l'air, Ă©tiquetĂ© boucle d'Ă©vĂ©nements. Panneau de droite : une rangĂ©e d'Ă©lĂ©phants qui tiennent chacun calmement une balle, Ă©tiquetĂ©e pool de processus. Les deux panneaux ont le mĂȘme nombre de balles

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.

Une barre horizontale qui montre une requĂȘte web comme une frise chronologique. Une fine tranche Ă  gauche est Ă©tiquetĂ©e PHP, puis un long segment Ă©tiquetĂ© base de donnĂ©es, puis un segment moyen Ă©tiquetĂ© appel HTTP, puis une fine tranche Ă©tiquetĂ©e PHP Ă  nouveau. Un petit Ă©lĂ©phant pointe le long segment base de donnĂ©es avec une loupe

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.