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 Ă©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.