PHP 5.3 : Garbage Collector et consommation mémoire

30 octobre 2008optimisation, php, php-5.3
 Cet article a été rédigé il y a plusieurs années et peut ne plus être tout à fait à jour…

Les exemples correspondant à ce point se trouvent dans le répertoire "garbage-collector".

PHP 5.3 apporte, via la mise en place d'un Garbage Collector, une solution aux problématiques de fuite mémoire que nous rencontrions souvent -- en particulier dans le cas de références cycliques.

Sommaire :


Ajout d'un Garbage Collector

Contexte : fuites mémoire

Au départ, PHP a été conçu pour le développement de << simples >> scripts, regroupés pour constituer des sites Web... << Personnels >>. Souvenez-vous, du temps de PHP << Personnal Home Page >> 1 et 2 !

Avec les années, les nouvelles versions, et le nombre de développeurs, PHP est petit à petit devenu un langage utilisé sur de gros sites, sur de grosses applications, que ce soit en se basant sur des Frameworks complexes, ou pour le développement de traitements batch pouvant parfois tourner pendant des heures[1].

Malheureusement, c'est dans ce genre de conditions qu'un des problèmes de PHP devient nettement visible : la gestion de la mémoire, telle qu'elle est intégrée à PHP <= 5.2 n'est pas toujours idéale ; en particulier, PHP est connu pour souffrir d'un problème de fuites mémoire.

Typiquement, c'est un problème qui se rencontre dans le cas d'utilisation de références cycliques : PHP jusqu'à la version 5.2 comprise ne sait pas libérer la mémoire dans cette situation, même si les variables ne sont plus utilisées... Et l'occupation mémoire de notre programme monte, monte, et monte encore, sans jamais redescendre.
Ceci n'est pas extrêmement gênant dans le cas de scripts ne durant que quelques centaines de millisecondes au maximum... Mais ça le devient lorsqu'il s'agit d'un traitement devant éventuellement durer des heures !


Ajout des fonctions gc_*

En réponse à cette problèmatique, PHP 5.3 introduit à la fois :

  • Un Garbage Collector, dont la mission est de libérer la mémoire qui peut l'être, typiquement en << moissonnant >> les références cycliques au niveau des variables qui ne sont plus utilisées,
  • et les fonctions << gc_* >> pour le contrôler.

Normalement, avec la configuration par défaut, le garbage collector est activé...
Mais vous pouvez vous en assurer à l'aide de la fonction gc_enabled :

echo "Garbage Colletor enabled (défaut) : " . (gc_enabled() ? 'OUI' : 'NON') . "\n";

Dans tous les cas, le garbage collector peut être activé avec la fonction suivante :

gc_enable();

Et désactivé avec :

gc_disable();

Enfin, une libération de la mémoire << gaspillée >> peut être lancée avec la fonction gc_collect_cycles :

gc_collect_cycles();

Celle-ci retourne le nombre de cycles libérés.

Et pour les plus curieux -- et motivés : ce n'est pas forcément une lecture facile -- vous pouvez télécharger les explications à propos de l'algorithme de garbage collection utilisé.


Références cycliques : exemple 1

Le premier exemple que je prendrai pour montrer l'intérêt et les capacités du garbage collector ajouté à PHP 5.3 est issu de l'article Optimize PHP memory usage: eliminate circular references posté par Alex Netkachov il y a maintenant plus d'un an.

Le principe est le suivant : on défini une classe et une fonction, le regroupement des deux créant une référence circulaire :

class Node {
    public $parentNode;
    public $childNodes = array();
    function Node() {
        $this->nodeValue = str_repeat('0123456789', 128);
    }
}
function createRelationship() {
    $parent = new Node();
    $child = new Node();
    $parent->childNodes[] = $child;
    $child->parentNode = $parent;
}

Pour chaque élément, l'attribut childNodes contient une référence vers un enfant, et l'attribut parentNode de cet enfant est une référence vers son attribut père.
Pour illustrer, ça donne quelque chose de ce genre :

garbage-collector-1-circular-reference.png

Avec le problème qui va derrière : comment libérer la mémoire allouée à l'un des deux objets, alors que l'autre pointe encore sur lui ?

Pour voir ce que ça donne en terme d'occupation mémoire, utilisons la fonction suivante :

