← Tous les cours / PHP — Concurrence & Runtimes Cours
00 — VUE D'ENSEMBLE

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

Client
Navigateur
HTTP
Serveur web
Nginx / Apache
FastCGI
Runtime
PHP-FPM
exécute
Code
PHP / Symfony

Par où commencer

01 — LES BRIQUES DE BASE

Processus & Threads

Avant PHP, avant tout serveur, il faut comprendre les deux unités que le système sait faire tourner.

Le processus

Un processus, c'est un programme en cours d'exécution, avec sa propre mémoire isolée, ses propres ressources et son propre identifiant (PID). C'est une bulle étanche : ce qui se passe dedans n'affecte pas les autres.

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.

PID 1234
Processus A
mémoire ████
isolés
PID 1235
Processus B
mémoire ████

Au fait, c'est quoi l'IPC ?

IPC = Inter-Process Communication, la « communication entre processus ». Comme chaque processus vit dans sa bulle mémoire étanche, il ne peut pas simplement lire les variables d'un autre. Pour échanger des données, il doit passer par un canal explicite que le système d'exploitation met à disposition : c'est ça, 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 :

🔌Tu en as déjà croisé un sans le nommer : le socket Unix entre Nginx et PHP-FPM (page FastCGI) est précisément de l'IPC. Deux processus séparés — le serveur web et l'interpréteur — qui se parlent à travers un canal dédié. C'est pour cela qu'on dit que la communication entre processus est « compliquée » comparée aux threads : ces derniers, partageant la même mémoire, n'ont besoin d'aucun de ces mécanismes pour échanger.

Le thread

