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

Le runtime

L’essentiel de ce que PHP fait bien, et l’essentiel de ce qu’il ne sait pas faire, dĂ©coule de la façon dont il sert une requĂȘte. Une requĂȘte PHP dĂ©marre sans rien, exĂ©cute votre code de haut en bas, envoie sa rĂ©ponse et est jetĂ©e ; la concurrence vient d’un pool de processus, chacun traitant une requĂȘte Ă  la fois. Rien ne survit d’une requĂȘte Ă  la suivante Ă  l’intĂ©rieur du processus, et c’est pour cela qu’on appelle ce modĂšle shared-nothing : aucun objet application ne reste en vie, pas de boucle d’évĂ©nements, pas de thread.

Une requĂȘte, un processus

Le dĂ©ploiement standard, c’est PHP-FPM, le gestionnaire de processus FastCGI, derriĂšre un serveur web comme nginx, Apache ou Caddy. Le gestionnaire entretient un pool de processus worker, confie chaque requĂȘte entrante Ă  un worker libre, et le worker exĂ©cute le script, Ă©crit la rĂ©ponse et revient dans le pool la mĂ©moire vidĂ©e. La taille du pool tient en une ligne de configuration. Une machine avec plus de cƓurs fait tourner plus de workers, et plus de machines derriĂšre un rĂ©partiteur de charge font tourner plus de pools, sans la moindre coordination entre eux.

Une rangĂ©e de six petites piĂšces identiques dessinĂ©es cĂŽte Ă  cĂŽte. Dans chacune, un Ă©lĂ©phant reçoit une enveloppe par une fente, travaille Ă  un bureau, tend une feuille par une autre fente, et la piĂšce est balayĂ©e derriĂšre lui. Une septiĂšme piĂšce, au bout, est vide et en train d'ĂȘtre balayĂ©e. Rien ne passe d'une piĂšce Ă  l'autre

Une Ă©quipe plateforme remarque d’abord le bon cĂŽtĂ© de ce modĂšle. Une requĂȘte qui plante, qui fuit ou qui dĂ©passe son temps emporte un worker avec elle et rien d’autre ; le gestionnaire remplace le worker et les autres requĂȘtes n’en voient rien. Une fuite mĂ©moire ne peut pas s’accumuler au-delĂ  d’une requĂȘte, si bien qu’une application PHP qui tourne longtemps ne se dĂ©grade pas au fil des jours comme peut le faire un processus qui vit longtemps. DĂ©ployer, c’est copier des fichiers puis vider un cache, parce qu’il n’y a aucun processus Ă  redĂ©marrer proprement et aucun Ă©tat en mĂ©moire Ă  drainer.

Le mĂȘme modĂšle a ses coĂ»ts, et c’est un ingĂ©nieur performance qui les voit en premier. Chaque requĂȘte paie l’amorçage de l’application : lire la configuration, cĂąbler les dĂ©pendances, enregistrer les routes. Il n’y a pas de cache en processus d’une requĂȘte Ă  l’autre, si bien qu’une table calculĂ©e une fois par processus dans un autre langage l’est ici une fois par requĂȘte, Ă  moins de la ranger en mĂ©moire partagĂ©e via l’extension APCu ou dans un cache externe. Les connexions Ă  la base de donnĂ©es s’ouvrent et se ferment Ă  chaque requĂȘte, sauf Ă  les configurer comme persistantes, si bien qu’un pool de connexions, quand il en faut un, vit hors de PHP. Une charge qui doit garder un Ă©tat entre les requĂȘtes, un serveur WebSocket ou un salon de jeu, n’entre pas du tout dans ce modĂšle, et Concurrence explique ce qui y entre Ă  la place.

OPcache, le préchargement et le JIT

