I. Rappel sur la gestion de la mémoire en C

Le langage C permet de programmer le processeur et la mémoire de la machine cible, via une couche d'abstraction (le langage C lui-même). En C, toute fonction et toute variable consomment de la mémoire. On mesure cela en octets.
Il existe plusieurs classes d'allocation mémoire en C.

Les classes d'allocation mémoire en C
  1. allocation automatique ;
  2. allocation statique ;
  3. allocation dynamique.

L'allocation automatique concerne tout argument reçu par une fonction, ou toute variable déclarée dans le corps d'une fonction. Le compilateur alloue de la mémoire pour la variable en fonction de son type et libère la mémoire automatiquement (d'où le nom de la classe d'allocation automatique) lorsqu'il sort de la fonction. Il est impossible de prendre la main sur cette gestion, et notamment il est impossible de dimensionner ou redimensionner la taille de la mémoire allouée.
L'allocation statique concerne toute variable globale, ou déclarée static. Le compilateur alloue la mémoire en fonction du type de la variable, mais il ne la libérera jamais (sauf en fin de vie du programme, bien sûr). Ici encore, on ne peut rien toucher quant à la zone de mémoire allouée, on ne peut la redimensionner.
Enfin, la classe d'allocation dynamique laisse le programmeur allouer lui même de la mémoire (virtuellement autant qu'il veut), il est totalement libre, il peut allouer un espace (on parle "d'allouer un tampon"), le remplir, le vider et même le redimensionner (dans les deux sens).
Cependant, il a aussi l'immense responsabilité de libérer cet espace mémoire dès qu'il ne s'en sert plus, car rien ni personne ne le fera à sa place.

L'allocation dynamique est très pratique, car le programmeur "joue" réellement avec les octets en mémoire et peut faire ce qu'il souhaite, y compris oublier de libérer un tampon alloué : on parle alors de fuite mémoire.

Les classes d'allocation mémoire en C
Sélectionnez

#include <stdio.h>
#include <stdlib.h>
 
/* Allocation statique, variable globale
   le compilateur s'occupe de tout mais ne libèrera
   jamais la mémoire */
int monentier;
 
char *mafonction(int i)
{
    /* Allocation statique, variable statique
      le compilateur s'occupe de tout mais ne libèrera jamais
      la mémoire */
    static char *my_static = "Foo";
 
    if (monentier == i) {
        my_static = "Bar";
    }
    return my_static;
}
 
int main(int argc, char *argv[])
{
    /* Allocation automatique, le compilateur alloue de
      la mémoire et la libèrera à la sortie de la fonction */
    int *p_int;
    int i;
 
    monentier = 18;
 
    /* Allocation dynamique, le programmeur demande d'allouer
       10*sizeof(int) octets en mémoire, ce sera à lui de la libérer */
    p_int = (int *)malloc(10 * sizeof(int));
 
    for (i=0; i<10; i++) {
        *(p_int + i) = i*monentier;
        mafonction(*p_int);
    }
 
    /* Libération de la mémoire utilisée, si cette étape
       est oubliée, une fuite mémoire va apparaitre */
    free(p_int);
 
    return 0;
}

Nous n'allons pas trop loin dans cet article. Il faut noter que la zone de mémoire occupée par une variable ne sera pas située au même endroit en fonction de sa classe d'allocation.
Une allocation automatique alloue la mémoire sur la pile, une allocation dynamique alloue la mémoire sur le tas et une allocation statique alloue la mémoire dans le segment bss ou data du binaire ELF.

Lorsque les données à manipuler sont dynamiques, on a recours à l'allocation dynamique et cela est extrêmement fréquent en C, donc dans PHP.
L'allocation dynamique est rendue possible majoritairement grâce à la libc, son fichier stdlib.c et ses fonctions malloc() et free().
Comme PHP tourne dans un serveur Web, sous forme de démon, la moindre fuite mémoire va pénaliser le processus et donc la machine. Il est tellement simple de créer une fuite mémoire en C, que l'allocation dynamique au moyen de la libc devient vite un casse-tête qui doit trouver des solutions. Zend Memory Manager arrive à la rescousse.

II. Les problèmes d'allocation dynamique de mémoire pour PHP

II-A. Différences entre les OS

Il faut savoir que la libc n'est pas implémentée de la même manière dans tous les OS, et c'est normal, car elle se sert d'appels systèmes directement liés au Kernel.
Ces appels sont très différents entre Windows et Linux, dans un premier temps.
Dans un second temps, la libc utilise en interne différents appels systèmes qui peuvent varier entre les versions d'Unix et qui sont configurables (sous Linux en tout cas, voir malloc.h). Un bon architecte système aura tout de suite reniflé que toucher à cette zone précise du système permet de faire de grosses bêtises, ou de gros miracles.
Enfin, il existe des différences dans la gestion de la mémoire entre les processeurs de type 32 bits ou 64 bits, et les libc ne s'en servent pas forcément tout le temps de manière optimale (taille des pages mémoires, MMU, type de processeur...).

Image non disponible
Gestion de la mémoire dynamique en C

II-B. Fragmentation du tas

Pour comprendre la fragmentation du tas, il faut se souvenir du fonctionnement du couple malloc()/free(). Je n'irai pas trop loin sur ce sujet, car il peut devenir très vaste et s'éterniser.
Malloc() crée un segment mémoire de taille X et retourne un pointeur void * dessus. En réalité, en interne, elle fait beaucoup plus que cela. Elle alloue aussi des blocs de gestion, dans une zone mémoire avant la zone demandée. Ces blocs de gestion permettent d'indiquer la taille de la zone mémoire qui suit, et d'autres choses encore. Aussi, il n'est pas rare qu'elle alloue plus d'espace que demandé (demander d'allouer un octet aura pour effet d'allouer effectivement 8 ou 16 octets, en tout cas un multiple de 8), il se passe la même chose pour les structures, on appelle ça l'alignement en mémoire.

Malloc gère un tas. Si j'alloue trois zones sur ce tas, malloc va conserver des statistiques via un mécanisme complexe qui fait qu'au fur et à mesure que je libère ces zones (free()), il les organise dans un arbre binaire pour pouvoir me les resservir aux prochains appels à malloc() en fonction de la taille que je vais demander. Il est aussi possible que free() déclenche un compactage du tas : coller côte à côte des blocs de taille similaire. Au fur et à mesure de demandes d'allocation de diverses tailles, le tas va se transformer "en gruyère", et l'arbre de statistiques de malloc va s'alourdir.
On appelle cela la fragmentation du tas : plus le temps passe et les allocations sont nombreuses et de tailles variées, plus les appels à malloc() et free() seront lents.
Dans une grande majorité de cas, cela a peu d'importance, mais pour les programmes "démons" - résidents en mémoire - comme PHP dans un serveur Web, cette problématique peut nécessiter une étude et la recherche de solutions.

La principale solution tente en fait de régler la cause majeure du problème : éviter d'appeler malloc() trop souvent en lui demandant des tampons de tailles trop variées.
Si l'on demande une fois pour toute à malloc un gros tampon, que l'on fragmente ensuite soi-même en segments de taille adéquate pour les "objets" à stocker, on consomme alors plus de mémoire, mais on la fragmente moins et on réduit considérablement les appels systèmes, les transferts de pages mémoires, l'éventuel déplacement du tas en mémoire (malloc() joue souvent avec sbrk() qui déplace l'adresse haute du tas), ce qui soulage le processeur.

Le serveur Apache (httpd) possède lui aussi une surcouche qui lui sert de gestionnaire de mémoire. Il ne fait jamais appel à malloc directement.

Zend Memory Manager a été principalement conçu dans ce but : palier aux faiblesses de performance de l'implémentation générique malloc()/free().
Pour plus d'information sur le fonctionnement interne d'un allocateur mémoire comme malloc(), voyez Once uppon a free() ou encore le manuel de référence de GNU libc

II-C. Gérer les fuites et la consommation

Ah, les fuites de mémoire ; une terrible histoire... Notons qu'il existe des outils aptes à détecter les fuites mémoires en langage C, par exemple valgrind, mtrace, ccmalloc ou encore malloc elle-même.
Prenons quelques exemples très simples :

Détection de fuite mémoire avec mtrace
Sélectionnez

#include <stdlib.h>
#include <string.h>
#include <mcheck.h>
 
#define BUFFER_SIZE 200
 
int main(int argc, char *argv[])
{
    mtrace();
    char *p_char = (char *)malloc(BUFFER_SIZE);
    char string[] = "Hello, world ; I'm gonna leak some memory";
    memcpy(p_char, string, sizeof(string));
    return 0;
}

Mtrace est très simple et très efficace. Il suffit d'inclure mcheck.h puis d'appeler mtrace() dès que l'on souhaite tracer les appels à malloc() pour les surveiller (en général en début de programme).
Ensuite, il faut définir une variable d'environnement indiquant le fichier utilisé pour les traces et invoquer le programme mtrace :