function test() {
    echo 'Avant : ' . number_format(memory_get_usage(), 0, '.', ',') . " octets\n";
    for($i = 0; $i < 10000; $i++) {
        createRelationship();
    }
    echo 'Pic : ' . number_format(memory_get_peak_usage(), 0, '.', ',') . " octets\n";
    echo 'Après : ' . number_format(memory_get_usage(), 0, '.', ',') . " octets\n";
}

Cette fonction affiche l'espace mémoire occupé, créée plusieurs milliers d'objets avec références circulaires, et termine en affichant la quantité mémoire utilisée au maximum pendant son exécution, ainsi que la quantité mémoire utilisée à la fin de la boucle.


Garbage Collector activé

En appelant cette méthode avec le garbage collector de PHP 5.3 activé, qu'obtenons-nous ?

gc_enable();
echo "Garbage Colletor enabled : " . (gc_enabled() ? 'OUI' : 'NON') . "\n";
test();
echo "Collect cycles : " . gc_collect_cycles() . "\n";
echo 'Mem après collect_cycles : ' . number_format(memory_get_usage(), 0, '.', ',') . " octets\n";

Juste par curiosité, nous forçons une purge des cycles mémoires avant de terminer, pour voir s'il est possible de re-gagner un peu de mémoire, qui n'ait pas été libérée automatiquement.

La sortie est la suivante :

Garbage Colletor enabled : OUI
Avant : 335,588 octets
Pic : 17,868,036 octets
Après : 963,004 octets
Collect cycles : 10
Mem après collect_cycles : 956,868 octets

En somme, nous faisons un pic à 17 Mo de mémoire occupée, mais une fois la boucle terminée, nous sommes redescendus, automatiquement, à une valeur plus acceptable, aux environs de 1 Mo.
Le gc_collect_cyles final libère 10 cycles, et permet de gagner quelques Ko -- ce qui semble indiquer que le garbage collector soit ne libère pas toute la mémoire occupée, soit ne la libère pas immédiatement (nous en dirons plus à ce sujet un peu plus bas).


Garbage Collector désactivé

Exécutons maintenant la même portion de code en désactivant le Garbage Collector :

gc_disable();
echo "Garbage Colletor enabled : " . (gc_enabled() ? 'OUI' : 'NON') . "\n";
test();
echo "Collect cycles : " . gc_collect_cycles() . "\n";
echo 'Mem après collect_cycles : ' . number_format(memory_get_usage(), 0, '.', ',') . " octets\n";

Cette fois, la sortie obtenue est la suivante :

Garbage Colletor enabled : NON
Avant : 956,868 octets
Pic : 35,433,568 octets
Après : 35,433,680 octets
Collect cycles : 25000
Mem après collect_cycles : 18,521,244 octets

Le pic de mémoire utilisée est deux fois plus important que lorsque le Garbage Collector était activé : 35 Mo !
Et, surtout, la quantité mémoire utilisée ne baisse pas après la fin de la bouble for : elle ne diminue que lorsque nous forçons le lancement du Garbage Collector à la main, via un appel à gc_collect_cycles.
Dans ce cas, on a 25,000 cycles de libérés... Mais la quantité mémoire occupée ne diminue pas autant que lorsque le Garbage Collector était activé : nous ne récupérons que la moitié de la mémoire perdue !

Notons que le fonctionnement avec le Garbage Collector désactivé est exactement le même que celui obtenu en PHP 5.2 : les références cycliques ne sont pas libérées, la quantité mémoire occupée monte, monte, et continue à monter, sans jamais redescendre...
... Et en PHP 5.2, nous ne pouvions rien y faire -- ou pas simplement, du moins : la fonction gc_collect_cycles, qui arrange un peu les choses, n'existait de toute manière pas !


Références cycliques : exemple 2

Le second exemple que je présenterai est extrait du Bug PHP n°33595, et de l'article Memory Leaks With Objects in PHP 5 rédigé par Paul M. Jones en septembre 2007.

Le principe est identique à celui que nous avons vu juste au-dessus : créer deux classes qui se référencent l'une-l'autre.

Voici la première, qui stocke en variable de classe une instance de la seconde :

class A {
    function __construct () {
        $this->b = new B($this);
    }
}

Et la seconde, qui est créée avec une référence sur une instance de la première :

