PHP 5.3 : Garbage Collector et consommation mémoire

le - Lien permanent 14 commentaires

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 !

Vous avez apprécié cet article ? Faites le savoir !

Commentaires

1. Par Stopher le 2008-10-30 08:14
Stopher

Salut ,
Merci pour cet article vraiment intéressant , une question ,
Est ce qu'il existe un paquet pear ou pecl pour installer le "Garbage Collectore" sur une version de php < à php5.3 ?

2. Par damdec le 2008-10-30 09:04
damdec

Merci pour cet article très clair et très intéressant ! Ça fait plaisir de voir que PHP ne cesse d'évoluer, le gestion d'un garbage collector est vraiment une très bonne news !

3. Par Renaud le 2008-10-30 09:12
Renaud

"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"

Sauf erreur de ma part, c'est logique, tu affiches les itérations avec un incrément de 5000 alors que tu affiches les destructeurs tous les 10000 !... En tout cas, c'est le code qui apparaît dans ce billet...

4. Par Pascal MARTIN le 2008-10-30 19:19
Pascal MARTIN

Merci à tous trois pour vos commentaires !

Stopher > Pas que je sache, non : le Garbage Collector est véritablement une nouveauté de PHP 5.3, et pas une extension PECL qui aurait été intégrée à la distribution.

Renaud > Mes exemples ont peut-être été un peu trop "raccourcis" pour tenir à l'écran, dans ce cas :-(

Ce que je propose pour vérifier : modifier le code du dernier exemple (celui avec les destructeurs), pour ne plus afficher un message aussi long avec retour à la ligne et tout, mais seulement un symbole :

  • Un point . à chaque itération,
  • un dièse # dans le destructeur de A,
  • Et une arobase @ dans le destructeur de B

Puis relançons l'exemple, avec et sans le GC :

  • Avec Garbage collector, on obtient alternativement plusieurs plages de .................. et plusieurs plages de #@#@#@#@#@#@#@#@#@#@#@#@#@#@
  • Sans Garbage Collector, on obtient une seule longue plage de ............. suivie d'une seule longue plage de #@#@#@#@#@#@#@#@#@#@

On retombe donc sur ce je disais, même en ayant des sorties écran à chaque itération : avec le Garbage Collector activé, les destructeurs ne sont pas appelés à chaque itération, mais seulement par lots, une fois de temps en temps ; et sans Garbage Collector, ils ne sont (tous) appelés qu'à la fin du script.

J'ai ajouté cet exemple "revu" au pack joint à cet article : garbage-collector/gc-5.3-5-2.php

Bonne soirée, et encore merci :-)

Pascal M.

5. Par agpaul le 2009-02-28 16:29
agpaul

Merci pour ces infos.
Je reste persuadé, qu'il y a des fuites de mémoires même sans reference cyclique.
Est que le garbage collecteur va régler ces problèmes ?

6. Par Pascal MARTIN le 2009-03-01 13:13
Pascal MARTIN

Bonjour,

Je n'ai pas vraiment eu le courage de mettre en place des tests à ce sujet (j'ai bien une idée de quelques programmes que je pourrais utiliser pour tester, vu les problèmes de fuite mémoire qu'ils m'ont posés, cela dit...), mais je doute que le GC corrige tous les problèmes : quand je vois les fuites mémoire causées par certains batchs écrits en PHP, je me dit qu'une solution qui corrigerait le tout serait vraiment une "solution miracle" ^^
Cela dit, le GC ne pourra pas faire de mal, quoi qu'il en soit !

Mais après, n'oublions pas que PHP a avant tout été développé comme un langage de scripts, pour la mise en place de sites Web ; et une page Web, en toute logique, ça met au maximum quelques secondes à se générer -- ce qui signifie que même s'il y a quelques fuites mémoire, ce n'est pas censé être catastrophique...

... Après, nous utilisons de plus en plus PHP pour des manipulations non prévues au départ : traitements batchs durant jusqu'à plusieurs heures, applications "lourdes", ... Peut-être que le problème est plus là ?