Détection de fuite mémoire avec mtrace
Sélectionnez

$ gcc -Wall -g -o leak leak.c 
$ export MALLOC_TRACE="/tmp/leak.out"
$ ./leak
$ mtrace ./leak /tmp/leak.out
 
Memory not freed:
-----------------
           Address     Size     Caller
0x0000000000efc460     0xc8  at /tmp/leak.c:10

Mtrace indique alors clairement les tampons alloués mais non libérés. muntrace() peut être utilisée pour écrire la trace avant la fin du programme (cas des démons).

Détection de fuite mémoire avec valgrind
Sélectionnez

$ valgrind --tool=memcheck --leak-check=full ./leak
==9488== Memcheck, a memory error detector
==9488== Copyright (C) 2002-2010, and GNU GPL'd, by Julian Seward et al.
==9488== Using Valgrind-3.6.0.SVN-Debian and LibVEX; rerun with -h for copyright info
==9488== Command: ./leak
==9488== 
==9488== 
==9488== HEAP SUMMARY:
==9488==     in use at exit: 200 bytes in 1 blocks
==9488==   total heap usage: 1 allocs, 0 frees, 200 bytes allocated
==9488== 
==9488== 200 bytes in 1 blocks are definitely lost in loss record 1 of 1
==9488==    at 0x4C2815C: malloc (vg_replace_malloc.c:236)
==9488==    by 0x4005DB: main (leak.c:8)
==9488== 
==9488== LEAK SUMMARY:
==9488==    definitely lost: 200 bytes in 1 blocks
==9488==    indirectly lost: 0 bytes in 0 blocks
==9488==      possibly lost: 0 bytes in 0 blocks
==9488==    still reachable: 0 bytes in 0 blocks
==9488==         suppressed: 0 bytes in 0 blocks
==9488== 
==9488== For counts of detected and suppressed errors, rerun with: -v
==9488== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 4 from 4)

Valgrind est un outil très puissant. Tout programmeur C connait cet outil et l'utilise très souvent. Il va permettre de tracer les fuites mémoires, mais aussi d'anticiper les accès illégaux à la mémoire (déréférencement de pointeur nul, écriture hors zone d'un tampon, fuite mémoire...). Un bijou merveilleux, dont la documentation est très bien faite.

Tous ces outils ont chacun leurs avantages et leurs inconvénients. La liste n'est pas exhaustive : il existe des tonnes d'outils permettant de vérifier avec plus ou moins de fiabilité la gestion du tas par un programme écrit en C.
Quoiqu'il en soit, rien ne remplace la vigilance du développeur, l'outil va aider mais ne va en aucun cas faire disparaitre les problèmes.

Evidemment, plus l'application grossit, plus le débogage de la mémoire dynamique sera complexe et pénible. Dès lors, il peut devenir utile de développer soi-même une bibliothèque de gestion de mémoire dynamique (basée majoritairement sur malloc()) qui sera utilisée dans tout le projet en lieu et place des traditionnels malloc()/free(), incluant la détection des fuites mémoires au runtime.
C'est un des buts de Zend Memory Manager.

Voici un rapide exemple de fuite mémoire dans une extension PHP, que le ZendMM va rapidement repérer :

Une fuite mémoire volontaire dans une extension PHP extrêmement simplifiée (tronquée)
Sélectionnez

PHP_FUNCTION(make_leak)
{
    void *leak = emalloc(200); /* emalloc est le "malloc" de ZendMM */
    RETURN_NULL(); /* on retourne, en oubliant volontairement de libérer le tampon leak*/
}
ZendMM ne manque pas de nous la signaler, automatiquement
Sélectionnez

$> php /tmp/leak_check.php
 
[Thu Apr  7 17:48:07 2011]  Script:  '/tmp/leak_check.php'
/usr/local/src/php/ext/leak/leak.c(172) :  Freeing 0x01DBB2E0 (200 bytes), script=/tmp/leak_check.php
=== Total 1 memory leaks detected ===

On voit que le ZendMM libère la mémoire du tampon void *leak, il indique libérer 200 octets et précise en plus à quel endroit la fuite a eu lieu (leak.c ligne 172 qui correspond à l'appel de emalloc()).
ZendMM trace donc tous les appels qu'il propose et gère entièrement les fuites (les oublis d'appel à efree()).

Rappel : ZendMM n'affiche sur la sortie standard des informations sur les fuites que s'il a été compilé en mode debug (--enable-debug). Dans le cas contraire, il n'affiche rien, mais libère tout de même la mémoire automatiquement.

III. Introduction à Zend Memory Manager

III-A. Buts

Zend Memory Manager est le composant du ZendEngine responsable de la gestion de la mémoire dynamique du programme. Il publie des fonctions que le développeur devra utiliser en lieu et place des allocateurs mémoires traditionnels type malloc().
Ses buts sont multiples :

  1. Eviter la fragmentation du tas à la longue, en réimplantant un tas en interne basé sur de la segmentation et en l'alignant correctement ;
  2. Signaler les fuites mémoires lors de l'écriture de code C pour PHP (particulièrement des extensions PHP) ;
  3. Libérer automatiquement la mémoire ayant fuit dans PHP (durant la phase de fermeture de la requête) ;
  4. Surveiller et limiter la consommation mémoire globale de PHP (memory_limit) ;
  5. Permettre de choisir la couche basse d'allocation (selon OS) ;
  6. Ne pas empêcher l'utilisation d'un débogueur de mémoire externe, type valgrind.

Zend Memory Manager est apparu en PHP4 et a été retouché profondément avec PHP5.2.

Image non disponible
La surcouche Zend Memory Manager

III-B. Configuration

Zend Memory Manager (ZendMM) est actif par défaut dans PHP, et peut être désactivé (il sera toujours présent, mais court-circuité). En revanche, son comportement va changer si PHP a été compilé en mode DEBUG ou non (--enable-debug).
En mode DEBUG, ZendMM va signaler sur la sortie d'erreurs toute fuite mémoire qu'il a rencontrée (si report_memleaks=1 dans php.ini, ce qui est le cas par défaut). Le premier paramètre de configuration est donc --enable-debug, qui rend loquace ZendMM.

Image non disponible
phpinfo() informe de l'état du gestionnaire de mémoire interne

Viennent ensuite quatre variables d'environnement qui modifient le fonctionnement de ZendMM : USE_ZEND_ALLOC, ZEND_MM_MEM_TYPE, ZEND_MM_SEG_SIZE et ZEND_MM_COMPACT.
Si USE_ZEND_ALLOC est utilisée et passée à 0, alors ZendMM est désactivé (court-circuité), et son utilisation renverra systématiquement vers le gestionnaire de mémoire de l'OS (malloc() principalement). Utile pour déboguer la mémoire avec un outil externe type Valgrind. Le phpinfo vous informera alors en indiquant "disabled" face à Zend Memory Manager.
ZEND_MM_MEM_TYPE définit la fonction bas niveau d'allocation mémoire, il s'agit par défaut de "malloc", mais les valeurs "mmap_anon" - "mmap_zero" - ou "win32" peuvent être utilisées.
ZEND_MM_SEG_SIZE définit la taille minimale des segments d'allocation. La fonction est (sensiblement) la même que celle de PAGESIZE pour le Kernel, à savoir quelle est la taille minimale d'une allocation de page (on ne travaille pas au même niveau tout de même). Par défaut, 256 Ko, ce qui est beaucoup et peu à la fois, nous y reviendrons.

Exemple d'utilisation de valgrind avec et sans Zend MM
Sélectionnez

$ USE_ZEND_ALLOC=0 valgrind --tool=memcheck php /tmp/small.php 
==6861== Memcheck, a memory error detector
==6861== Copyright (C) 2002-2010, and GNU GPL'd, by Julian Seward et al.
==6861== Using Valgrind-3.6.0.SVN-Debian and LibVEX; rerun with -h for copyright info
==6861== Command: ./php /tmp/small.php
==6861== 
 
==6861== HEAP SUMMARY:
==6861==     in use at exit: 0 bytes in 0 blocks
==6861==   total heap usage: 9,697 allocs, 9,697 frees, 2,686,147 bytes allocated
==6861== 
==6861== All heap blocks were freed -- no leaks are possible
==6861== 
==6861== For counts of detected and suppressed errors, rerun with: -v
==6861== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 4 from 4)
 
$ valgrind --tool=memcheck ./php /tmp/small.php ==6866== Memcheck, a memory error detector
==6866== Copyright (C) 2002-2010, and GNU GPL'd, by Julian Seward et al.
==6866== Using Valgrind-3.6.0.SVN-Debian and LibVEX; rerun with -h for copyright info
==6866== Command: ./php /tmp/small.php
==6866== 
 