class B {
    function __construct ($parent = NULL) {
        $this->parent = $parent;
    }
}

Et pour quelques lignes de code utilisant ces deux classes :

for ($i = 0 ; $i < 1000000 ; $i++) {
    $a = new A();
}

Pour finir, à la fin de l'exécution de cette boucle créant des références cycliques, affichons la quantité mémoire maximale utilisée par le script :

echo 'Pic : ' . number_format(memory_get_peak_usage(), 0, '.', ',') . " octets\n";

Et maintenant, exécutons celui-ci sur une machine PHP 5.3 (avec Garbage Collector activé) et une machine 5.2 (ou une machine 5.3 avec Garbage Collector désactivé), en changeant le nombre d'itérations de la boucle for.
Voici les résultats obtenus :

  • Pour 1000 itérations :
    • PHP 5.3 (gc_enabled) : Pic : 727,568 octets
    • PHP 5.2 (no gc) : Pic : 2,108,996 octets
  • Pour 10000 itérations :
    • PHP 5.3 (gc_enabled) : Pic : 2,674,776 octets
    • PHP 5.2 (no gc) : Pic : 7,562,808 octets
  • Pour 100000 itérations :
    • PHP 5.3 (gc_enabled) : Pic : 2,681,632 octets
    • PHP 5.2 (no gc) : Pic : 60,232,328 octets
  • Et pour 1000000 itérations :
    • PHP 5.3 (gc_enabled) : Pic : 2,750,396 octets
    • PHP 5.2 (no gc) : Fatal error: Allowed memory size of 268435456 bytes exhausted (tried to allocate 12 bytes)

En somme, à partir d'un nombre suffisant d'itérations, la quantité maximale de mémoire utilisée par le script en PHP 5.3 cesse quasiment d'augmenter : elle plafonne à un peu plus de 2.5 Mo.

Par contre, comme nous pouvions nous y attendre considérant le sujet de cet article, en PHP 5.2, elle ne cesse d'augmenter, et finit tôt ou tard par atteindre la quantité maximale de mémoire autorisée pour un script (quantité configurée par la directive memory_limit).
Et encore, ici, j'ai été extrêmement généreux sur la memory_limit :

# grep 'memory_limit' /etc/php5/apache2/php.ini
memory_limit = 256M ; Maximum amount of memory a script may consume (128MB)

256 Mo est une valeur totalement inconcevable pour un environnement de production (en particulier pour un site Web susceptible de recevoir plus 3 ou 4 utilisateurs en simultané ^^ ) !
Et cette valeur en suffit pas pour PHP 5.2... Alors que PHP 5.3 avec le Garbage Collector activé se contente d'à peine 3 Mo !

Accessoirement, je vous laisse jeter un oeil au rapport de Bug n° 33595 sur php.net pour une solution permettant de résoudre ce problème en PHP 5.2 -- mais c'est à la limite de l'infaisable à mettre en place, en particulier si vous souffrez de ce problème en plus d'un ou deux endroits dans votre code !


Mémoire occupée et destructeurs : graphique

Quand est-ce que le Garbage Collector se déclenche ?

Avant de terminer, entrons un peu plus en avant dans le fonctionnement du Garbage Collector : une question que je me suis posé en voyant les résultats obtenu lors de l'exécution du second exemple était << pourquoi est-ce que le pic mémoire est plus haut avec 10,000 itérations qu'avec 1,000 ? >>.

Est-ce que cela signifie que le Garbage Collector ne travaille pas en permanence, et ne se déclenche que << de temps en temps >>, lorsque cela devient nécessaire ?

Pour essayer de répondre à cette question, modifions un peu notre second exemple, en lui faisant tracer la consommation mémoire actuelle toutes les quelques itérations.
(Pas à chaque itération : OpenOffice.org, que j'ai utilisé pour tracer les courbes, ne faisait pas le poids face à autant de données... ou pas dans des temps acceptables à mes yeux ^^ )

Donc, toujours nos deux classes entrainant création de références cycliques :

class A {
    function __construct () {
        $this->b = new B($this);
    }
}

class B {
    function __construct ($parent = NULL) {
        $this->parent = $parent;
    }
}

Et le code les appelant :

echo memory_get_usage() . "\n";
for ($i = 0 ; $i < 20000 ; $i++) {
    $a = new A();
    if ($i%100 === 0) {
        echo memory_get_usage() . "\n";
    }
}
echo memory_get_usage() . "\n";
gc_collect_cycles();
echo memory_get_usage() . "\n";

Et l'exécution de ce code, une fois avec le Garbage Collector activé, et une fois avec le Garbage Collector désactivé (à vous de rajouter l'appel à gc_enable ou gc_disable au début du script, selon le cas) :

