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.
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
PHP n'est pas multi-threadé par défaut, et c'est un choix volontaire de sûreté. Chaque script s'exécute dans un seul fil, du haut vers le bas. Pour faire du vrai multi-threading il faut une version « thread-safe » de PHP (ZTS) et un runtime qui l'exploite : c'est ce que font Swoole et 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.
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.
🧵 CONCURRENCE — 1 cœurt = 0
⚡ PARALLÉLISME — 4 cœurst = 0
Note l'horloge : avec un seul cœur, le temps total pour finir les 4 tâches est ~4× plus long. Le parallélisme ne « divise » le temps que s'il y a assez de cœurs 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.
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.
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
Thread = processus ? Non, vraie différence
Ce n'est pas qu'une question de vocabulaire : ce sont deux primitives différentes du système d'exploitation.
| Processus (FPM) | Thread (FrankenPHP) | |
|---|---|---|
| Mémoire | Espace isolé, à soi | Partagé avec les autres threads du processus |
| Si l'un plante | Les autres survivent | Peut corrompre/emporter le processus |
| Coût | lourd (RAM, context switch) | léger |
| Copies de l'interpréteur | N copies indépendantes | 1 seule, partagée |
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.
- 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 / goroutines | 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. |
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.