==6866== HEAP SUMMARY:
==6866==     in use at exit: 0 bytes in 0 blocks
==6866==   total heap usage: 7,854 allocs, 7,854 frees, 2,547,726 bytes allocated
==6866== 
==6866== All heap blocks were freed -- no leaks are possible
==6866== 
==6866== For counts of detected and suppressed errors, rerun with: -v
==6866== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 4 from 4)

Rappel : la consommation mémoire de PHP dépend du script utilisateur, certes, mais aussi des extensions chargées, car elles aussi utilisent (théoriquement, il faut le vérifier manuellement) ZendMM à la place de l'implémentation de base (malloc()).
Plus PHP possède d'extensions, à fortiori lourdes, et plus sa consommation mémoire sera élevée. Le dernier chapitre de cet article détaille cette consommation.

Remarque : la consommation mémoire de PHP est plus faible avec ZendMM activé que sans (comme en témoigne la sortie de Vallgrind ci-dessus). Sachez aussi que PHP est beaucoup plus rapide lorsque ZendMM est activé. J'ai eu dans des benchmarks des rapports à 100% plus rapide !
Ceci est principalement dû au fait que le ZendMM est adapté à la consommation mémoire de PHP, il préaloue des blocs de tailles précises, les organise correctement et évite beaucoup d'appels à malloc().

Modification de la taille minimale d'une allocation par ZendMM à 8Ko
Sélectionnez

$> ZEND_MM_SEG_SIZE=8k php /tmp/my_script.php

Ici, les allocations de ZendMM se feront par pas de 8 Ko. Nous reviendrons sur ce point dans un chapitre ultérieur.

ZEND_MM_MEM_TYPE permet de choisir l'implémentation mémoire en dessous de ZendMM. Par défaut, il utilisera malloc() classique de la libc. Vous pouvez utiliser mmap() avec des zones mémoires anonymes, ou en mappant /dev/zero. Vérifiez que votre système le supporte.

ZEND_MM_COMPACT permet de définir la taille à partir de laquelle le mécanisme de compactage du tas de ZendMM sera déclenché. Cette opération est inutile et non implémentée sous Linux.

IV. Fonctionnement de ZendMM

ZendMM reprend en gros le concept d'allocateur utilisé par malloc().

Voici les structures principales de ZendMM :

  1. zend_mm_heap : le tas ;
  2. zend_mm_mem_handlers : les gestionnaires sous-jacents disponibles (malloc, mmap_anon, win32...) ;
  3. zend_mm_segment : segment de mémoire. Liste chainée sur lui-même ;
  4. zend_mm_block / zend_mm_free_block : blocs de mémoire présents dans les segments.

Je ne vais pas entrer dans les détails, car le code de Zend Memory Manager est très complexe, et sa compréhension complète n'apportera rien de très utile dans cet article.

IV-A. Structures remarquables

zend_mm_mem_handlers représente le gestionnaire bas-niveau de ZendMM, il est donc rempli de pointeurs sur fonctions. L'implémentation de base utilise malloc(), et est représentée dans la macro ZEND_MM_MEM_MALLOC_DSC.

Un gestionnaire de mémoire, une des couches basses sélectionnables de ZendMM
Sélectionnez

typedef struct _zend_mm_mem_handlers {
	const char *name;
	zend_mm_storage* (*init)(void *params);
	void (*dtor)(zend_mm_storage *storage);
	void (*compact)(zend_mm_storage *storage);
	zend_mm_segment* (*_alloc)(zend_mm_storage *storage, size_t size);
	zend_mm_segment* (*_realloc)(zend_mm_storage *storage, zend_mm_segment *ptr, size_t size);
	void (*_free)(zend_mm_storage *storage, zend_mm_segment *ptr);
} zend_mm_mem_handlers;
 
#define ZEND_MM_MEM_MALLOC_DSC {"malloc", zend_mm_mem_dummy_init, zend_mm_mem_dummy_dtor, zend_mm_mem_dummy_compact, zend_mm_mem_malloc_alloc,
zend_mm_mem_malloc_realloc, zend_mm_mem_malloc_free}

zend_mm_segment représente un segment mémoire, dont la taille est configurable via la variable d'environnement ZEND_MM_SEG_SIZE. L'allocateur va allouer un tampon de cette taille, placer une structure zend_mm_segment dedans, puis retourner un pointeur sur la zone mémoire juste après. Cette zone-là sera alors à son tour scindée en blocs chainés entre eux.
zend_mm_segment est une liste chainée.

Un segment mémoire dans ZendMM
Sélectionnez

typedef struct _zend_mm_segment {
	size_t	size;
	struct _zend_mm_segment *next_segment;
} zend_mm_segment;

Le tampon de chaque segment est découpé en blocs dont la structure et l'organisation sont très complexes. La zone mémoire restante est utilisée et retournée sous forme de void*

Un bloc mémoire
Sélectionnez

typedef struct _zend_mm_free_block {
	zend_mm_block_info info;
#if ZEND_DEBUG
	unsigned int magic;
# ifdef ZTS
	THREAD_T thread_id;
# endif
#endif
	struct _zend_mm_free_block *prev_free_block;
	struct _zend_mm_free_block *next_free_block;
 
	struct _zend_mm_free_block **parent;
	struct _zend_mm_free_block *child[2];
} zend_mm_free_block;

zend_mm_heap est le tas. Il est partagé dans une variable globale à tout PHP s'il en a besoin (mais dans le code de PHP, on accède rarement directement au tas).

Le tas de Zend Memory Manager
Sélectionnez

struct _zend_mm_heap {
	int                 use_zend_alloc;
	void               *(*_malloc)(size_t);
	void                (*_free)(void*);
	void               *(*_realloc)(void*, size_t);
	size_t              free_bitmap;
	size_t              large_free_bitmap;
	size_t              block_size;
	size_t              compact_size;
	zend_mm_segment    *segments_list;
	zend_mm_storage    *storage;
	size_t              real_size;
	size_t              real_peak;
	size_t              limit;
	size_t              size;
	size_t              peak;
	size_t              reserve_size;
	void               *reserve;
	int                 overflow;
	int                 internal;
#if ZEND_MM_CACHE
	unsigned int        cached;
	zend_mm_free_block *cache[ZEND_MM_NUM_BUCKETS];
#endif
	zend_mm_free_block *free_buckets[ZEND_MM_NUM_BUCKETS*2];
	zend_mm_free_block *large_free_buckets[ZEND_MM_NUM_BUCKETS];
	zend_mm_free_block *rest_buckets[2];
#if ZEND_MM_CACHE_STAT
	struct {
		int count;
		int max_count;
		int hit;
		int miss;
	} cache_stat[ZEND_MM_NUM_BUCKETS+1];
#endif
};

Compiler PHP en mode debug active beaucoup de choses dans les structures de ZendMM. Il en est de même pour des fonctions qui ne s'activent que lorsque le mode debug est activé.
Principalement, le tas comporte sa taille et son pique (size et peak) puis le zend_mm_storage vu plus haut et des zend_mm_segment derrière lesquels des blocs de mémoire (zend_mm_block) vont être créés.

IV-B. Fonctions publiées

ZendMM est très principalement utilisé lors de la création d'extensions PHP. Pour cela, il publie des fonctions de gestion de la mémoire dynamique, que le développeur devra utiliser en lieu et place des allocateurs habituels.
Ces fonctions sont très simples d'emploi, car elles sont calquées à l'identique sur les très connues fonctions de la libc malloc()/free() et leurs amies.

Fonctions publiées par Zend Memory Manager
Fonction ZendMM Fonction libc
void *emalloc(size_t size);
void *pemalloc(size_t size, char persistent)
void *malloc(size_t size)
void *ecalloc(size_t size);
void *pecmalloc(size_t size, char persistent)
void *calloc(size_t size)
void *erealloc(void *ptr, size_t size);
void *perealloc(void *ptr, size_t size, char persistent)
void *realloc(void *ptr, size_t size)
void *estrdup(void *ptr)
void *pestrdup(void *ptr, char persistent)
void *strdup(void *ptr)
void efree(void *ptr)
void pefree(void *ptr, char persistent)
void free(void *ptr)

Inutile de préciser comment les utiliser. Petite note sur les versions "persistent", elles renvoient directement vers les fonctions de la libc. On utilisera donc les fonctions "persistantes" lorsqu'on aura besoin d'allouer des ressources qui doivent durer au-delà d'une requête, car ZendMM libère automatiquement tout ce qui lui a été demandé en fin de cycle de requête.

Pour en savoir plus sur le cycle de vie de PHP et d'une requête HTTP dans PHP, lisez PHP Internals : Fonctionnement global de PHP. C'est très important car c'est principalement la raison d'être de ZendMM : compter tout ce qui est alloué durant une requête pour le détruire.