$ php gc-5.3-4.php > result-4-gc_enabled.txt
$ php gc-5.3-4.php > result-4-gc_disabled.txt

Importons ces résultats sous OpenOffice.org, et traçons les courbes correspondantes... ... Voici le résultat obtenu, avec une memory_limit configurée à 128 Mo :

garbage-collector-4-memoire-occupee.png

Ce qui est intéressant ici n'est pas l'augmentation mémoire ininterrompue sans Garbage Collector : nous étions déjà au courant de ce problème.

Par contre, ce qui est à noter est que la libération de mémoire ne se fait pas en continu, mais par uniquement moments, lorsqu'une certaine quantité de mémoire << perdue << est atteinte.
Dans ce cas, à chaque fois que nous atteignons un pic sur la courbe bleue, PHP déclenche une garbage collection, et moissonne les références cycliques des instances d'objets qui ne sont plus utilisés.

Vous noterez qu'en passant la memory_limit à 8 Mo, on obtient le même résultat avec le Garbage Collector -- les pics mémoire sont à un peu plus de 2 Mo -- et on explose la mémoire sans le Garbage Collector, ce qui entraine une Fatal Error.
En passant la memory_limit à 2Mo, les deux versions partent en Fatal Error.

Donc, le Garbage Collector, ou plutôt, le déclenchement du Garbage Collector, ne dépend pas du paramètrage de la memory_limit.


Garbage Collector et appel des destructeurs

Pour terminer, reprenons encore une fois notre second exemple, mais ajoutons des destructeurs à chacune des classes, qui provoqueront un affichage nous permettant de déterminer à quel moment il est appelé :

class A {
    protected $_i;
    function __construct ($i) {
        $this->b = new B($i, $this);
        $this->_i = $i;
    }
    public function __destruct() {
        if ($this->_i%10000 === 0) {
            echo "A::__destruct({$this->_i})\n";
        }
    }
}

class B {
    protected $_i;
    function __construct ($i, $parent = NULL) {
        $this->parent = $parent;
        $this->_i = $i;
    }
    public function __destruct() {
        if ($this->_i%10000 === 0) {
            echo "B::__destruct({$this->_i})\n";
        }
    }
}

Ici encore, nous ne loggons qu'une fois de temps en temps, pour éviter d'alourdir la sortie, et obtenir quelque chose d'exploitable.

Le code de test, quand à lui, devient :

for ($i = 0 ; $i < 50000 ; $i++) {
    $a = new A($i);
    if ($i%5000 === 0) {
        echo '<span style="font-weight: bold;">Itération : ' . $i . '</span>' . "\n";
    }
}

Pour commencer : voici la sortie obtenue en ayant désactivé le Garbage Collector :

garbage-collector-5-destruct-gc_disabled.png

Pas de GC == pas de libération de la mémoire... Et pas d'appel aux destructeurs !
(Si PHP ne sait pas comment libérer la mémoire en cas de références cycliques, il ne risque pas de savoir dans quel ordre appeler les destructeurs : le problème est le même)

Par contre, en activant le Garbage Collector :

garbage-collector-5-destruct-gc_enabled.png

On fait la même constatation qu'en regardant le graphe un peu plus haut : les destructeurs ne sont pas appelés à chaque itération (comme la logique le voudrait, puisque l'on créée un objet à chaque itération, et que l'on cesse de l'utiliser immédiatement après), mais par lots, une fois de temps en temps -- en somme, lorsque le Garbage Collector se déclenche...
On retombe sur ce qu'on disait plus haut : la libération mémoire et les appels de destructeurs ne se font pas en permanence, mais seulement lorsque PHP juge qu'il est temps de lancer une garbage collection.


Note

[1] Si tout le site Web et la brique applicative qui va avec est en PHP, pourquoi est-ce que les quelques traitements batch accompagnant le site ne seraient pas aussi en PHP, après tout ? Même si PHP n'a pas été conçu pour cela au départ !