Un thread (fil d'exécution) vit à l'intérieur d'un processus. Plusieurs threads d'un même processus partagent la même mémoire et peuvent s'exécuter en parallèle.

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 :

L'anatomie d'un thread
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.

📖L'image la plus juste : un livre avec plusieurs lecteurs. Même texte, même bibliothèque — mais chacun son marque-page et son carnet de notes. Le marque-page, c'est le compteur ordinal. Le carnet, c'est la pile. Le livre, lui, n'existe qu'en un seul exemplaire.

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émoire
CLONE_THREAD — même groupe de tâches
Un thread : la nouvelle tâche voit exactement la même mémoire
Aucune de ces optionsUn 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.

Le processus, redéfini
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.
La conséquence directe : passer d'un thread à l'autre dans le même processus est bon marché — on sauvegarde des registres, on en restaure d'autres, on saute. La table des pages ne change pas. Passer d'un processus à l'autre oblige en plus à changer d'espace d'adressage, et c'est là que le surcoût apparaît. Même ordonnanceur, deux tarifs.

Processus vs thread, côte à côte

CritèreProcessusThread
MémoireIsolée, à soiPartagée avec les autres threads
Coût de créationélevéfaible
Isolation / sûretétotaleaucune (race conditions)
CommunicationComplexe (IPC)Simple (mémoire commune)
Un crash……reste confiné…peut emporter tout le processus
🔑Retenir : isolation et sûreté d'un côté (processus), légèreté et partage de l'autre (threads). Tout le reste de ce site découle de ce compromis.
02 — METTRE PLUSIEURS UNITÉS AU TRAVAIL

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

Lancer plusieurs processus indépendants, chacun dans sa bulle mémoire, pour traiter plusieurs tâches en même temps.

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

Plusieurs threads dans un même processus, partageant la mémoire. Plus léger : on ne duplique pas l'interpréteur, on ajoute juste des fils.

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
1Le 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 ».
2Les 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.
3FPM 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 ?
📜Le précédent qui a laissé des traces : la documentation officielle de PHP est explicite sur le sujet — elle recommande de ne pas utiliser un MPM threadé d'Apache en production avec mod_php, et de rester sur prefork. La raison invoquée est précisément celle du point 2 : avec un Apache threadé, PHP doit être compilé en ZTS, et « toutes les extensions ne seront pas disponibles ». La prudence sur les threads est donc documentée, pas folklorique.
🔍Vérifie-le toi-même : 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.

compteur = 0
Attendu : 10. Choisis un mode et lance.
La démo est illustrative (l'entrelacement est scénarisé), mais le phénomène est réel : sans synchronisation (mutex, opérations atomiques), des incréments se perdent. C'est la raison pour laquelle PHP a longtemps évité les threads.
Idée maîtresse : le multithreading ne rend pas une requête plus rapide. Il permet de traiter plus de requêtes à la fois, avec moins de mémoire que le multiprocessus — au prix de la prudence sur l'état partagé.

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.

🧠C'est là que réside la difficulté du parallélisme — pas dans la mécanique des verrous. Poser un mutex est facile ; savoir quels invariants protéger, et à quels endroits, l'est beaucoup moins.

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.

03 — « EN MÊME TEMPS », VRAIMENT ?

Concurrence vs Parallélisme

Le mot « simultané » cache deux réalités très différentes. Le simulateur les met côte à côte.

La concurrence : plusieurs tâches progressent sur une même période, mais pas forcément au même instant physique. Un seul cœur alterne très vite entre elles (context switching). Illusion de simultanéité.
Le parallélisme : plusieurs tâches s'exécutent physiquement au même instant, sur plusieurs cœurs CPU. Vraie simultanéité.

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.

Le cœur
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.
Le thread matériel (SMT)
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 :

💡Un thread matériel n'est pas un cœur. C'est un siège depuis lequel on peut soumettre du travail au cœur. Sur un processeur « 12 cœurs / 24 threads », il y a 24 sièges mais seulement 12 machines pour travailler.

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 :

SituationCe que fait l'ordonnanceurLe nom
Plus de tâches prêtes que de siègesIl les fait tourner par tranches de temps, très viteConcurrence
Autant de sièges que de tâches, ou plusElles avancent réellement en même tempsParallé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.

4 tâches identiques à exécuter de chaque côté

🧵 CONCURRENCE — 1 cœurt = 0

Le cœur jongle entre les tâches. Toutes avancent… mais chacune son tour.

⚡ PARALLÉLISME — 4 cœurst = 0

Chaque cœur prend une tâche. Tout avance vraiment en même temps.
Tâche 1 Tâche 2 Tâche 3 Tâche 4

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.

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.

04 — CE QU'EST PHP, EXACTEMENT

La nature de PHP

PHP est un interpréteur, pas un serveur. Cette phrase explique presque toute la suite.

PHP, seul, ne sait pas recevoir une requête HTTP. Il lui faut un serveur web devant pour accepter les connexions réseau, et un runtime / SAPI qui décide comment l'appeler et lui passer la requête.

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 :

SAPIContexteExemple
cliLigne de commandephp script.php
fpm-fcgiPHP-FPM via FastCGINginx → PHP-FPM
apache2handlerModule dans ApacheApache + mod_php
embedEmbarqué dans un autre programmeFrankenPHP (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.

💡OPcache atténue ce coût en gardant en mémoire le bytecode compilé des fichiers PHP (plus besoin de re-parser le code source à chaque fois). Mais l'état de l'application (objets, conteneur) est quand même reconstruit. C'est précisément ce que le mode worker de FrankenPHP/Swoole supprime — voir la page Runtimes.
05 — LE TUYAU & LE PROTOCOLE

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

Chaque requête naît un processus puis le tue. Le compteur explose.

⚡ FastCGI — pool réutilisédémarrages : 4

4 processus démarrés une fois. Ils s'allument, traitent, se rendorment.

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.

Serveur web
Nginx
unix:/run/php/php-fpm.sock
Interpréteur
PHP-FPM
→ Aller : records FCGI_PARAMS + FCGI_STDIN
SCRIPT_FILENAME/var/www/app/public/index.php
REQUEST_METHODPOST
REQUEST_URI/checkout?cart=42
QUERY_STRINGcart=42
HTTP_HOSTmonsite.fr
HTTP_COOKIEPHPSESSID=8f3a…
STDIN (corps){"qty":2,"coupon":"NOEL"}
← Retour : record FCGI_STDOUT
Status200 OK
Content-Typetext/html; charset=utf-8
Body<!DOCTYPE html>…la page générée…</html>

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 :

  1. parse la requête HTTP entrante,
  2. en extrait les informations utiles (méthode, URL, en-têtes, corps),
  3. reconstruit des paramètres FastCGI structurés (REQUEST_METHOD, QUERY_STRING, SCRIPT_FILENAME…),
  4. 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.
Navigateur
HTTP
Nginx / Apache / Caddy / Traefik
FastCGI (RPC)
PHP-FPM
exécute
Interpréteur PHP
🌍FastCGI n'a rien de spécifique à PHP. Historiquement il servait à parler à des applications dans d'autres langages, et plusieurs serveurs le parlent (Nginx, Apache, Caddy, Traefik, lighttpd…). Aujourd'hui, son usage de loin le plus répandu reste PHP-FPM.
# 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
}
06 — LE GESTIONNAIRE D'OUVRIERS

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.

💡Clique sur un worker occupé (ambre) pour terminer sa requête à la main. Si la file déborde, tu verras le 502.
0
occupés
0
en file
0
servies
0
erreurs 502
FILE D'ATTENTE (backlog)0 / 8
Pool prêt. Envoie une requête.
⚠ 502 Bad Gateway — file d'attente pleine

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.

ModeComportementPour quoi
staticTous 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.
dynamicDé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…).
ondemandAucun 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.

📐La conséquence pratique : un 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.

07 — LES VRAIES ARCHITECTURES

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)