Evidemment, il existe encore quelques fonctions d'aide de ZendMM, les très classiques que l'on retrouve dans tous les projets C quasiment :

Quelques fonctions d'aide additionnelles
  1. void *estrndup(void *ptr, int len); : duplique len octets de ptr et les retourne en ajoutant l'octet nul en fin de chaine automatiquement ;
  2. void *safe_emalloc(size_t size, size_t count, size_t addt); : retourne un pointeur de (count * size + addt) octets, ça évite de faire le calcul à la main et ça vérifie un éventuel dépassement de capacité des entiers.

IV-C. Interaction depuis PHP

PHP propose d'interagir avec son gestionnaire de mémoire. Les fonctions memory_get_usage() et memory_get_peak_usage() sont utilisées pour cela, ainsi que le paramètre de php.ini memory_limit, tous jouent directement avec les structures de ZendMM.
Le très connu memory_limit permet - comme son nom l'indique si bien - de limiter la consommation mémoire d'un script PHP. La plupart des allocations passant par le gestionnaire Zend Memory Manager, il est très simple pour celui-ci de calculer la consommation de son tas. Dès que celui-ci dépasse la valeur de memory_limit, il renvoie une erreur fatale en indiquant un message.

Erreur, la mémoire à allouer dépasse la limite autorisée
Sélectionnez

zend_mm_safe_error(heap, "Allowed memory size of %ld bytes exhausted (tried to allocate %ld bytes)", heap->limit, size);

Rappel : tout consomme de la mémoire, mais en PHP presque tout est stocké dans des variables. Détruire une variable ne libère pas nécessairement la mémoire associée si une autre variable pointe encore dessus. Lisez l'article gestion des variables en PHP pour plus d'informations.

Pour connaitre la consommation mémoire de PHP à un moment donné, memory_get_usage() peut être utilisée. Elle retourne la consommation actuelle des données dans les segments du tas.
Si vous passez 1 à cette fonction, elle retourne le "real usage", c'est à dire la taille du tas de Zend Memory Manager (la somme des tailles de tous les segments). Le tas est toujours plus gros que la consommation réelle actuelle, il est plus gros par palier de segment, dont la taille est déterminée par ZEND_MM_SEG_SIZE pour mémoire.

/tmp/mem.php : Script d'exemple qui crée une chaine de 10Mo et affiche la consommation mémoire de PHP
Sélectionnez
<?php
ini_set('memory_limit', -1); // mémoire "illimitée"
 
function show_memory($real = false) {
    printf("%.2f Ko\n", memory_get_usage($real) / 1024);
}
 
show_memory();
show_memory(1);
 
$a = str_repeat('a', 1024*1024*10); // 10Mo
 
echo "\n";
 
show_memory();
show_memory(1);
Utilisation du script
Sélectionnez

$> php /tmp/mem.php
621.62Ko
768.00 Ko
 
10861.83 Ko
11264.00 Ko

Un petit mot sur memory_get_peak_usage() permet de retourner le pique de mémoire que PHP a rencontré. En fait, ZendMM mémorise en permanence la plus haute consommation effective et la consommation de son tas, et les retourne via cette fonction.

Important : rien n'oblige le développeur d'une extension PHP à utiliser ZendMM. Il peut à tout moment le court-circuiter et utiliser "en dur" malloc()/free(), c'est notamment le cas lors de l'utilisation des fonctions "persistent" du ZendMM (vues plus haut). Cette consommation n'est donc pas comptabilisée par le Zend Memory Manager, et n'est donc pas comptée dans le résultat de memory_get_usage(). La surveillance des fuites qu'effectue ZendMM est aussi court-circuitée. N'importe quelle extension PHP mal conçue peut faire fuir de la mémoire. Evidemment, il est hautement recommandé d'utiliser ZendMM lorsqu'on crée des extensions PHP, mais il appartient à chacun de vérifier si l'extension en question ne génère pas de fuite et utilise correctement le gestionnaire de mémoire interne ZendMM.
Gardez aussi en tête que PHP Core et le Zend Engine n'utilisent pas ZendMM dans 100 % des cas, parfois ils ont recours à des appels directs à la mémoire. Pour connaitre la "vraie" consommation mémoire à l'octet près de PHP, il faut utiliser un débogueur de mémoire comme Valgrind (très efficace et très puissant). Nous reviendrons sur ce point dans le dernier chapitre.

Pour résumer : ZendMM et ses fonctions ont clairement été conçus pour être utilisés dans les extensions PHP. Le coeur de PHP et le Zend Engine l'utilisent aussi ponctuellement, pour tout ce qui a une durée de vie égale à la requête courante.
memory_get_usage() ne donne qu'une information approximative (mais souvent proche de la réalité) de la consommation mémoire de PHP.

IV-C-1. ext/memtrack

ext/memtrack est une extension PHP qui permet de mesurer la consommation mémoire et de lever un warning lorsque celle-ci est supérieure à une certaine limite.
ext/memtrack se branche sur ZendMM, ainsi il ne faut pas indiquer une limite plus haute que celle de ZendMM (memory_limit), ça n'a pas de sens.

Installation de ext/memtrack
Sélectionnez

> pecl install memtrack
> echo "memtrack.enable=1 memtrack.hard_limit=1M memtrack.soft_limit=1M" >> /etc/php/php.ini
Exemple d'utilisation de ext/memtrack
Sélectionnez
<?php
echo memory_get_usage(); // environ 645696 octets
 
$a = str_repeat('a', 1000000);
 
/* PHP Warning:  [memtrack] [pid 20989] internal function str_repeat() executed in php shell
code on line 1 allocated 1048576 bytes in Unknown on line 0 */

Fonctionne nickel. Plus d'informations dans le README de l'extension.
Il reste après possible de développer soi-même une extension jouant avec la mémoire de PHP. J'en ai une sur le feu.

IV-D. Tuning et idées de configurations avancées

IV-D-1. ZEND_MM_SEG_SIZE ou la taille des segments du tas

Si on choisit par exemple des segments de 256 Ko (cas par défaut) et que PHP doit consommer 320 Ko effectifs, ZendMM va allouer sur son tas deux segments, et va donc consommer effectivement 512 Ko de mémoire alors que seuls 320 Ko sont utilisés.
C'est exactement le rôle d'un allocateur de mémoire et c'est aussi à peu de chose près, comme ça que fonctionne malloc() en interne. Régler la taille des segments permet donc d'optimiser la mémoire consommée par PHP. C'est une des raisons d'être de Zend Memory Manager : réduire les appels à malloc() et donc les appels systèmes, les transferts mémoire - registres du processeur, et la fragmentation du tas du processus.

Taille des segments de 1Mo (1024*1024*10)
Sélectionnez

// /tmp/mem.php est présenté dans le chapitre précédent
 
$> ZEND_MM_SEG_SIZE=1048576 php /tmp/mem.php 
625.67 Ko
1024.00 Ko
 
10865.88 Ko
12288.00 Ko

Note : la taille des segments peut s'exprimer avec un 'K', un 'M' ou un 'G' (insensibles à la casse). Par exemple : ZEND_MM_SEG_SIZE=10M

On le voit très nettement ici. Alors que PHP consomme 625.67 Ko effectifs, l'allocateur a alloué des segments de 1 Mo, la consommation réelle (real usage) est donc de 1024 Ko, soit 1 segment.
Lorsqu'on crée ensuite une chaine de 10Mo, la consommation effective passe à 10865.88 Ko, mais la réelle passe à 12288 Ko, soit 12 segments de 1 Mo. (Rappel : 1 Mo = 1024 Ko et non pas 1000 Ko).

ZEND_MM_SEG_SIZE doit représenter une puissance de 2 et ne peut être inférieur à une certaine valeur calculée par rapport aux structures internes du tas de ZendMM.
Cette valeur est dépendante de la plateforme, consultez la source pour plus d'informations.

Nouvelle version de /tmp/mem.php : Calcul des statistiques du tas de ZendMM
Sélectionnez
<?php
ini_set('memory_limit', -1); // mémoire "illimitée"
 
