10 Minutes read

Plongée dans le Zend Memory Manager : Comprendre la gestion mémoire interne de PHP

La gestion de la mémoire est un aspect fondamental de tout langage de programmation performant. Dans le cas de PHP, cette responsabilité revient au Zend Memory Manager (ZMM), un composant sophistiqué du moteur Zend qui équipe PHP.

Cet article explore les mécanismes internes de ce gestionnaire de mémoire en analysant son implémentation dans le fichier zend_alloc.c et plus particulièrement les “small allocations”.

Photo by Benjamin Voros on Unsplash

Besoin custom ? Allocateur custom !

Avant de plonger dans les détails techniques, il est légitime de se demander : pourquoi PHP n’utilise-t-il pas simplement les fonctions standard d’allocation mémoire du système (malloc, free, etc.) ?

La réponse tient en plusieurs points :

  1. Performance : Les applications PHP créent et détruisent constamment des objets, variables et structures. Un système optimisé pour ces opérations spécifiques est beaucoup plus efficace.
  2. Cycle de vie des requêtes : PHP fonctionne généralement sur un modèle où chaque requête web démarre avec un état mémoire propre et libère toute sa mémoire à la fin. Ce modèle particulier permet des optimisations spécifiques.
  3. Détection des fuites mémoire : Un gestionnaire personnalisé peut intégrer des outils pour suivre les allocations et identifier les fuites.
  4. Contrôle de la fragmentation : En gérant directement les allocations, PHP peut minimiser la fragmentation mémoire, un problème courant dans les applications à longue durée de vie.

Ces contraintes ont donc donné lieu à la création du Zend Memory Manager (ZMM). Dmitry Stogov (dstogov) s’est principalement attelé à ce chantier et une bonne partie des améliorations du ZMM proviennent de lui.

Voyons voir de quoi il en retourne…

Architecture fondamentale du Zend Memory Manager

Le ZMM est structuré autour de quelques concepts essentiels :

Architecture technique du ZMM

Afin de minimiser les appels directs aux fonctions d’allocation du système et réduire la fragmentation, le ZMM organise la mémoire en trois niveaux (qui sont également 3 approches dans le développement du Memory Management) :

  • Memory Pools : Des zones de mémoire de grande taille (chunks) sont préallouées pour réduire les appels à malloc.
  • Bucketing Memory : Ces chunks sont découpés en pages, puis les pages sont subdivisées en buckets (ou bins) qui regroupent des blocs de taille fixe.
  • Fixed Size Allocation : Chaque allocation de petite taille est réalisée sur un bloc prédéfini, simplifiant ainsi la gestion des demandes fréquentes.

🤓 Ne cherchez pas trop de théorie sur le “bucketing memory”, il s’agit vraiment de use cases très spécifiques liés à la programmation système et à l’allocation de mémoire dans certains contextes. Internet semble un peu avare sur le sujet ! Pour les plus curieux, voici quelques papiers fondateurs des “bucketized allocations”.

Un focus sur la Heap

Au cœur du ZMM se trouve le concept de “heap” . Dans le code, on peut repérer la structure _zend_mm_heap qui représente l'état global du gestionnaire de mémoire (extrait simplifié) :

struct _zend_mm_heap {
zend_mm_storage *storage;
size_t size; /* current memory usage */
size_t peak; /* peak memory usage */
uintptr_t shadow_key; /* free slot shadow ptr xor key */
zend_mm_free_slot *free_slot[ZEND_MM_BINS]; /* free lists for small sizes */
size_t real_size; /* current size of allocated pages */
size_t real_peak; /* peak size of allocated pages */
size_t limit; /* memory limit */
int overflow; /* memory overflow flag */

zend_mm_huge_list *huge_list; /* list of huge allocated blocks */

zend_mm_chunk *main_chunk;
zend_mm_chunk *cached_chunks; /* list of unused chunks */
int chunks_count; /* number of allocated chunks */
int peak_chunks_count; /* peak number of allocated chunks for current request */
int cached_chunks_count; /* number of cached chunks */
double avg_chunks_count; /* average number of chunks allocated per request */
int last_chunks_delete_boundary; /* number of chunks after last deletion */
int last_chunks_delete_count; /* number of deletion over the last boundary */

pid_t pid;
zend_random_bytes_insecure_state rand_state;
};

La Heap principale contient des informations sur la mémoire totale allouée, les pics d’utilisation, et d’autres métriques utiles pour superviser l’utilisation de la mémoire.

La notion de “memory_limit” est donc monitorée par la Heap.

Stratégie des “Chunks” et “Pages”