⚙️Petit point sur la concurrence du serveur web lui-même : Apache prefork ouvre un processus par connexion (simple, gourmand). Nginx est événementiel : quelques processus gèrent des milliers de connexions de façon asynchrone (très léger). C'est pourquoi le couple Nginx + FPM domine pour servir beaucoup de trafic.
08 — LE MODÈLE THREADÉ

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 n'est pas « un seul worker avec plusieurs threads ». C'est : plusieurs workers, et chaque worker EST un thread, tous logés dans un seul processus (le binaire Go/Caddy). Chaque thread traite une seule requête à la fois — exactement comme un worker FPM traite une requête à la fois. La seule différence, c'est l'unité : thread au lieu de processus.

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.

🎯Ce qui occupe un siège dans le processeur, c'est toujours un thread. Jamais un processus.

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-FPMFrankenPHP
Contenants12 processus1 processus
Threads par contenant1 chacun12
Ce que le processeur voit12 threads12 threads
Débit brutle 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 vraimentPourquoi
L'empreinte mémoireUn seul exemplaire de l'interpréteur et du code compilé, au lieu de N
Le rayon d'un plantageChez 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 cachesDes 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.

🔗La chaîne est mécanique : runtime Go multi-threadé → threads obligatoires → PHP compilé en ZTS obligatoire. C'est bien ce que fait FrankenPHP, dont la procédure de compilation exige explicitement l'option --enable-zts. Rien à voir avec un arbitrage de vitesse.
Et pourquoi ça ne marchait pas avant : le surcoût du mode thread-safe a beaucoup baissé avec PHP 8, mais surtout le mode worker change l'économie du problème. Payer ce surcoût une fois par thread au démarrage, au lieu d'une fois par requête, le rend négligeable. Le même pari sur PHP 5 aurait été déraisonnable.

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

Chaque requête = un processus isolé, avec sa propre copie de tout.

🧵 FrankenPHP — pool de threads

Un seul processus. L'app est résidente et partagée ; on n'ajoute que des threads.
📏Chiffres illustratifs (l'empreinte réelle dépend de ton app et de 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.

ConceptPHP-FPMFrankenPHP
Unitéprocessusthread OS
Démarrage initialpm.start_serversnum_threads / num (worker)
Plafond (autoscale)pm.max_childrenmax_threads
Mode fixepm = staticmax_threads = num_threads
Mode élastiquepm = dynamicmax_threads > num_threads
File pleine →502 immédiatattente, 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

ModeCe que font les threadsAnalogie 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
workerChaque thread charge le script une seule fois, garde l'app bootstrapée en mémoire, et boucle sur les requêtes. , 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 :

🔁Le détail qui boucle la boucle : l'unité d'échelle de FrankenPHP est le thread, pas le processus. Son « scaling dynamique » fait donc monter/descendre le nombre de threads (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.
🧩Le fil rouge de tout le cours : le modèle mental de PHP a toujours été le share-nothing — une requête, un monde neuf, qu'on jette après. FPM réalise ça honnêtement, en donnant à chaque requête un vrai monde séparé. ZTS fait autre chose : il garde un seul monde et reconstruit l'illusion de mondes séparés par indirection. PHP n'a jamais abandonné le share-nothing — il l'émule à l'intérieur d'un espace d'adressage partagé. C'est pourquoi le partage réel y reste l'exception, autorisée seulement là où c'est sûr.
09 — TOUT RASSEMBLER

Synthèse

Le tableau de bord, et le réflexe à garder pour chaque concept.

Comparatif des architectures

ArchitectureServeur HTTPConcurrence PHPMémoireBootstrapUsage
Apache + mod_phpApache (prefork)Multiprocessusélevéechaque requêtelegacy
Apache + PHP-FPMApache (event)Multiprocessusmoyennechaque requête*migration
Nginx + PHP-FPMNginx (async)Multiprocessusmoyennechaque requête*standard
FrankenPHPCaddy (intégré)Threads OS (pool ZTS)faibleune foismoderne
Swoole / RoadRunnerintégréCoroutines / workersfaibleune foishaute 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

ProcessusProgramme en exécution, mémoire isolée.
ThreadFil dans un processus, mémoire partagée.
MultiprocessusPlusieurs processus → plus de requêtes en parallèle, mémoire isolée mais lourde.
MultithreadingPlusieurs threads → plus léger, mais attention à l'état partagé.
ConcurrencePlusieurs tâches progressent (pas forcément au même instant).
ParallélismePlusieurs tâches au même instant (multi-cœurs).
FastCGIProtocole RPC entre serveur web et interpréteur ; réutilise les processus.
RuntimeL'environnement qui exécute PHP et lui amène les requêtes.
Siège CPUCe 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

🧭Le fil rouge : on échange toujours isolation/simplicité contre légèreté/vitesse. FPM penche vers la sûreté ; FrankenPHP/Swoole vers la performance, au prix d'une vigilance sur l'état partagé entre requêtes.