Comment PHP
traite plusieurs requêtes
Une question, un fil rouge : quand mille visiteurs arrivent en même temps, qui fait quoi, et où va la mémoire ?
PHP, tout seul, ne sait faire qu'une chose à la fois : exécuter un script du début à la fin, dans un seul fil. Toute la magie de la « simultanéité » se joue donc autour de PHP — dans le système d'exploitation (processus, threads), dans le serveur web (Nginx, Apache, Caddy) et dans le runtime qui héberge PHP (FPM, FrankenPHP…).
Ce site décompose la chaîne, couche par couche. Les briques de base d'abord (processus, threads, concurrence), puis comment elles s'assemblent dans les vraies architectures PHP.
Le chemin d'une requête
Par où commencer
Processus & Threads
Avant PHP, avant tout serveur, il faut comprendre les deux unités que le système sait faire tourner.
Le processus
Quand tu tapes php index.php, le système crée un processus. Tu lances la commande deux fois → deux processus totalement séparés, chacun avec ses variables. L'un peut planter sans toucher l'autre.
- Mémoire isolée : par défaut, deux processus ne partagent rien.
- Création coûteuse : créer un processus (
fork) demande au système de copier tout un contexte. - Communication compliquée : pour se parler, ils passent par des mécanismes dédiés (sockets, fichiers, signaux) — c'est l'IPC.
Au fait, c'est quoi l'IPC ?
L'image : deux personnes dans deux pièces fermées et insonorisées (les deux processus). Elles ne s'entendent pas. Pour communiquer, il leur faut un dispositif convenu — glisser un mot sous la porte, un interphone, un tube. Ces « dispositifs », côté système, ce sont les mécanismes d'IPC :
- Socket (Unix ou réseau) : un canal bidirectionnel, comme un téléphone entre les deux processus.
- Tube / pipe : un flux à sens unique, la sortie de l'un branchée sur l'entrée de l'autre (le
|du shell, p.ex. cat fichier | grep mot). - Fichier partagé : l'un écrit, l'autre lit — simple mais lent.
- Signal : une petite notification (« arrête-toi », « recharge ta config »), comme kill -HUP.
- Mémoire partagée : une zone de RAM exposée volontairement à plusieurs processus (rapide, mais on retrouve les précautions de synchronisation).
Le thread
L'image du restaurant : le processus est le restaurant entier (avec sa cuisine = la mémoire) ; les threads sont les serveurs qui travaillent en même temps dans cette même cuisine. Créer un serveur de plus coûte bien moins cher que d'ouvrir un nouveau restaurant.
Ce qu'un thread contient vraiment
L'analogie est jolie, mais regardons la mécanique. Un thread, ce n'est pas grand-chose — trois éléments seulement :
1. Un compteur ordinal — l'adresse de la prochaine instruction à exécuter.
2. Un jeu de registres — l'état du processeur à cet instant précis.
3. Une pile — quelques mégaoctets pour les variables locales et la chaîne des appels.
C'est tout. Le code, le tas, les fichiers ouverts, la table des pages : tout ça appartient au processus et n'est pas dupliqué quand tu ajoutes un thread. Voilà pourquoi un thread coûte si peu cher comparé à un processus — tu ne recopies pas le monde, tu ajoutes juste un acteur qui s'y promène.
Cette image dit aussi l'essentiel sur ce qu'un thread garantit : à l'intérieur d'un fil, les instructions se suivent dans l'ordre, et la pile raconte l'histoire de comment tu es arrivé là. C'est exactement l'abstraction qui te permet de continuer à raisonner comme sur une machine séquentielle, alors que la machine réelle ne l'est plus depuis longtemps.
Sous Linux, c'est la même structure
Voici le point qui déroute presque tout le monde : pour le noyau Linux, un processus et un thread sont le même objet. Le noyau ne connaît qu'un type de tâche, qu'il ordonnance uniformément. La différence ne se joue pas sur la nature de la chose, mais sur ce qu'elle partage avec sa voisine.
Tout passe par un seul appel système, clone(), et c'est le jeu d'options passé à cet appel qui décide du résultat :
| Options passées à clone() | Résultat |
|---|---|
CLONE_VM — même espace mémoireCLONE_THREAD — même groupe de tâches | Un thread : la nouvelle tâche voit exactement la même mémoire |
| Aucune de ces options | Un processus : la mémoire est recopiée (en copy-on-write) |
Et fork(), l'appel classique pour créer un processus, n'est rien d'autre qu'un clone() sans partage. Les options ne sont d'ailleurs pas libres : le noyau exige que CLONE_THREAD soit accompagné de CLONE_SIGHAND, qui lui-même exige CLONE_VM. Autrement dit, on ne peut pas être un thread sans partager la mémoire — c'est verrouillé jusque dans l'appel système.
Un processus, côté noyau, c'est un groupe de tâches. Son identifiant — celui que te renvoie
getpid() — est le numéro de son premier thread. Il n'existe pas de processus vide auquel on ajouterait ensuite un fil : le processus doit son existence à son premier thread.
Processus vs thread, côte à côte
| Critère | Processus | Thread |
|---|---|---|
| Mémoire | Isolée, à soi | Partagée avec les autres threads |
| Coût de création | élevé | faible |
| Isolation / sûreté | totale | aucune (race conditions) |
| Communication | Complexe (IPC) | Simple (mémoire commune) |
| Un crash… | …reste confiné | …peut emporter tout le processus |
Multiprocessus vs Multithreading
Deux façons de traiter plusieurs choses à la fois — l'une sûre et gourmande, l'autre légère et délicate.
Multiprocessus
C'est exactement le modèle de PHP-FPM : un pool d'interpréteurs PHP, chacun dans son processus. Une requête = un processus qui s'en occupe entièrement. Comme la mémoire est isolée, si un script plante (segfault, fuite mémoire), il n'emporte que sa requête.
| Multiprocessus (FPM, Apache prefork) | |
|---|---|
| + | Isolation totale : un crash ne tue pas les voisins |
| + | Simple à raisonner et à déboguer ; pas de partage = pas de race condition |
| − | Mémoire : chaque processus recharge tout l'interpréteur et le code |
| − | Plafond de processus vite atteint (RAM) |
Multithreading
Pour faire du vrai multi-threading en PHP, il faut une version « thread-safe » de l'interpréteur (ZTS) et un runtime qui l'exploite : c'est ce que font Swoole et FrankenPHP. Mais pourquoi ce n'est pas le cas par défaut mérite une vraie réponse — on entend souvent « PHP n'est pas fait pour ça », ce qui n'explique rien.
Pourquoi PHP n'est pas threadé par défaut
Ce n'est pas une incapacité technique. Ce sont trois contraintes empilées, dont une seule est vraiment décisive.
| La contrainte | |
|---|---|
| 1 | Le choix est figé à la compilation. PHP se construit soit en NTS (non thread-safe), soit en ZTS. En NTS, les variables globales du moteur sont de vraies globales C : deux threads dans le même interpréteur s'écraseraient mutuellement. Or les distributions packagent NTS, parce que c'est le mode le plus rapide. Le PHP de ton système n'est donc simplement pas « threadable ». |
| 2 | Les extensions — la vraie raison. Le cœur de PHP est correct en ZTS. Les extensions tierces, pas de façon fiable. Il suffit qu'une seule garde une variable statique non protégée pour provoquer des plantages non déterministes sous charge. |
| 3 | FPM n'en avait pas besoin. Un processus maître qui se duplique, des enfants qui prennent les requêtes, l'isolation offerte gratuitement par le matériel. Avec la copie à l'écriture, le coût mémoire reste acceptable. Pourquoi aller chercher les threads ? |
php -i | grep "Thread Safety". Sur un PHP de distribution tu liras disabled. Dans une image FrankenPHP, enabled — parce que FrankenPHP n'a pas le choix, on verra pourquoi à la page FrankenPHP.Le piège : la race condition
Le partage de mémoire est puissant mais dangereux : si deux threads modifient la même variable « en même temps », les écritures peuvent s'écraser. Lance la démo : deux threads font chacun +1 cinq fois sur un compteur partagé. Sans verrou, le total final est faux.
Le vrai prix du partage
La démo ci-dessus montre un compteur qui se trompe, et c'est spectaculaire. Mais le coût réel du partage est plus profond que quelques additions perdues : tu perds le droit de raisonner localement.
En mono-thread, tu peux affirmer une chose très puissante : « entre ces deux lignes, personne n'a touché à cet objet ». Cette phrase est la base de presque tout ce que tu sais sur ton code. Dès qu'un second fil partage la mémoire, elle devient fausse. Tes invariants ne doivent plus seulement tenir au repos : ils doivent tenir à chaque frontière entre deux instructions.
Retiens la formule : un thread, c'est une machine séquentielle imaginaire posée sur un substrat qui ne l'est pas. Tant qu'il n'y en a qu'un, l'illusion est parfaite et gratuite. À plusieurs, c'est à toi de la maintenir.
Concurrence vs Parallélisme
Le mot « simultané » cache deux réalités très différentes. Le simulateur les met côte à côte.
Une formule utile : la concurrence parle de structure (gérer plusieurs choses), le parallélisme parle d'exécution (faire plusieurs choses à la fois). On peut avoir de la concurrence sans parallélisme (1 cœur), et il faut plusieurs cœurs pour du vrai parallélisme.
L'étage matériel : cœurs et « sièges »
Avant d'aller plus loin, il faut désamorcer un piège de vocabulaire. Quand une fiche technique annonce « 12 cœurs / 24 threads », le mot thread n'a presque rien à voir avec le fil d'exécution dont on parle depuis le début. Ce sont deux étages différents.
L'unité d'exécution réelle : un pipeline, ses unités de calcul, son banc de registres, ses caches privés. C'est lui, et lui seul, qui exécute vraiment des instructions.
Un cœur passe énormément de temps à attendre — un accès à la mémoire principale coûte des centaines de cycles pendant lesquels ses unités de calcul ne font rien. Le SMT duplique le minimum nécessaire pour tenir deux contextes en vol, afin que le second vienne boucher les trous du premier.
D'où une conséquence souvent mal comprise : le SMT n'apporte pas un doublement. Le gain est partiel, il dépend entièrement de la nature du travail, et sur un calcul qui sature déjà les unités il peut même être négatif. La bonne image :
Le système d'exploitation, lui, voit simplement un certain nombre de sièges à remplir, et il y installe les tâches prêtes. Et c'est là que nos deux définitions deviennent concrètes, presque arithmétiques :
| Situation | Ce que fait l'ordonnanceur | Le nom |
|---|---|---|
| Plus de tâches prêtes que de sièges | Il les fait tourner par tranches de temps, très vite | Concurrence |
| Autant de sièges que de tâches, ou plus | Elles avancent réellement en même temps | Parallélisme |
Le simulateur ci-dessous met les deux cas côte à côte. À gauche, une seule piste : le temps du cœur est découpé entre les quatre tâches, et tu vois les segments colorés se partager la même barre. À droite, quatre pistes indépendantes. Lance-le et compare surtout les deux horloges.
🧵 CONCURRENCE — 1 cœurt = 0
⚡ PARALLÉLISME — 4 cœurst = 0
Avec un seul cœur, le temps total pour finir les quatre tâches est environ quatre fois plus long : rien n'a disparu, le travail a juste été mis en file. Le parallélisme ne « divise » le temps que s'il y a assez de sièges et que les tâches sont indépendantes.
Et le chargement d'une page web ?
Quand le navigateur charge une page, il réclame plusieurs ressources (CSS, JS, images) « en parallèle ». Mais attention : c'est géré par le navigateur et le serveur HTTP, pas par PHP.
- HTTP/1.1 : ~6 connexions parallèles par domaine maximum.
- HTTP/2 et HTTP/3 : multiplexage — de nombreuses requêtes voyagent dans une seule connexion.
PHP, lui, ne voit que des requêtes individuelles qui lui arrivent une par une. La concurrence côté serveur PHP, c'est l'affaire de FPM ou du runtime — les pages suivantes.
La nature de PHP
PHP est un interpréteur, pas un serveur. Cette phrase explique presque toute la suite.
Les SAPI : les visages de PHP
Un SAPI (Server API) est l'interface par laquelle PHP est invoqué. Le même langage, plusieurs portes d'entrée :
| SAPI | Contexte | Exemple |
|---|---|---|
cli | Ligne de commande | php script.php |
fpm-fcgi | PHP-FPM via FastCGI | Nginx → PHP-FPM |
apache2handler | Module dans Apache | Apache + mod_php |
embed | Embarqué dans un autre programme | FrankenPHP (dans Caddy/Go) |
Le cycle de vie « classique » d'une requête
# Au démarrage du worker FPM — UNE SEULE FOIS : 0. PHP lit php.ini, initialise le moteur et les extensions # MINIT # Puis, à CHAQUE requête (le worker reste vivant) : 1. Navigateur ─HTTP→ Nginx / Apache # reçoit la connexion 2. Serveur web ─FastCGI→ PHP-FPM # transmet la requête 3. PHP charge le script (autoloader, bootstrap Symfony…) 4. PHP exécute et produit le HTML / JSON 5. Fin de requête : variables et état jetés, mémoire libérée # le worker NE meurt PAS 6. Réponse ─HTTP→ Navigateur
Le point crucial : à chaque requête, l'application (autoloader, conteneur, configs) est rebootée puis détruite — mais le worker, lui, ne meurt pas et ne relit ni php.ini ni les extensions (ça, c'est une fois au démarrage). Pour un gros framework (Symfony, Laravel), reconstruire le conteneur, l'autoloader et les configs à chaque hit a un coût réel.
FastCGI
Comment le serveur web parle à PHP. Pas une « mise en binaire » du HTTP : un protocole à part entière.
D'où ça vient : le problème de CGI
Au début du web dynamique, il y avait CGI. Le principe : à chaque requête, le serveur démarre un nouveau processus, lui fait exécuter le script, récupère le résultat, puis tue le processus. Démarrer un interpréteur à chaque visiteur, des milliers de fois par seconde, est un gâchis énorme.
FastCGI (1996) corrige ça avec une idée simple : garder les processus vivants et les réutiliser. On démarre un petit groupe d'interpréteurs une fois, et chaque requête est confiée à un processus déjà « chaud ».
🐌 CGI — un processus par requêtedémarrages : 0
⚡ FastCGI — pool réutilisédémarrages : 4
Le tuyau, et ce qui voyage dedans
Le socket de domaine Unix (ex. /run/php/php8.2-fpm.sock) est juste un fichier spécial servant de point de rendez-vous entre deux programmes — exactement comme docker.sock. Ce qui change, c'est le protocole qui circule dedans. Le détail ci-dessous montre exactement ce que Nginx envoie et ce que PHP-FPM renvoie.
La nuance qui fait tout comprendre
On dit souvent « le serveur convertit le HTTP en binaire ». C'est trompeur : le HTTP circule déjà sous forme d'octets sur le réseau. Le serveur ne prend pas le texte HTTP brut pour le pousser tel quel dans le tuyau. Il :
- parse la requête HTTP entrante,
- en extrait les informations utiles (méthode, URL, en-têtes, corps),
- reconstruit des paramètres FastCGI structurés (
REQUEST_METHOD,QUERY_STRING,SCRIPT_FILENAME…), - les sérialise dans des messages binaires FastCGI (les « records »).
PHP-FPM décode ensuite ces records et reconstitue les variables que tu connais : $_SERVER, $_GET, $_POST… C'est pourquoi le mieux est de voir FastCGI comme un protocole RPC spécialisé : le serveur web ne « transmet » pas une requête, il appelle PHP-FPM en lui disant « exécute ce script avec ces paramètres-là ».
# En texte, côté HTTP, Nginx reçoit ceci : GET /index.php?id=123 HTTP/1.1 Host: example.com User-Agent: Chrome # Il ne le recopie PAS. Il le transforme en paramètres FCGI : REQUEST_METHOD = "GET" SCRIPT_FILENAME = "/var/www/index.php" QUERY_STRING = "id=123" HTTP_HOST = "example.com" # …puis sérialise tout ça en records binaires dans le socket.
# Côté Nginx, brancher le tuyau tient en deux lignes : location ~ \.php$ { include fastcgi_params; # mappe HTTP → variables FCGI fastcgi_pass unix:/run/php/php8.2-fpm.sock; # le tuyau }
PHP-FPM : le pool
FPM = FastCGI Process Manager. Il maintient un pool de processus PHP prêts à travailler. Joue avec.
Chaque worker traite une seule requête à la fois, du début à la fin. Envoie des requêtes, change le mode, et regarde la file d'attente se remplir puis se vider.
Les trois modes pm
Le mode décide quand les workers existent (dans www.conf). Change-le dans le simulateur : en ondemand les slots restent vides tant qu'aucune requête n'arrive ; en static tous les workers sont là d'emblée.
| Mode | Comportement | Pour quoi |
|---|---|---|
static | Tous les workers (pm.max_children) démarrent et restent en vie, occupés ou non. | Trafic élevé et constant. Prévisible, mais réserve la RAM en permanence. |
dynamic | Démarre avec pm.start_servers, en crée d'autres à la demande jusqu'à max_children, détruit les inactifs en trop. | Le défaut recommandé. Bon compromis (Symfony, Laravel…). |
ondemand | Aucun worker au repos. Chaque requête en crée un, détruit après pm.process_idle_timeout. | Beaucoup de petits sites sur un serveur. Zéro gâchis RAM, latence au 1er hit. |
Quand tout sature → 502
Workers tous occupés → les requêtes entrent dans la file d'attente. Si la file est pleine (ou qu'une requête dépasse request_terminate_timeout), FPM renvoie une 502 Bad Gateway au serveur web.
D'où l'enjeu de calibrer pm.max_children : trop peu → des 502 sous charge ; trop → RAM épuisée (chaque worker ≈ 30–60 Mo). Règle de pouce : max_children ≈ RAM dispo ÷ RAM par worker.
Pourquoi la RAM, et pas le nombre de cœurs
Le réflexe naturel serait de calquer le nombre de workers sur le nombre de cœurs : douze cœurs, douze workers. C'est un bon réflexe pour du calcul pur — et un mauvais réflexe pour PHP.
Regarde ce que fait vraiment un worker qui traite une requête Symfony : il interroge la base, attend la réponse, appelle Redis, attend, appelle une API tierce, attend encore. L'essentiel de sa vie se passe bloqué sur une entrée-sortie. Et un worker bloqué ne consomme pas de processeur : il libère son siège, que l'ordonnanceur donne aussitôt à quelqu'un d'autre.
pm.max_children à 40 sur une machine à 12 cœurs est parfaitement sain pour une application web classique. Ce qui te limite, ce n'est pas le CPU — c'est la mémoire, parce que chaque worker en réserve, occupé ou non.C'est aussi pour cette raison que la règle de pouce ci-dessus parle de RAM et jamais de cœurs. Le corollaire : si tes workers ne sont pas bloqués sur des entrées-sorties mais occupés à calculer, alors le raisonnement s'inverse et le nombre de cœurs redevient la borne. Mesure avant de choisir.
Runtimes & architectures
Quatre montages classiques. Pour chacun : qui parle HTTP, comment la concurrence est gérée, et où va la mémoire.
Chemin d'une requête
Occupation mémoire (schématique)
FrankenPHP : pool de threads
FPM met en pool des processus. FrankenPHP met en pool des threads, dans un seul processus. Ça change tout — voici pourquoi.
FrankenPHP fait bien du scaling dynamique « façon FPM », mais l'unité n'est pas la même. Le programme frankenphp est un binaire écrit en Go (bâti sur le serveur Caddy) dans lequel l'interpréteur PHP est embarqué (via cgo). Ce programme est multithreadé, et ce sont ses threads qui exécutent ton code PHP.
La correction du modèle mental
Ce que le processeur voit, lui
Il y a ici une correction à faire, et elle vaut d'être posée noir sur blanc parce qu'elle défait une confusion très répandue.
Un processus ne s'exécute pas lui-même. C'est un contenant : de la mémoire, des fichiers ouverts, des ressources. Ça ne calcule rien. Ce qui calcule, c'est le thread qui se trouve dedans. Quand on dit « ce processus tourne sur le cœur 3 », la formulation exacte serait « l'unique thread de ce processus tourne sur le cœur 3 ».
Applique ça à nos deux modèles, et le résultat surprend :
| PHP-FPM | FrankenPHP | |
|---|---|---|
| Contenants | 12 processus | 1 processus |
| Threads par contenant | 1 chacun | 12 |
| Ce que le processeur voit | 12 threads | 12 threads |
| Débit brut | le même | |
Dans les deux cas, exactement douze threads occupent douze sièges. L'ordonnanceur ne fait aucune différence entre les deux situations — il n'a même pas besoin de savoir à quel processus chaque thread appartient. La différence n'est donc jamais dans le placement sur les cœurs.
Elle est entièrement dans ce qu'il y a derrière ces threads. Chez FPM : douze threads répartis dans douze contenants séparés, chacun sa mémoire isolée. Chez FrankenPHP : douze threads dans un seul contenant, qui se partagent tout. D'où trois écarts concrets — et la mémoire est le plus visible, c'est l'objet du curseur ci-dessous :
| Ce qui change vraiment | Pourquoi |
|---|---|
| L'empreinte mémoire | Un seul exemplaire de l'interpréteur et du code compilé, au lieu de N |
| Le rayon d'un plantage | Chez FPM le contenant qui tombe emporte son unique thread, les onze autres continuent. Chez FrankenPHP il n'y a qu'un contenant : s'il tombe, les douze threads disparaissent ensemble. |
| La chaleur des caches | Des threads du même processus partagent les caches, donc les données communes (dont l'OPcache) restent chaudes |
Pourquoi FrankenPHP n'avait pas le choix
On présente souvent les threads de FrankenPHP comme un choix de performance. C'est plutôt une contrainte subie, et le raisonnement est élégant.
FrankenPHP embarque l'interpréteur dans Caddy, un serveur écrit en Go. Or le runtime Go est multi-threadé par construction : ses tâches légères sont réparties sur plusieurs threads système dès le démarrage. Et là, un mur : dupliquer un processus déjà multi-threadé est inutilisable. La documentation de fork() est sans ambiguïté — l'enfant est créé avec un seul thread, celui qui a appelé fork(), alors que tout l'espace mémoire est recopié « y compris l'état des mutex ». Traduction : l'enfant hérite de verrous tenus par des threads qui n'existent pas chez lui. Le modèle FPM est donc fermé d'emblée.
--enable-zts. Rien à voir avec un arbitrage de vitesse.Visualise-le : 10 processus isolés vs 10 threads partagés
Bouge le curseur : à gauche FPM duplique tout l'interpréteur par processus ; à droite FrankenPHP charge l'app une seule fois et n'ajoute que des threads légers.
🧱 PHP-FPM — pool de processus
🧵 FrankenPHP — pool de threads
memory_limit). Mais la tendance est exacte : les threads partagent l'OPcache et le code compilé au lieu d'en avoir N copies — d'où une RAM bien plus basse.Le scaling : num_threads & max_threads
L'équivalent du pm = dynamic de FPM se règle dans le bloc frankenphp. Le scaling est piloté par la profondeur de la file : quand tous les threads sont occupés, les requêtes sont mises en file ; si la file dépasse un seuil, FrankenPHP démarre des threads supplémentaires jusqu'à max_threads.
| Concept | PHP-FPM | FrankenPHP |
|---|---|---|
| Unité | processus | thread OS |
| Démarrage initial | pm.start_servers | num_threads / num (worker) |
| Plafond (autoscale) | pm.max_children | max_threads |
| Mode fixe | pm = static | max_threads = num_threads |
| Mode élastique | pm = dynamic | max_threads > num_threads |
| File pleine → | 502 immédiat | attente, puis rejet si max_wait_time dépassé |
# Caddyfile — pool élastique en mode worker frankenphp { num_threads 4 # threads PHP au démarrage (≈ pm.start_servers) max_threads 20 # plafond, montée auto sous charge (≈ pm.max_children) } example.com { php_server { root /app/public worker { file index.php num 4 # instances du worker tenues chaudes en mémoire } } }
max_threads auto estime le plafond depuis le memory_limit de ton php.ini (sinon il retombe sur 2× num_threads). La doc prévient qu'auto peut fortement sous-estimer : règle de stabilité recommandée → num_threads × memory_limit < RAM disponible, et fais des tests de charge.Deux modes, et « thread = worker » seulement dans l'un
| Mode | Ce que font les threads | Analogie FPM |
|---|---|---|
| classic (défaut) | Traitent les requêtes mais réinitialisent l'état PHP après chacune. Drop-in remplaçant de FPM/mod_php. | FPM, mais en threads |
| worker | Chaque thread charge le script une seule fois, garde l'app bootstrapée en mémoire, et boucle sur les requêtes. Là, thread = worker persistant. | Worker FPM, mais persistant et chaud |
Donc la formule juste : FrankenPHP est un programme Go multithreadé ; en mode worker, chacun de ces threads est un worker qui garde ton app en mémoire. En mode classique, on parle juste de « threads » (pas de notion de processus qui reste chaud entre requêtes).
Les contreparties du partage
Comme tout est dans un seul processus à mémoire partagée, FrankenPHP doit gérer ce que l'isolation de FPM offrait gratuitement :
- PHP doit être compilé en ZTS (Zend Thread Safe) : obligatoire dès que plusieurs threads exécutent PHP dans le même espace mémoire. Pas requis pour FPM. Le mécanisme mérite un mot, parce qu'il éclaire tout le reste : puisque la mémoire est commune, chaque thread reçoit ses propres « globales » du moteur par une indirection — le processeur dispose d'un registre qui pointe, pour chaque thread, vers un bloc de données qui lui est réservé. L'isolement n'est donc plus donné par le matériel : il est reconstruit logiciellement.
- Ce sont de vrais threads OS, pas de simples goroutines Go : le mode thread-safe de PHP (TSRM/ZTS) exige que chaque contexte d'exécution PHP tourne sur son propre thread système dédié. FrankenPHP gère donc un pool de threads OS et y répartit les requêtes.
- Fuites mémoire : en mode worker, l'app vit longtemps, donc une fuite s'accumule. Parades :
max_requests(redémarre un thread après N requêtes) et redémarrage gracieux des workers. - Robustesse :
max_consecutive_failuresévite qu'un worker qui crashe en boucle (typo, fatal) ne fasse tomber le serveur ; backoff exponentiel sur les redémarrages.
max_threads), là où FPM fait varier le nombre de processus (pm.max_children). Même idée de pool élastique, deux primitives différentes.Synthèse
Le tableau de bord, et le réflexe à garder pour chaque concept.
Comparatif des architectures
| Architecture | Serveur HTTP | Concurrence PHP | Mémoire | Bootstrap | Usage |
|---|---|---|---|---|---|
| Apache + mod_php | Apache (prefork) | Multiprocessus | élevée | chaque requête | legacy |
| Apache + PHP-FPM | Apache (event) | Multiprocessus | moyenne | chaque requête* | migration |
| Nginx + PHP-FPM | Nginx (async) | Multiprocessus | moyenne | chaque requête* | standard |
| FrankenPHP | Caddy (intégré) | Threads OS (pool ZTS) | faible | une fois | moderne |
| Swoole / RoadRunner | intégré | Coroutines / workers | faible | une fois | haute perf |
* Avec OPcache, le bytecode est mis en cache : le coût du bootstrap chute, mais l'état applicatif est tout de même reconstruit à chaque requête.
Une phrase par concept
| Processus | Programme en exécution, mémoire isolée. |
| Thread | Fil dans un processus, mémoire partagée. |
| Multiprocessus | Plusieurs processus → plus de requêtes en parallèle, mémoire isolée mais lourde. |
| Multithreading | Plusieurs threads → plus léger, mais attention à l'état partagé. |
| Concurrence | Plusieurs tâches progressent (pas forcément au même instant). |
| Parallélisme | Plusieurs tâches au même instant (multi-cœurs). |
| FastCGI | Protocole RPC entre serveur web et interpréteur ; réutilise les processus. |
| Runtime | L'environnement qui exécute PHP et lui amène les requêtes. |
| Siège CPU | Ce qu'occupe un thread — jamais un processus. 12 processus mono-thread et 12 threads d'un même processus se valent pour l'ordonnanceur. |
Guide de choix express
- Site classique, trafic normal → Nginx + PHP-FPM (mode
dynamic). Le standard, fiable. - Hébergement mutualisé, beaucoup de petits sites → FPM en
ondemand. - Recherche de performance max, API à fort débit → FrankenPHP (worker mode) ou Swoole/RoadRunner.
- Vieux projet, contrainte historique → Apache + mod_php, en sachant que c'est le moins efficient.