Le ZMM utilise une approche hiérarchique pour gérer la mémoire :

  1. Chunks : Ce sont de grands blocs de mémoire (généralement 2MB) alloués directement auprès du système d’exploitation.
  2. Pages : Chaque chunk est divisé en pages (généralement 4KB), qui servent d’unités d’allocation intermédiaires.
  3. Arènes : Un allocateur d’arène est un système où un grand bloc de mémoire contiguë (l’arène) est pré-alloué, puis subdivisé selon les besoins. Les allocations réalisées dans une arène ne sont pas libérées individuellement. Au lieu de cela, toute l’arène est vidée en une seule opération (par exemple, lors de la fin d’une requête PHP “request-bound”), ce qui élimine le besoin de suivre chaque allocation et diminue la surcharge de gestion de la mémoire. Dans le ZMM, le concept d’arène est implémenté à travers les chunks et les pages. Cf : zend_arena.h
  4. Petites allocations : Pour les demandes de petite taille, ZMM utilise des “free lists” ou “free slots lists” (listes chaînées d’emplacements libres) classées par taille.
  5. Grandes allocations : les demandes de grandes tailles ( ≥ 2MB) sont directement allouées via mmap et sont donc déléguées à l’OS.

Les “Small Allocations” : le cœur de l’optimisation

Quand PHP a besoin d’allouer une petite quantité de mémoire (typiquement moins de 3KB), voici les étapes qui sont déclenchées dans le ZMM :

Aperçu des Small Allocations

Etape 1 : La table des bins et le mapping taille → bin

Le fichierzend_alloc_sizes.h définit une table de données via la macro ZEND_MM_BINS_INFO. Chaque ligne de cette table contient plusieurs informations pour un bin (ou bucket) donné :

  • L’index (count) : Un identifiant numérique (de 0 à 29 dans l’exemple) qui sert d’index dans un tableau interne.
  • La taille d’un bloc (size) : La taille fixe des blocs dans ce bin (par exemple, 8, 16, 24, …, 3072 octets).
  • Le nombre de slots (nb_slots) : Le nombre de blocs de cette taille que l’on peut extraire d’une page.
  • Le nombre de pages (nb_pages) : Le nombre de pages à allouer lorsqu’un nouveau groupe de blocs doit être créé pour ce bin.

Pour une demande d’allocation, le ZMM doit déterminer quel bin (on peut aussi parler de bucket) est adapté à la taille demandée.
Pour cela, il existe un mapping qui, étant donné la taille de l’allocation, retourne l’index du bin dont la taille des blocs est juste supérieure ou égale à la taille requise.

Par exemple, si la taille demandée est de 100 octets, le gestionnaire va chercher dans la table le premier bin ayant un size supérieur ou égal à 100. La structure de la table permet de réaliser ce calcul de manière efficace (parfois avec une recherche binaire ou un tableau d’index prédéfini).

Dans le ZMM, il existe 30 buckets (ou bin) de taille prédéfinie.

// Zend/zend_alloc.c#L218
/* Tailles prédéfinies pour les petites allocations */
#define ZEND_MM_BINS 30


// Zend/zend_alloc_sizes.h
/* Les "bins" représentent des classes de taille, de 8 octets à environ 3KB */
#define ZEND_MM_BINS_INFO(_, x, y)
_( 0, 8, 512, 1, x, y)
_( 1, 16, 256, 1, x, y)
_( 2, 24, 170, 1, x, y)
_( 3, 32, 128, 1, x, y)
_( 4, 40, 102, 1, x, y)
_( 5, 48, 85, 1, x, y)
_( 6, 56, 73, 1, x, y)
// ...
_(27, 2048, 8, 4, x, y)
_(28, 2560, 8, 5, x, y)
_(29, 3072, 4, 3, x, y)

Par exemple:

  • cette ligne _( 6, 56, 73, 1, x, y) indique que dans une page de 4096KB, il y aura 73 slots de 56 octets et correspondra au bucket (bin) 7 (index 6)
  • la ligne _(27, 2048, 8, 4, x, y), indique qu’il y aura 4 pages de 4096KB soit 16MB et comportera 8 slots de 2048 octets et correspondra au bucket (bin) 28 (index 27)

Etape 2 : L’accès aux free_list et la récupération d’un bloc

Une fois le bin sélectionné, le gestionnaire se tourne vers la free_list associée à ce bin. Voici le fonctionnement détaillé :