function get_mem_stats() {
    printf("Utilisation mémoire %.2f Ko\n", memory_get_usage() / 1024);
    if ($segSize = getenv('ZEND_MM_SEG_SIZE') {
        printf("Segmentation du tas : %d segments de %d octets (soit %d Ko utilisés)\n", memory_get_usage(1)/$segSize, $segSize, memory_get_usage(1)/1024);
    }
}
 
get_mem_stats();
 
$a = str_repeat('a', 1024*1024*10); // 10 Mo
 
echo "\n";
 
get_mem_stats();
 
Statistiques du tas de ZendMM avec notre exemple
Sélectionnez

$> ZEND_MM_SEG_SIZE=2048 php /tmp/mem.php
Utilisation mémoire 630.97 Ko
Segmentation du tas : 325 segments de 2048 octets (soit 650 Ko utilisés)
 
Utilisation mémoire 10871.18 Ko
Segmentation du tas : 5446 segments de 2048 octets (soit 10892 Ko utilisés)

On en déduit que plus les segments sont petits, plus le tas sera proche de la mémoire effectivement utilisée (donc économe), mais au plus souvent il devra créer des segments. Or la création d'un segment prend du temps, et chaque appel à l'allocateur sous-jacent (malloc par défaut) fragmente un peu plus le tas réel du processus.
C'est pour cela que par défaut, la taille d'un segment est de 256 Ko. ZendMM ne doit alors allouer que quelques segments, car la consommation mémoire de PHP dépasse rarement 2 Mo.

Nuance est faite dans le cas d'utilisation de frameworks. Regardez plutôt ceci :

Zend_Date, une classe très lourde du ZendFramework
Sélectionnez
<?php
get_mem_stats();
require 'Zend/Date.php'; // On sait que cette classe est lourde, voyons voir
 
echo "\n";
get_mem_stats();
 
Sélectionnez

$> ZEND_MM_SEG_SIZE=2048 php /tmp/mem.php 
Utilisation mémoire 630.35 Ko
Segmentation du tas : 325 segments de 2048 octets (soit 650 Ko utilisés)
 
Utilisation mémoire 4994.70 Ko
Segmentation du tas : 2687 segments de 2048 octets (soit 5374 Ko utilisés)

Oui, on passe de 630 Ko "au repos", à environ 5 Mo rien qu'en ayant analysé (parsé) le code de la classe Zend_Date et de ses dépendances. C'est normal, pour ceux qui ne connaissent pas, Zend_Date et toutes ses dépendances, ce sont environ 10.000 lignes de code PHP à parser et à mémoriser. Quand on dit qu'il ne faut pas inclure du code que l'on n'utilisera pas (d'où la notion d'autoload PHP), c'est plus clair là non ? Nous reviendrons dans un autre article sur le compilateur de PHP et la machine virtuelle.

Pour tuner la taille des segments, il faut connaitre la taille que va occuper PHP en mémoire, et utiliser une taille de segment arrondie au dessus (à la puissance de 2), pour réduire le nombre d'allocations et ajuster la consommation mémoire au plus juste. Ca parait anodin, mais si on oscille autour de deux segments, l'allocateur de ZendMM passera beaucoup de temps à allouer et libérer de la mémoire sur le tas du processus, ce qui cause inévitablement une baisse légère des performances qui pourrait avoir un impact sur du très critique.

IV-D-2. ZEND_MM_MEM_TYPE ou le choix de l'allocateur sous-jacent

Comme nous l'avons vu, l'allocateur que va utiliser ZendMM est configurable. Par défaut, il s'agit de 'malloc' ('win32' sous Windows).

Les différentes couches basses de l'allocateur
Sélectionnez

#define ZEND_MM_MEM_WIN32_DSC {"win32", zend_mm_mem_win32_init, zend_mm_mem_win32_dtor, zend_mm_mem_win32_compact, zend_mm_mem_win32_alloc,
 zend_mm_mem_win32_realloc, zend_mm_mem_win32_free}
 
#define ZEND_MM_MEM_MALLOC_DSC {"malloc", zend_mm_mem_dummy_init, zend_mm_mem_dummy_dtor, zend_mm_mem_dummy_compact, zend_mm_mem_malloc_alloc,
 zend_mm_mem_malloc_realloc, zend_mm_mem_malloc_free}

Il reste encore mmap_anon ou mmap_zero. Pour ceux qui connaissent, mmap_anon fait une projection mémoire en zone de mémoire anonyme :

Couche basse basée sur une projection mémoire anonyme via mmap et MAP_ANON
Sélectionnez

zend_mm_segment *ret = (zend_mm_segment*)mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);

mmap_zero fait la même chose, mais via le descripteur /dev/zero.

Couche basse basée sur une projection mémoire anonyme via mmap et /dev/zero
Sélectionnez

zend_mm_dev_zero_fd = open("/dev/zero", O_RDWR, S_IRUSR | S_IWUSR);
zend_mm_segment *ret = (zend_mm_segment*)mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE, zend_mm_dev_zero_fd, 0);

Il m'est arrivé une fois de tomber sur un comportement louche. Je demande à PHP (via ZendMM) d'allouer beaucoup de petits segments mémoires, en créant et remplissant un très grand tableau.
Ensuite, je libère ce tableau, avec un unset(), et là, la mémoire n'est que partiellement libérée. Voyez plutôt :

Il semblerait qu'une grosse fuite mémoire apparaisse
Sélectionnez

ini_set('memory_limit', -1);
 
function heap() {
return shell_exec(sprintf('grep "VmData:" /proc/%s/status', getmypid()));
}
 
printf ("Original heap status: %s\n", heap());
 
$a = range(1, 1024*1024); /* Crée plein de petits segments mémoires, consommateur */
 
printf("I'm now eating heap memory: %s\n", heap());
unset($a); /* Libération de la mémoire */
printf("Memory should now have been freed: %s\n", heap());

Ce code renifle le VmData fourni par le Kernel, sur le processus PHP. Il s'agit en fait d'un idicateur très précis de la consommation mémoire réelle du tas (et uniquement du tas) du processus.
Ca évite de lancer un valgrind, on a l'info immédiatement.
Si vous lancez ce script en l'état (appelé ici 'leak.php'), il va afficher quelque chose comme :

 
Sélectionnez

> php leak.php
 
Original heap status: VmData:         4504 kB
I'm now eating heap memory: VmData:    152232 kB
Memory should now have been freed: VmData:    143780 kB

Ca sent la (grosse) fuite à plein nez, pourtant, le même script lancé dans Valgrind n'indique aucune fuite. Que se passe-t-il ?

Relancez le script en changeant l'implémentation mémoire sous-jascente du ZendMM, en utilisant mmap_anon plutot que malloc de la libc :

 
Sélectionnez

>ZEND_MM_MEM_TYPE=mmap_anon php leak.php 
 
Original heap status: VmData:         4404 kB
I'm now eating heap memory: VmData:	  152116 kB
Memory should now have been freed: VmData:      4916 kB

C'est clair : mmap_anon a libéré la mémoire, mais pas l'implémentation de la libc, et c'est normal !
Souvenez-vous : selon votre OS, derrière malloc()/free() de la libc se cache une tonne de code qui tente d'être économe en cycles CPU. free() ne vas pas forcément rendre la mémoire au Kernel, free() gère une table de blocs mémoires alloués au processus courant qu'il conserve et ressert éventuellement à de futurs malloc(). Ceci évite au kernel d'allouer/désallouer des pages mémoires au processus trop fréquemment, car ces opérations sont couteuses en temps processeur. malloc()/free() de la libc agissent un peu dans le même but que le ZendMM pour PHP : éviter de faire trop travailler le processeur et le Kernel.
Mais ceci a un prix : la consommation mémoire du processus est plus importante. Dans notre cas, elle est de 143Mo au lieu de 4Mo, car la libc a jugé efficace de conserver 143Mo sur les 152Mo qui ont été libérés. Il reste aussi possible de consolider les blocs disponibles non adjascents, on appelle cela le compactage.
Un bon architecte système saura configurer l'allocateur mémoire de la libc de sa distrib, car il y a de quoi faire à ce sujet.

V. Un mot sur le "Garbage Collector"

Zend Memory Manager n'a rien à voir avec le garbage collector (ZendGC) introduit dans PHP5.3. ZendGC est un outil interne à PHP qui traque les dépendances circulaires sur les variables PHP (zval) et les nettoie lorsque nécessaire.
ZendGC est apparu bien après ZendMM, et est d'ailleurs désactivable au runtime alors que ZendMM n'est désactivable (pas conseillé du tout, sauf pour du debug) qu'à l'invocation de PHP.

Le garbage collector (ZendGC) n'a rien à voir avec le gestionnaire de mémoire (ZendMM). ZendGC n'interagit jamais directement avec ZendMM. ZendGC s'occupe de scruter les zval possédant des références circulaires et de les nettoyer (libérant effectivement la mémoire qu'elles occupaient) alors que le programmeur ne peut plus le faire manuellement.

Le garbage collector ZendGC libère la mémoire qu'occupent les variables PHP mal conçues (possédant des références circulaires inatteignables) dans un script et c'est tout.
Lisez donc son fonctionnement ici, ou apprenez comment les variables PHP sont gérées en interne par là.
ZendMM est une couche de bas niveau dans PHP gérant toutes les allocations dynamiques de mémoire de ses composants.

Image non disponible
ZendMM contre ZendGC

VI. Quelques benchmarks

VI-A. Benchmark applicatif