Cela dit, si vous tombez un jour sur un bench "cas réel" d'occupation mémoire entre PHP 5.2 et PHP 5.3, n'hésitez pas à poster le lien ici : ça ne peut être qu'intéressant, quelques soient les résultats obtenus !

7. Par agpaul le 2009-03-01 15:38
agpaul

Ca fait un moment que je souhaite annalyser ces problemes defuites de mémoire. Il faut que j'arrive a reproduire les fuites que je rencontre sur des batchs de productions. Dès que j'ai ca je vous fait par de mes résultat.

8. Par arena le 2009-10-14 14:59
arena

bonjour,

bravo pour cet article. très interessant.

je cherche un article équivalent sur la gestion de la mémoire dans une approche programmation orientée objet.

exemple :

abstract class abstraite { ... }

Ensuite j'ai 'n' classes qui sont un 'extends' de la classe abstraite.
A chaque nouvelle instance de classe de type 'n', la classe abstraite est elle dupliquée en mémoire ?

ce genre de question...

9. Par Agence web Wixiweb Rouen le 2010-01-13 12:01
Agence web Wixiweb Rouen

Article très intéressant. Je l'avais lu à l'époque ou il était sorti et je reviens aujourd'hui pour vous faire part de mon expérience (mon problème).

Je fais un appel à un webservice en utilisant le client Natif de PHP. Je suis en PHP5.3. L'appel retour un XML assez gros ce qui fait que mon script passe de 1Mo de mémoire utilisé avant l'appel à 45Mo (mesuré à l'aide de memory_get_usage())
A la suite, j'ai essayé de faire :
-
unset($soapClient);
gc_collect_cycles();
print round(memory_get_usage()/(1024*1024)).' Mo';
-

Le problème c'est que la mémoire utilisée n'a pas baissée d'un poil !

Avez-vous une idée ?

10. Par petitchevalroux le 2010-04-03 08:19
petitchevalroux

Article très intéressant cependant l'exemple des références cycliques est un cas extrême qui arrive rarement si la modélisation des données est faite correctement.

Utilisant moi même des scripts batch pour des traitements différés sur de gros lot de données les problèmes de mémoire que j'ai rencontré étaient souvent dut à des tampons qui se remplissaient sans jamais se vider (multiton ou autre).

En implémentant du LRU sur ces tampons et en découpant mes traitements en plus petits lots je ne rencontre plus ce genre de problème depuis un moment.

Avant de lire cet article je ne connaissais pas les fonctionnalités de garbage collector de php. Ces méthodes pourront certainement me dépanner un jour ou l'autre c'est pourquoi je te remercie pour la découverte ;)

11. Par Pascal MARTIN le 2010-04-03 10:32
Pascal MARTIN

Et merci à toi pour ton commentaire ;-)

12. Par coolmoov le 2010-05-21 03:31
coolmoov

Well, I got to say that your article confirmed what I was thinking. I was dealing with memory leak on my e-commerce wed development. I upgraded from php 5.2.13 to 5.3, then I used the new garbage collector. It works like a charm. No more "out of memory" errors.

Thanks for this great article.

13. Par Geoffrey le 2010-08-05 14:35
Geoffrey

Salut !

Article très interessant ! Je suis donc passé à PHP 5.3.
Malheureusement, cela ne semble en rien améliorer la consommation mémoire de mon script.

Apres une enquete minutieuse, je me rends compte que c'est le code PHP en lui même qui prend plein de mémoire.

Je m'explique : je fais un require sur un fichier PHP un peu gros : 5500 lignes qui occupe 188 Ko sur mon disque.

C'est une classe, il n'y a pas de code interprété à ce moment là.

Et bien la memoire fait immédiatement un bon de 2.3 Mo !

Du coup, comme mon projet est assez gros, une fois que tous les fichiers utiles sont inclus, je ne peux descendre en dessous de 8Mo.

Est-ce normal ?

Merci de votre réponse !

14. Par DavidV le 2011-12-06 15:43
DavidV

Excellent article !!!

Ce post n'est pas ouvert aux nouveaux commentaires (probablement parce qu'il a été publié il y a longtemps).