🚀 Les free_list

  • Pour chaque bin, le gestionnaire maintient une liste chaînée (free_list) qui contient les blocs de mémoire préalablement alloués et qui ont été libérés.
  • Ces listes permettent de réutiliser rapidement la mémoire sans avoir à allouer à nouveau depuis l’arena.
  • La structure d’un bloc alloué dans un bin inclut en général un petit en-tête (ou est utilisé de manière implicite) pour stocker un pointeur vers le bloc suivant de la free_list.

🏗️ Accès et extraction du bloc

Si la free_list du bin est non vide :

  • Le gestionnaire dépile (pop) le premier bloc de cette liste et le renvoie pour satisfaire la demande d’allocation.

Si la free_list est vide :

  • Le gestionnaire alloue un groupe de mémoire en demandant à l’allocateur d’arène.
  • La taille totale allouée est déterminée par nb_pages × ZEND_MM_PAGE_SIZE
  • Ce bloc de mémoire est ensuite découpé en plusieurs blocs de taille size correspondant au bin.
  • Les blocs excédentaires (ou tous, sauf un qui sera utilisé pour la demande en cours) sont insérés dans la free_list du bin pour être réutilisés lors de futures demandes.

Dans le code zend_alloc.c, on peut voir ce mécanisme à travers des fonctions comme zend_mm_alloc_small:

static zend_always_inline void *zend_mm_alloc_small(zend_mm_heap *heap, size_t size, bin_num_t bin_num)
{
zend_mm_free_slot *p = heap->free_slot[bin_num];

if (UNEXPECTED(p == NULL)) {
/* Pas de bloc libre disponible - allouons une nouvelle page (arène) */
return zend_mm_alloc_small_slow(heap, bin_num);
}

/* Utiliser un bloc existant de la free list */
heap->free_slot[bin_num] = p->next_free_slot;
return (void*)p;
}

La fonction zend_mm_alloc_small_slow est appelée lorsqu'il n'y a pas de bloc libre disponible:

static void *zend_mm_alloc_small_slow(zend_mm_heap *heap, bin_num_t bin_num)
{
/* Demander une nouvelle page au gestionnaire de pages */
zend_mm_chunk *chunk = heap->main_chunk;
zend_mm_page_info *info = chunk->map + page_num;

/* ... allouer une nouvelle page (arène) ... */

/* Diviser la page en petits blocs de la taille bin_num */
zend_mm_free_slot *p = (zend_mm_free_slot*)ZEND_MM_PAGE_ADDR(chunk, page_num);
int i = 0;

/* Initialiser la chaîne de blocs libres */
while (i < bin_data_size[bin_num] / bin_size[bin_num] - 1) {
p->next_free_slot = (zend_mm_free_slot*)((char*)p + bin_size[bin_num]);
p = p->next_free_slot;
i++;
}
p->next_free_slot = NULL;

/* ... */
return first_block;
}

Exemple concret :

Prenons la ligne de la table :

// Zend/zend_alloc_sizes.h#L53
_(20, 640, 32, 5, x, y)
  • Taille d’un bloc : 640 octets.
  • Nombre de slots : 32 blocs par groupe.
  • Nombre de pages : 5 pages de 4 KB, soit 5 × 4096 = 20 KB.

Ici, la totalité de l’espace de 20 KB est utilisée pour fournir exactement 32 blocs de 640 octets (32 × 640 = 20480 octets).
Lorsque le bin 20 est sélectionné pour une demande d’allocation de taille inférieure ou égale à 640 octets, le gestionnaire vérifie la free_list associée à ce bin.

  • Si un bloc est disponible, il est retiré de la liste.
  • Sinon, une nouvelle zone de 20 KB est allouée, découpée en 32 blocs, et l’un d’entre eux est utilisé immédiatement tandis que les autres sont ajoutés à la free_list.

Cette approche, appelée “segregated free list”, est extrêmement efficace pour les langages comme PHP qui font beaucoup de petites allocations successives.
Cela permet :

D’accéder rapidement à un bin donné lorsque PHP demande une allocation.
Parcourir la table des bins de manière efficace.
Optimiser la réutilisation des blocs pour minimiser la fragmentation mémoire.

Cette approche réduit considérablement les appels système et la fragmentation mémoire. Donc, c’est plus performant.

Libération de mémoire et réutilisation

Quand tu appelles unset() sur une variable PHP ou qu'un objet n'est plus référencé, le ZMM doit gérer la libération de cette mémoire. Là encore, des optimisations sont mises en place :

  1. Les petits blocs libérés sont placés dans la liste des blocs libres de leur classe de taille (les buckets ou bin), prêts à être réutilisés
  2. Si une page entière devient libre, elle peut être rendue au chunk parent
  3. Si un chunk n’est plus utilisé, il peut être rendu au système d’exploitation