J'ai réalisé quelques benchmarks rapidement. Machine Ubuntu avec PHP 5.3.5 compilé main en mode debug SAPI apxs2 classique sans cache d'OPCodes. J'ai utilisé PHP frameworks benchmarks comme base de tests, je l'ai retouché légèrement pour le tourner à ma guise.
Les tests portent juste sur un hello world Zend Framework 1.10.x, rien de bien efficace, j'admets. J'ai joué sur USE_ZEND_ALLOC, ZEND_MM_MEM_TYPE et ZEND_MM_SEG_SIZE.

Rappelons-nous qu'un simple "hello world" Zend Framework, ce sont des dizaines de fichiers PHP inclus, ce sont des centaines de lignes de code exécutées, ce sont de gros objets... bref : ce sont beaucoup d'allocations/libérations de mémoire, et c'est ce qui m'intéresse ici. Bien sûr libre à chacun de jouer avec une application réelle.
De manière générale, l'utilisation d'un framework fait énormément travailler PHP, sur tous les points (mémoire, processeur) ; donc la moindre optimisation (ou dégradation) de bas niveau va immédiatement avoir un impact très important sur l'applicatif.

Benchmarks. 20 secondes, concurrence de 10, pas de cache d'OPCode
Référence USE_ZEND_ALLOC = 0 ZEND_MM_MEM_TYPE=mmap_anon ZEND_MM_SEG_SIZE=2M ZEND_MM_SEG_SIZE=2K
25 rq/s 22.47 rq/s 14.08 rq/s 14.57 rq/s 24.4 rq/s

Je rappelle que la référence est ZEND_MM_MEM_TYPE=malloc et ZEND_MM_SEG_SIZE=256k (valeurs par défaut que PHP utilise). On voit que ce sont elles qui donnent le meilleur résultat.
En fait j'ai un réglage qui donne un meilleur résultat, c'est avec des segments de 64 Ko, j'arrive à 26 rq/s soit un gain de 4 %.
A chacun de faire ses essais sur sa plateforme, par exemple chez moi, "mmap_zero" n'est pas disponible et je n'ai pas particulièrement tunné mon kernel.

VI-B. Benchmark interne

Ici, il s'agit de tracer très précisément ce qu'il se passe dans le moteur de PHP au niveau de la mémoire. Pour cela, nous allons utiliser un outil hyperpuissant : Valgrind avec Massif.
Cela va nous permettre de mettre en évidence le fonctionnement interne de la gestion de la mémoire de PHP, notamment au travers du Zend Memory Manager.

Le script utilisé est très simple :

/tmp/void.php
Sélectionnez
<?php
echo "hello world";

De cette manière, on va mesurer la consommation de PHP et non du script qu'il lance, celui-ci étant réduit à quasiment rien du tout.

 
Sélectionnez

> valgrind --tool=massif --massif-out-file=massif.out --max-snapshots=1000 --stacks=yes
 php /tmp/void.php && ms_print massif.out > massif.txt

Voila rapidement ce qu'il en ressort. Pour ceux qui ne connaissent pas Massif, voici sa documentation

La pyramide de Massif
Sélectionnez

    MB
1.984^                                                               :        
     |                                                             #@@@:      
     |                                                             #@@@:      
     |                                                             #@@@:      
     |                                                             #@@@:      
     |                                                             #@@@:      
     |                                                          @@@#@@@@@     
     |                                                       @@@@@@#@@@@@@    
     |                                                    @@@@@@@@@#@@@@@@@   
     |                                                 @@@@@@@@@@@@#@@@@@@@:  
     |                                              :@:@@@@@@@@@@@@#@@@@@@@@  
     |                                           @@@@@:@@@@@@@@@@@@#@@@@@@@@  
     |                                       @@@@@@@@@:@@@@@@@@@@@@#@@@@@@@@: 
     |                                   @:@@@@@@@@@@@:@@@@@@@@@@@@#@@@@@@@@@ 
     |                                 @@@:@@@@@@@@@@@:@@@@@@@@@@@@#@@@@@@@@@@
     |                                 @@@:@@@@@@@@@@@:@@@@@@@@@@@@#@@@@@@@@@@
     |                                 @@@:@@@@@@@@@@@:@@@@@@@@@@@@#@@@@@@@@@@
     |      @:@:@@::@@@:::@@@@@@@@@@@@@@@@:@@@@@@@@@@@:@@@@@@@@@@@@#@@@@@@@@@@
     |    @@@:@:@@::@@@:::@@@@@@@@@@@@@@@@:@@@@@@@@@@@:@@@@@@@@@@@@#@@@@@@@@@@
     |    @@@:@:@@::@@@:::@@@@@@@@@@@@@@@@:@@@@@@@@@@@:@@@@@@@@@@@@#@@@@@@@@@@
   0 +----------------------------------------------------------------------->Mi
     0                                                                   7.483
Number of snapshots: 959
 Detailed snapshots: [1, 9, 15, 17, 28, 39, 47, 54, 57, 63, 82, 104, 112, 120, 144, 150, 152, 156,
 164, 202, 219, 224, 237, 245, 250, 255, 257, 260, 262, 264, 274, 280, 285, 295, 304, 307, 314, 333,
 342, 347, 363, 382, 387, 394, 397, 404, 417, 419, 422, 437, 440, 444, 449, 460, 462, 473, 491, 494,
 498, 507, 516, 526, 534, 544, 545, 555, 565, 566, 575, 585, 593, 601, 607, 614, 624, 632, 637, 647,
 654, 663, 673, 674, 684, 686, 696, 700, 710, 712, 722, 723, 733, 735, 745, 747, 755 (peak), 765, 775,
 785, 795, 805, 815, 825, 835, 845, 855, 865, 875, 885, 895, 905, 915, 925, 935, 945, 955]

La consommation maximale est de 1.984 Mo (arrondissons à 2 Mo) et Massif a pris 959 snapshots et le pique de mémoire est au 755è. Attention, il s'agit de la consommation mémoire de mon PHP, avec mes extensions, sur ma plateforme, etc.
Moins vous activez d'extensions PHP, moins sa consommation mémoire sera élevée.

Timeshot 54 : Consommation mémoire de PHP après 577000 instructions
Sélectionnez

--------------------------------------------------------------------------------
  n        time(i)         total(B)   useful-heap(B) extra-heap(B)    stacks(B)
--------------------------------------------------------------------------------
 51        544,385          282,768          279,361         1,423        1,984
 52        555,883          285,232          281,622         1,626        1,984
 53        562,633          286,760          282,909         1,747        2,104
 54        577,034          289,872          285,753         2,199        1,920