L’objection Ă©vidente Ă  un amorçage Ă  chaque requĂȘte, c’est le coĂ»t d’analyser et de compiler le source Ă  chaque fois, et PHP l’a levĂ©e en 2013. OPcache garde la forme compilĂ©e de chaque fichier en mĂ©moire partagĂ©e, si bien qu’un fichier est compilĂ© une fois par dĂ©ploiement et non une fois par requĂȘte. Il fait partie de la distribution standard depuis PHP 5.5 et il est activĂ© dans le modĂšle php.ini-production livrĂ© avec elle ; un benchmark lancĂ© sans lui ne mesure rien de PHP.

Le prĂ©chargement, arrivĂ© avec PHP 7.4, va un cran plus loin. Une liste de fichiers est compilĂ©e et liĂ©e au dĂ©marrage du gestionnaire puis gardĂ©e en mĂ©moire, si bien que les classes existent avant la premiĂšre requĂȘte et qu’aucun autoloading n’a lieu. La RFC qui l’a introduit a mesurĂ© un gain de 30 % sur la page hello-world d’un framework et de 50 % sur celle d’un autre, et prĂ©cise dans le mĂȘme paragraphe que les gains en conditions rĂ©elles « seront probablement plus faibles » et dĂ©pendent du rapport entre l’amorçage et le travail effectif.

Le compilateur JIT, ajoutĂ© dans PHP 8.0 Ă  l’intĂ©rieur d’OPcache, compile les chemins de code chauds en code machine, et ses propres notes de version sont plus sobres que les gros titres. php.net indique que le JIT traçant « montre des performances environ 3 fois meilleures sur des benchmarks synthĂ©tiques et une amĂ©lioration de 1,5 Ă  2 fois sur certaines applications spĂ©cifiques de longue durĂ©e », et que « les performances d’une application typique sont au niveau de PHP 7.4 ». La mesure de la RFC elle-mĂȘme sur WordPress donnait 326 requĂȘtes par seconde avec le JIT contre 315 sans. Le JIT n’a jamais Ă©tĂ© activĂ© par dĂ©faut : avant PHP 8.4, la taille de son tampon Ă©tait zĂ©ro, et depuis 8.4 un tampon de 64 mĂ©gaoctets est rĂ©servĂ© mais opcache.jit vaut disable, si bien qu’il faut l’activer explicitement. Une application web en tire peu, un script limitĂ© par le CPU peut en tirer beaucoup.

Les runtimes worker

Le seul coĂ»t qu’OPcache ne supprime pas est l’amorçage de l’application, et une seconde famille de runtimes le supprime en gardant l’application en mĂ©moire. En mode worker, un processus amorce l’application une fois, puis traite les requĂȘtes en boucle, une Ă  la fois, pendant des milliers de requĂȘtes avant d’ĂȘtre recyclĂ©. Par ordre alphabĂ©tique : FrankenPHP, un serveur d’application Ă©crit en Go au-dessus du serveur web Caddy, qui propose aussi bien le mode classique d’une requĂȘte par processus qu’un mode worker ; RoadRunner, un serveur d’application lui aussi Ă©crit en Go, qui garde en vie un pool de workers PHP et leur transmet les requĂȘtes par un protocole ; et Swoole, ou son fork OpenSwoole, une extension en C qui donne Ă  PHP sa propre boucle d’évĂ©nements et son propre serveur HTTP. FrankenPHP est hĂ©bergĂ© sous l’organisation php sur GitHub depuis le 8 juin 2025, avec une gouvernance inchangĂ©e.

Ce que le changement rapporte dĂ©pend entiĂšrement du prix de l’amorçage, et la façon honnĂȘte de le montrer est de prendre le mĂȘme code avec le runtime pour seule variable. Sur un script hello-world nu, oĂč il n’y a aucun amorçage Ă  Ă©viter, Tideways, un Ă©diteur de profileur PHP et membre fondateur de la PHP Foundation sans intĂ©rĂȘt dans l’un ou l’autre runtime, a trouvĂ© FrankenPHP en mode classique et PHP-FPM Ă  moins d’un demi pour cent l’un de l’autre, autour de 18 400 requĂȘtes par seconde sur une machine virtuelle Ă  huit cƓurs. Sur un framework complet, la suite de benchmarks que DĂ©bit et latence lit en dĂ©tail montre l’entrĂ©e Symfony passer de 26 000 requĂȘtes par seconde sous PHP-FPM Ă  74 000 sous FrankenPHP et 111 000 sous Swoole sur son test Fortunes, sans une ligne changĂ©e dans le framework. Les Ă©diteurs et les auteurs de frameworks publient des rapports plus Ă©levĂ©s sur leurs propres pages. Je m’en tiens Ă  ces deux chiffres parce que vous pouvez reproduire l’un et l’autre Ă  partir de documents publiĂ©s.