Protection contre la corruption de la mémoire

Pour renforcer l’intégrité de la liste chaînée et détecter d’éventuelles corruptions, une technique de “shadow pointer” est utilisée :

  • En plus du pointeur vers le prochain bloc libre stocké au début du bloc, une copie de ce pointeur est conservée à la fin du même bloc.
  • Cette copie n’est pas stockée telle quelle. Elle est encodée en effectuant un XOR avec une clé aléatoire spécifique dans la heap (heap>shadow_key). De plus, le pointeur encodé est converti en big-endian. Cette conversion garantit que les corruptions affectant les octets les moins significatifs (les plus susceptibles d'être altérés) se manifestent dans les octets les plus significatifs, augmentant ainsi la probabilité de détecter des adresses invalides.
  • Avant de déréférencer un pointeur vers le prochain bloc libre, le gestionnaire de mémoire décode le pointeur “shadow” en effectuant un XOR inverse avec la clé aléatoire et en reconvertissant le résultat en l’ordre d’octets natif de la machine. Si le pointeur obtenu ne correspond pas au pointeur stocké au début du bloc, une corruption de la mémoire est détectée.
Bougre, c’est vraiment malin tout ça ! Sans rire, c’est du génie.

Cette technique s’inscrit dans une famille plus large d’approches de détection d’erreurs mémoire. Des concepts similaires existent dans d’autres systèmes :

  • Les “canaries” de stack ou de heap (valeurs sentinelles placées aux limites des blocs mémoire)
  • Les guard pages (pages mémoire protégées placées entre allocations)
  • Les checksums et métadonnées de validation

Retiens que les “shadow pointers” ne sont ni une invention pure de PHP, ni une pratique standard universelle, mais plutôt une technique spécifique que PHP a adapté et intégré à son modèle de gestion mémoire en fonction de ses besoins particuliers.

Voici en synthèse, les avantages de ce système pour les petites allocations :

  • Réduction des appels système : Une seule demande au système d’exploitation permet d’obtenir une page entière qui servira pour de nombreuses petites allocations.
  • Localité spatiale : Les allocations de même taille sont regroupées, ce qui améliore les performances du cache CPU.
  • Fragmentation réduite : La division d’une page en blocs de taille identique élimine la fragmentation interne au sein de cette page.
  • Rapidité d’allocation : Une fois qu’une arène (chunk + page) est disponible, les allocations deviennent de simples opérations de pointeur, beaucoup plus rapides qu’un appel à malloc.
  • Optimisation des libérations massives : À la fin d’une requête PHP, libérer des chunks entiers est beaucoup plus efficace que libérer individuellement chaque petit bloc.
C’est un peu chaud tout ça quand même…

Et voilà ! Tu en sais un peu plus maintenant sur l’internal de PHP et notamment sur l’allocation de la mémoire avec le ZMM.
Mais ton voyage ne s’arrête pas là, tu n’as qu’une vue partielle, je n’ai pas parlé en détails des RUNS, nous nous sommes pas intéressés aux fameuses ZVAL, aux différents HEADERS d’allocation etc…

C’est un sujet fascinant et complexe, et bien souvent, un peu casse tête !

Have fun 😉

🎁 Un petit cadeau si tu m’as lu jusque là. Voici une stat que tu pourras balancer lors d’une soirée pour briller de mille feux:

La fragmentation interne dans le ZMM reste généralement < 25%.

On peut penser que c’est “beaucoup”.

Alors oui, si tu fais de l’embarqué, mais nous, on code pour le web donc c’est moins impactant. C’est toujours une histoire de compromis. Le ZMM fait tout pour nous, donc il ne peut être parfait !

🎁 Sinon, tu peux toujours coder ton propre allocateur… Ah, je ne t’avais pas dit que la structure de la HEAP de PHP te permet de balancer ton propre allocateur ? Curieux … 😏

Regarde par là : https://github.com/php/php-src/blob/master/Zend/zend_alloc.c#L3108

// Dans ton extension custom de folie
void set_custom_allocator() {
zend_mm_set_custom_handlers(zend_mm_get_heap(), &custom_handlers);
}

Si tu m’as lu jusqu’ici, un gros merci à toi ! ❤️

Suivez les dernières actualités d’ekino sur note site internet ou notre page LinkedIn.


Plongée dans le Zend Memory Manager : Comprendre la gestion mémoire interne de PHP was originally published in ekino-france on Medium, where people are continuing the conversation by highlighting and responding to this story.