98.58% (285,753B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc.
->90.43% (262,144B) 0x5CF587: zend_mm_mem_malloc_alloc (zend_alloc.c:283)
| ->90.43% (262,144B) 0x5D2254: _zend_mm_alloc_int (zend_alloc.c:1898)
|   ->90.43% (262,144B) 0x5D035D: zend_mm_startup_ex (zend_alloc.c:1090)
|     ->90.43% (262,144B) 0x5D0792: zend_mm_startup (zend_alloc.c:1184)
|       ->90.43% (262,144B) 0x5D4218: alloc_globals_ctor (zend_alloc.c:2558)
|         ->90.43% (262,144B) 0x5D42AA: start_memory_manager (zend_alloc.c:2583)
|           ->90.43% (262,144B) 0x5FA163: zend_startup (zend.c:610)
|             ->90.43% (262,144B) 0x57B950: php_module_startup (main.c:1852)
|               ->90.43% (262,144B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
|                 ->90.43% (262,144B) 0x6E4A04: main (php_cli.c:776)
|                   
->06.29% (18,219B) 0x609FF6: __zend_malloc (zend_alloc.h:95)
| ->04.42% (12,816B) 0x60AA02: _zend_hash_add_or_update (zend_hash.c:256)
| | ->04.24% (12,296B) 0x6039BA: zend_register_functions (zend_API.c:1898)
| | | ->04.24% (12,296B) 0x602B42: zend_register_module_ex (zend_API.c:1714)
| | |   ->04.24% (12,296B) 0x60FC20: zend_startup_builtin_functions (zend_builtin_functions.c:320)
| | |     ->04.24% (12,296B) 0x5FA556: zend_startup (zend.c:696)
| | |       ->04.24% (12,296B) 0x57B950: php_module_startup (main.c:1852)
| | |         ->04.24% (12,296B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
| | |           ->04.24% (12,296B) 0x6E4A04: main (php_cli.c:776)
| | |             
| | ->00.18% (520B) in 1+ places, all below ms_print's threshold (01.00%)
| | 
| ->01.86% (5,403B) 0x60A953: _zend_hash_add_or_update (zend_hash.c:250)
|   ->01.57% (4,557B) 0x6039BA: zend_register_functions (zend_API.c:1898)
|   | ->01.57% (4,557B) 0x602B42: zend_register_module_ex (zend_API.c:1714)
|   |   ->01.57% (4,557B) 0x60FC20: zend_startup_builtin_functions (zend_builtin_functions.c:320)
|   |     ->01.57% (4,557B) 0x5FA556: zend_startup (zend.c:696)
|   |       ->01.57% (4,557B) 0x57B950: php_module_startup (main.c:1852)
|   |         ->01.57% (4,557B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
|   |           ->01.57% (4,557B) 0x6E4A04: main (php_cli.c:776)
|   |             
|   ->00.29% (846B) in 1+ places, all below ms_print's threshold (01.00%)
|   
->01.86% (5,390B) in 11 places, all below massif's threshold (01.00%)

A 500.000 instructions environ, PHP consomme 290 Ko (arrondis) de mémoire sur le tas et 2 Ko (arrondis) sur la pile. Seul le tas va nous intéresser.
90 % de cette mémoire a été allouée par zend_mm_mem_malloc_alloc(), souvenez-vous, c'est la fonction bas niveau utilisée par défaut par ZendMM, elle appelle malloc() de la libc. Elle consomme ici 262 Ko, c'est en fait la taille d'un segment mémoire (256 Ko par défaut) + quelques Kilo-octets pour les structures de contrôle et l'alignement. On est juste.
Pourquoi ce segment est-il créé ? Regardez la stack : zend_mm_startup() a été appelée, elle démarre le gestionnaire de mémoire qui s'auto-affecte immédiatement un segment dit "de secours" ("reserved"), au cas où le système refuserait dans le futur d'allouer de la mémoire, ZendMM pourra alors utiliser cet espace pour continuer de faire vivre PHP et afficher une erreur fatale.
6.2 % ont été alloués par __zend_malloc(). C'est très simple : __zend_malloc() proxie directement sur malloc() de la libc : elle représente toutes les allocations que PHP a fait sans passer par son gestionnaire de mémoire.
Souvenez-vous, PHP et le Zend Engine ne passent pas tout le temps par ZendMM, tout ce qui passe outre utilise en général soit pmalloc() (vue dans les chapitres précédents), soit __zend_malloc() soit carrément directement malloc() (peu conseillé).

Bonne déduction : tout ce qui passe par __zend_malloc() n'est pas comptabilisé par la fonction PHP memory_get_usage(), puisque ça court-circuite le gestionnaire de mémoire.

Timeshot 337 : Consommation mémoire de PHP après 3.6M instructions
Sélectionnez

--------------------------------------------------------------------------------
  n        time(i)         total(B)   useful-heap(B) extra-heap(B)    stacks(B)
--------------------------------------------------------------------------------
336      3,618,476          353,600          336,683        15,205        1,712
337      3,628,360          675,888          658,754        15,582        1,552
338      3,637,067          678,472          660,456        15,960        2,056
339      3,645,857          680,904          662,631        16,169        2,104
340      3,653,094          683,256          664,504        16,360        2,392
341      3,668,442          686,336          667,691        16,661        1,984
342      3,678,366          689,072          669,921        16,895        2,256
97.22% (669,921B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc.
->46.44% (320,000B) 0x621E89: gc_init (zend_gc.c:124)
| ->46.44% (320,000B) 0x5F8F8C: OnUpdateGCEnabled (zend.c:84)
|   ->46.44% (320,000B) 0x615E2C: zend_register_ini_entries (zend_ini.c:214)
|     ->46.44% (320,000B) 0x5FA5A7: zend_register_standard_ini_entries (zend.c:719)
|       ->46.44% (320,000B) 0x57BF38: php_module_startup (main.c:1981)
|         ->46.44% (320,000B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
|           ->46.44% (320,000B) 0x6E4A04: main (php_cli.c:776)
|             
->38.04% (262,144B) 0x5CF587: zend_mm_mem_malloc_alloc (zend_alloc.c:283)
| ->38.04% (262,144B) 0x5D2254: _zend_mm_alloc_int (zend_alloc.c:1898)
|   ->38.04% (262,144B) 0x5D035D: zend_mm_startup_ex (zend_alloc.c:1090)
|     ->38.04% (262,144B) 0x5D0792: zend_mm_startup (zend_alloc.c:1184)
|       ->38.04% (262,144B) 0x5D4218: alloc_globals_ctor (zend_alloc.c:2558)
|         ->38.04% (262,144B) 0x5D42AA: start_memory_manager (zend_alloc.c:2583)
|           ->38.04% (262,144B) 0x5FA163: zend_startup (zend.c:610)
|             ->38.04% (262,144B) 0x57B950: php_module_startup (main.c:1852)
|               ->38.04% (262,144B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
|                 ->38.04% (262,144B) 0x6E4A04: main (php_cli.c:776)
|                   
->10.34% (71,281B) 0x609FF6: __zend_malloc (zend_alloc.h:95)
| ->05.18% (35,712B) 0x60AA02: _zend_hash_add_or_update (zend_hash.c:256)
| | ->02.79% (19,256B) 0x6039BA: zend_register_functions (zend_API.c:1898)
| | | ->02.79% (19,256B) 0x602B42: zend_register_module_ex (zend_API.c:1714)
| | |   ->01.78% (12,296B) 0x60FC20: zend_startup_builtin_functions (zend_builtin_functions.c:320)
| | |   | ->01.78% (12,296B) 0x5FA556: zend_startup (zend.c:696)
| | |   |   ->01.78% (12,296B) 0x57B950: php_module_startup (main.c:1852)
| | |   |     ->01.78% (12,296B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
| | |   |       ->01.78% (12,296B) 0x6E4A04: main (php_cli.c:776)
| | |   |         
| | |   ->01.01% (6,960B) 0x602BD0: zend_register_internal_module (zend_API.c:1729)
| | |     ->01.01% (6,960B) 0x57B7CF: php_register_extensions (main.c:1740)
| | |       ->01.01% (6,960B) 0x6E5E83: php_register_internal_extensions (internal_functions_cli.c:52)
| | |         ->01.01% (6,960B) 0x57BFFE: php_module_startup (main.c:2011)
| | |           ->01.01% (6,960B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
| | |             ->01.01% (6,960B) 0x6E4A04: main (php_cli.c:776)
| | |               
| | ->01.37% (9,464B) 0x615D1F: zend_register_ini_entries (zend_ini.c:199)
| | | ->01.34% (9,256B) 0x57BF33: php_module_startup (main.c:1978)
| | | | ->01.34% (9,256B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
| | | |   ->01.34% (9,256B) 0x6E4A04: main (php_cli.c:776)
| | | |     
| | | ->00.03% (208B) in 1+ places, all below ms_print's threshold (01.00%)
| | | 
| | ->01.01% (6,992B) in 8 places, all below massif's threshold (01.00%)
| |   
| ->05.09% (35,061B) 0x60A953: _zend_hash_add_or_update (zend_hash.c:250)
| | ->01.90% (13,099B) 0x585606: php_ini_parser_cb (php_ini.c:240)
| | | ->01.90% (13,099B) 0x5C552F: ini_parse (zend_ini_parser.y:289)
| | |   ->01.90% (13,099B) 0x5C496C: zend_parse_ini_file (zend_ini_parser.y:206)
| | |     ->01.90% (13,099B) 0x5867E2: php_init_config (php_ini.c:683)
| | |       ->01.90% (13,099B) 0x57BF13: php_module_startup (main.c:1973)
| | |         ->01.90% (13,099B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
| | |           ->01.90% (13,099B) 0x6E4A04: main (php_cli.c:776)
| | |             
| | ->01.16% (7,963B) 0x615D1F: zend_register_ini_entries (zend_ini.c:199)
| | | ->01.13% (7,790B) 0x57BF33: php_module_startup (main.c:1978)
| | | | ->01.13% (7,790B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
| | | |   ->01.13% (7,790B) 0x6E4A04: main (php_cli.c:776)
| | | |     
| | | ->00.03% (173B) in 1+ places, all below ms_print's threshold (01.00%)
| | | 
| | ->01.02% (7,062B) 0x6039BA: zend_register_functions (zend_API.c:1898)
| | | ->01.02% (7,062B) 0x602B42: zend_register_module_ex (zend_API.c:1714)
| | |   ->01.02% (7,062B) in 2 places, all below massif's threshold (01.00%)
| | |     
| | ->01.01% (6,937B) in 8 places, all below massif's threshold (01.00%)
| |   
| ->00.07% (508B) in 1+ places, all below ms_print's threshold (01.00%)
| 
->02.39% (16,496B) in 29 places, all below massif's threshold (01.00%)

Voila qui est intéressant, entre le timeshot 336 et le 337, la consommation mémoire se met à quasiment doubler brusquement, passant de 350 à 675 Ko. Le fautif ? "zend_gc" : le fameux garbage collector.
Certes il est pratique (voir chapitre précédent), mais il consomme 320 Ko de mémoire (par défaut, configurable dans la source), il faut le savoir.
Notons au passage le négligeable (quelques pour cent) : les directives du fichier php.ini par exemple.

Timeshot 755, la machine virtuelle Zend entre en jeu
Sélectionnez

--------------------------------------------------------------------------------
  n        time(i)         total(B)   useful-heap(B) extra-heap(B)    stacks(B)
--------------------------------------------------------------------------------
754      6,723,623        1,539,152        1,432,702       104,674        1,776
755      6,727,536        2,063,544        1,956,990       104,682        1,872
94.84% (1,956,990B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc.
->38.11% (786,432B) 0x5CF587: zend_mm_mem_malloc_alloc (zend_alloc.c:283)
| ->38.11% (786,432B) 0x5D2254: _zend_mm_alloc_int (zend_alloc.c:1898)
|   ->25.41% (524,288B) 0x5D3C07: _emalloc (zend_alloc.c:2340)
|   | ->25.41% (524,288B) 0x5E7D30: zend_vm_stack_new_page (zend_execute.h:163)
|   |   ->25.41% (524,288B) 0x5E7D80: zend_vm_stack_init (zend_execute.h:173)
|   |     ->25.41% (524,288B) 0x5E8245: init_executor (zend_execute_API.c:158)
|   |       ->25.41% (524,288B) 0x5FA8F5: zend_activate (zend.c:850)
|   |         ->25.41% (524,288B) 0x57ACA8: php_request_startup (main.c:1407)
|   |           ->25.41% (524,288B) 0x6E526A: main (php_cli.c:1089)
|   |             
|   ->12.70% (262,144B) 0x5D184F: zend_mm_shutdown (zend_alloc.c:1657)
|   | ->12.70% (262,144B) 0x5D4204: shutdown_memory_manager (zend_alloc.c:2552)
|   |   ->12.70% (262,144B) 0x57C1D3: php_module_startup (main.c:2098)
|   |     ->12.70% (262,144B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
|   |       ->12.70% (262,144B) 0x6E4A04: main (php_cli.c:776)
|   |         
|   ->00.00% (0B) in 1+ places, all below ms_print's threshold (01.00%)
|   
->34.48% (711,530B) 0x609FF6: __zend_malloc (zend_alloc.h:95)
| ->15.43% (318,408B) 0x60AA02: _zend_hash_add_or_update (zend_hash.c:256)
| | ->13.60% (280,720B) 0x6039BA: zend_register_functions (zend_API.c:1898)
| | | ->07.36% (151,960B) 0x602B42: zend_register_module_ex (zend_API.c:1714)
| | | | ->06.73% (138,968B) 0x602BD0: zend_register_internal_module (zend_API.c:1729)
| | | | | ->06.73% (138,968B) 0x57B7CF: php_register_extensions (main.c:1740)
| | | | |   ->06.73% (138,968B) 0x6E5E83: php_register_internal_extensions (internal_functions_cli.c:52)
| | | | |     ->06.73% (138,968B) 0x57BFFE: php_module_startup (main.c:2011)
| | | | |       ->06.73% (138,968B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
| | | | |         ->06.73% (138,968B) 0x6E4A04: main (php_cli.c:776)
| | | | |           
| | | | ->00.63% (12,992B) in 1+ places, all below ms_print's threshold (01.00%)
| | | | 
| | | ->06.23% (128,528B) 0x6046E5: do_register_internal_class (zend_API.c:2181)
| | | | ->05.97% (123,192B) 0x60495F: zend_register_internal_class (zend_API.c:2237)
| | | | | ->02.34% (48,256B) 0x6047EC: zend_register_internal_class_ex (zend_API.c:2209)
| | | | | | ->01.71% (35,264B) 0x4A6908: spl_register_sub_class (spl_functions.c:61)
| | | | | | | ->01.71% (35,264B) in 22 places, all below massif's threshold (01.00%)
| | | | | | |   
| | | | | | ->00.63% (12,992B) in 1+ places, all below ms_print's threshold (01.00%)
| | | | | | 
| | | | | ->02.19% (45,240B) 0x4A6759: spl_register_std_class (spl_functions.c:46)
| | | | | | ->02.19% (45,240B) in 12 places, all below massif's threshold (01.00%)
| | | | | |   
| | | | | ->01.44% (29,696B) in 10 places, all below massif's threshold (01.00%)
| | | | |   
| | | | ->00.26% (5,336B) in 1+ places, all below ms_print's threshold (01.00%)
| | | | 
| | | ->00.01% (232B) in 1+ places, all below ms_print's threshold (01.00%)
| | | 
| | ->01.83% (37,688B) in 12 places, all below massif's threshold (01.00%)
| |   
| ->08.70% (179,467B) 0x60A953: _zend_hash_add_or_update (zend_hash.c:250)
| | ->04.84% (99,794B) 0x6039BA: zend_register_functions (zend_API.c:1898)
| | | ->02.65% (54,774B) 0x602B42: zend_register_module_ex (zend_API.c:1714)
| | | | ->02.42% (49,955B) 0x602BD0: zend_register_internal_module (zend_API.c:1729)
| | | | | ->02.42% (49,955B) 0x57B7CF: php_register_extensions (main.c:1740)
| | | | |   ->02.42% (49,955B) 0x6E5E83: php_register_internal_extensions (internal_functions_cli.c:52)
| | | | |     ->02.42% (49,955B) 0x57BFFE: php_module_startup (main.c:2011)
| | | | |       ->02.42% (49,955B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
| | | | |         ->02.42% (49,955B) 0x6E4A04: main (php_cli.c:776)
| | | | |           
| | | | ->00.23% (4,819B) in 1+ places, all below ms_print's threshold (01.00%)
| | | | 
| | | ->02.18% (44,946B) 0x6046E5: do_register_internal_class (zend_API.c:2181)
| | | | ->02.09% (43,106B) 0x60495F: zend_register_internal_class (zend_API.c:2237)
| | | | | ->02.09% (43,106B) in 12 places, all below massif's threshold (01.00%)
| | | | |   
| | | | ->00.09% (1,840B) in 1+ places, all below ms_print's threshold (01.00%)
| | | | 
[...]
| 
->15.51% (320,000B) 0x621E89: gc_init (zend_gc.c:124)
| ->15.51% (320,000B) 0x5F8F8C: OnUpdateGCEnabled (zend.c:84)
|   ->15.51% (320,000B) 0x615E2C: zend_register_ini_entries (zend_ini.c:214)
|     ->15.51% (320,000B) 0x5FA5A7: zend_register_standard_ini_entries (zend.c:719)
|       ->15.51% (320,000B) 0x57BF38: php_module_startup (main.c:1981)
|         ->15.51% (320,000B) 0x6E3BE9: php_cli_startup (php_cli.c:402)
|           ->15.51% (320,000B) 0x6E4A04: main (php_cli.c:776)
|             
->02.67% (55,080B) 0x60463B: do_register_internal_class (zend_API.c:2171)
| ->02.27% (46,920B) 0x60495F: zend_register_internal_class (zend_API.c:2237)
| | ->01.48% (30,600B) 0x6047EC: zend_register_internal_class_ex (zend_API.c:2209)
| | | ->01.19% (24,480B) 0x4A6908: spl_register_sub_class (spl_functions.c:61)
| | | | ->01.19% (24,480B) in 36 places, all below massif's threshold (01.00%)
| | | |   
[...]

Timeshot 755 : +500 Ko (environ). En lisant l'appel, c'est clair : zend_vm_stack_init() est une fonction du moteur d'exécution de scripts PHP, la Zend Virtual Machine (ZendVM) : elle consomme deux segments mémoire de 256 Ko chacun tout pile (elle alloue très exactement 512Ko).

VIII. Conclusions

Zend Memory Manager (ZendMM) est donc une surcouche d'accès à la mémoire dynamique en C, lorsqu'on code dans le coeur de PHP. Elle a un temps de vie pour la requête courante, c'est-à-dire que chaque allocation faite via ZendMM va être taguée comme faisant partie de la requête en cours. Lorsque PHP arrive dans sa phase de destruction de requête, ZendMM va libérer automatiquement toute la mémoire qui a été allouée via ses fonctions (ceci est malpropre, en mode debug, ZendMM va générer un avertissement concernant une fuite mémoire).

En regardant un peu comment fonctionne cette couche, on voit qu'on peut légèrement la personnaliser au travers de variables d'environnement. Il est aussi possible d'interagir avec le gestionnaire de mémoire depuis PHP, au moyen des fonctions memory_get_usage() et autres. Enfin, ZendMM assure la possibilité de limiter la consommation d'un script sur une requête au moyen du paramètre de php.ini memory_limit.

Pour aller plus loin, je recommande : Fonctionnement global de PHP et modèle d'exécution interne
Maitrise de la gestion interne des variables PHP
l'article sur le wiki de PHP concernant ZendMM

Remerciements à Mahefasoa, jacques_jean et aieeeuuuuu pour leurs relectures.