Le prix, c’est la perte de la garantie shared-nothing. Un worker qui garde l’application amorcĂ©e garde aussi tout ce qu’elle a laissĂ© fuir, si bien que propriĂ©tĂ©s statiques, caches et ressources ouvertes persistent dĂ©sormais d’une requĂȘte Ă  l’autre, et qu’une classe de bugs que PHP-FPM rendait impossible redevient possible. La documentation de FrankenPHP le dit sans dĂ©tour : PHP « n’a pas Ă©tĂ© conçu Ă  l’origine pour des processus de longue durĂ©e », et le mode worker est livrĂ© avec un compteur de requĂȘtes maximum qui recycle les processus par prĂ©caution. Une Ă©quipe qui choisit le mode worker endosse la discipline que les Ă©quipes Node.js ou Java ont dĂ©jĂ .

Ce que coûte un processus

Vous voudrez un point de rĂ©fĂ©rence avant d’ajouter le moindre framework. Sur la machine oĂč j’ai Ă©crit ce livre, un processus PHP 8.3 en ligne de commande, sans script, alloue 2 mĂ©gaoctets pour son propre tas, atteint 33 mĂ©gaoctets de mĂ©moire rĂ©sidente en comptant l’interprĂ©teur et ses extensions chargĂ©es, et dĂ©marre puis se termine en 20 millisecondes, mesurĂ© avec memory_get_usage(true) et /usr/bin/time. Reproduisez ces mesures sur votre matĂ©riel, et souvenez-vous qu’un worker PHP-FPM partage avec ses frĂšres les pages en lecture seule de l’interprĂ©teur, si bien qu’un worker de plus coĂ»te moins que le chiffre ne le suggĂšre. La configuration de production de Wikimedia, publiĂ©e dans son gestionnaire de tĂąches, fait tourner huit workers PHP-FPM par conteneur MediaWiki, avec un OPcache de 500 mĂ©gaoctets et un cache APCu de 768 mĂ©gaoctets, dans un Ă  deux gigaoctets de mĂ©moire par conteneur.

La limite : le modĂšle shared-nothing n’a ni threads, ni Ă©tat partagĂ© en processus, ni travail en arriĂšre-plan Ă  l’intĂ©rieur d’une requĂȘte. Tout ce qui doit survivre Ă  une requĂȘte, un pool de connexions, un cache chaud, une tĂąche planifiĂ©e, vit dans un autre processus ou un autre systĂšme, et l’architecture autour d’une application PHP en porte la marque. C’est une vraie contrainte, et c’est aussi ce qui rend l’exploitation simple.

Ce que vous pouvez vĂ©rifier vous-mĂȘme

Installez PHP-FPM et un serveur web sur une petite machine virtuelle, activez la page de statut de FPM, et regardez le pool sous un gĂ©nĂ©rateur de charge comme wrk ou ab : le nombre de processus, la mĂ©moire par worker et le temps de requĂȘte sont lĂ , sous vos yeux. Lancez ensuite le mĂȘme hello-world sous FrankenPHP en mode worker et comparez. Un aprĂšs-midi de ce rĂ©gime vous apprend plus sur le runtime que je ne viens de le faire, et les chiffres seront les vĂŽtres.

Un chiffre de dĂ©bit ne veut dire quelque chose qu’une fois que vous savez lequel de ces runtimes l’a produit.