Ramasse-miettes (Garbage Collection) Cette section explique les mérites du nouveau mécanisme ramasse-miettes (aussi appelé GC, du terme anglais Garbage Collection) fourni à partir de PHP 5.3. Bases sur le comptage des références Une variable PHP est stockée en interne dans un conteneur appelé "zval". Un conteneur zval contient, outre le type de la variable et sa valeur, deux informations supplémentaires. La première se nomme "is_ref" et est une valeur booléenne qui indique si une variable fait partie d'une référence ou non. Grâce à cette information, le moteur de PHP sait différencier les variables normales des références. Comme PHP autorise le programmeur à utiliser des références, au moyen de l'opérateur &, un conteneur zval possède aussi un mécanisme de comptage des références afin d'optimiser l'utilisation de la mémoire. Cette seconde information, appelée "refcount", contient le nombre de variables (aussi appelées symboles) qui pointent vers ce conteneur zval. Tous les symboles sont stockés dans une table de symboles, et il y a une table par espace de visibilité (scope). Il y a un espace global pour le script principal (celui appelé par exemple via le navigateur) et un espace par fonction ou méthode. Un conteneur zval est créé lorsqu'une nouvelle variable est créée avec une valeur constante, comme par exemple : Création d'un nouveau conteneur zval ]]> Dans ce cas, le nouveau symbole a est créé dans le scope global, et un nouveau conteneur est créé avec comme type string et comme valeur new string. Le bit "is_ref" est mis par défaut à &false; car aucune référence n'a été créée par le programmeur. Le compteur de références "refcount" est mis à 1 car il n'y a qu'un seul symbole qui utilise ce conteneur. Il est à noter que les références (c.à.d. "is_ref" est &true;) avec "refcount" 1, sont traitées comme si elles n'étaient pas des références (c.à.d. comme si "is_ref" était &true;). Si vous avez installé Xdebug, vous pouvez afficher cette information en appelant xdebug_debug_zval. Affichage des informations zval ]]> &example.outputs; Assigner cette variable à un autre symbole va incrémenter le refcount. Incrémentation du refcount d'une zval ]]> &example.outputs; Le refcount vaut 2 ici, car le même conteneur est lié à la fois à a et à b. PHP est suffisamment intelligent pour ne pas dupliquer le conteneur lorsque ce n'est pas nécessaire. Les conteneurs sont détruits lorsque leur "refcount" atteint zéro. Le "refcount" est décrémenté de un lorsque n'importe quel symbole lié à un conteneur sort du scope (ex. : lorsque la fonction se termine) ou lorsqu'un symbole est déalloué (ex. : par l'appel de unset). L'exemple qui suit le démontre : Décrémentation du refcount d'une zval ]]> &example.outputs; Si, maintenant, nous appelons unset($a);, le conteneur zval, incluant le type et la valeur, va être supprimé de la mémoire. Types composés Les choses se compliquent dans le cas de types composés comme array et object. A la différence des valeurs scalaires, les array et object stockent leurs propriétés dans une table de symboles qui leur est propre. Ceci signifie que l'exemple qui suit crée trois conteneurs zval : Création d'une zval <type>array</type> 'life', 'number' => 42 ); xdebug_debug_zval( 'a' ); ?> ]]> &example.outputs.similar; (refcount=1, is_ref=0)='life', 'number' => (refcount=1, is_ref=0)=42 ) ]]> Ou graphiquement Zvals d'un tableau simple Les trois conteneurs zval sont : a, meaning, et number. Les mêmes règles s'appliquent pour l'incrémentation et la décrémentation des "refcounts". Ci-après, nous ajoutons un autre élément au tableau, et nous renseignons sa valeur avec le contenu d'un élément déjà existant du tableau : Ajout d'un élément déja existant au tableau 'life', 'number' => 42 ); $a['life'] = $a['meaning']; xdebug_debug_zval( 'a' ); ?> ]]> &example.outputs.similar; (refcount=2, is_ref=0)='life', 'number' => (refcount=1, is_ref=0)=42, 'life' => (refcount=2, is_ref=0)='life' ) ]]> Ou graphiquement Zvals pour un tableau simple avec une référence La sortie Xdebug que nous voyons indique que l'ancien et le nouvel élément du tableau pointent maintenant tous deux vers un conteneur zval dont le "refcount" vaut 2. Même si la sortie XDebug montre deux conteneurs zval avec comme valeur 'life', ils sont les mêmes. La fonction xdebug_debug_zval ne montre pas cela, mais vous pourriez le voir en affichant aussi le pointeur de mémoire. Supprimer un élément du tableau est assimilable à la suppression d'un symbole depuis un espace. Ce faisant, le "refcount" du conteneur vers lequel l'élément du tableau pointe est décrémenté. Une fois encore, s'il atteint zéro, le conteneur zval est supprimé de la mémoire. Voici un exemple qui le démontre : Suppression d'un élément de tableau 'life', 'number' => 42 ); $a['life'] = $a['meaning']; unset( $a['meaning'], $a['number'] ); xdebug_debug_zval( 'a' ); ?> ]]> &example.outputs.similar; (refcount=1, is_ref=0)='life' ) ]]> Maintenant, les choses deviennent intéressantes si nous ajoutons le tableau comme élément de lui-même. Nous faisons cela dans l'exemple qui suit, en utilisant un opérateur de référence pour éviter que PHP ne crée une copie : Ajout du tableau comme référence à lui-même en tant qu'élement ]]> &example.outputs.similar; (refcount=1, is_ref=0)='one', 1 => (refcount=2, is_ref=1)=... ) ]]> Ou graphiquement Zvals dans un tableau avec référence circulaire Vous pouvez voir que la variable tableau (a) tout comme le second élément (1) pointent désormais vers un conteneur dont le "refcount" vaut 2. Les "..." sur l'affichage indiquent une récursion, qui, dans ce cas, signifie que le "..." pointe sur le tableau lui-même. Comme précédemment, supprimer une variable supprime son symbole, et le refcount du conteneur sur lequel il pointaint est décrémenté. Donc, si nous supprimons la variable $a après avoir exécuté le code ci-dessus, le compteur de références du conteneur sur lequel pointent $a et l'élément "1" sera décrémenté de un, passant de "2" à "1". Ceci peut être représenté par : Suppression de <varname>$a</varname> (refcount=1, is_ref=0)='one', 1 => (refcount=1, is_ref=1)=... ) ]]> Ou graphiquement Zvals après suppression du tableau contenant une référence circulaire, fuite mémoire Problèmes de nettoyage Bien qu'il n'y ait plus aucun symbole dans l'espace de variables courant qui pointe vers cette structure, elle ne peut être nettoyée, car l'élément "1" du tableau pointe toujours vers ce même tableau. Comme il n'y a plus aucun symbole externe pointant vers cette structure, l'utilisateur ne peut pas la nettoyer manuellement ; il y a donc une fuite de mémoire. Heureusement, PHP va détruire cette structure à la fin de la requête, mais avant cette étape, la mémoire n'est pas libérée. Cette situation se produit souvent si vous implémentez un algorithme d'analyse ou d'autres idées où vous avez un enfant qui pointe vers son parent. La même chose peut bien entendu se produire avec les objets, et c'est même plus probable, puisqu'ils sont toujours implicitement utilisés par référence. Ceci peut ne pas être gênant si cela n'arrive qu'une ou deux fois, mais s'il y a des des milliers, ou même des millions, de ces fuites mémoires, alors cela risque évidemment de devenir un problème important. C'est particulièrement problématique pour les scripts qui durent longtemps, comme les démons pour lesquels la requête ne termine pour ainsi dire jamais, ou encore dans de grosses suites de tests unitaires. Ce dernier cas a été rencontré en lançant les tests unitaires du composant Template de la bibliothèque eZ Components. Dans certains cas, la suite de tests nécessitait plus de 2Go de mémoire, que le serveur le test n'avait pas vraiment à disposition. Nettoyage de Cycles Traditionnellement, les mécanismes de comptage de références, comme utilisés auparavant dans PHP, ne savent pas gérer les fuites mémoires dûes à des références circulaires ; cependant depuis PHP 5.3.0, un algorithme synchrone issu de l'analyse Concurrent Cycle Collection in Reference Counted Systems est utilisé pour répondre à ce problème particulier. Une explication complète du fonctionnement de l'algorithme irait un peu au-delà du cadre de cette section, mais nous allons ici présenter les principes de base. Avant tout, nous allons établir quelques règles de base. Si un refcount est incrémenté, le conteneur est toujours utilisé, donc pas nettoyé. Si le refcount est décrémenté et atteint zéro, le conteneur zval peut être supprimé et la mémoire libérée. Premièrement, ceci signifie que les cycles perturbateur ne peuvent être créés que lorsque le refcount est décrémenté vers une valeur différente de zéro. Ensuite, dans un cycle problématique, il est possible de détecter les déchets en vérifiant s'il est possible ou non de décrémenter leur refcount de un, en vérifiant ensuite quelles zvals ont un refcount à zéro. Algorithme de collecte des déchets Pour éviter d'avoir à appeler la routine de nettoyage à chaque décrémentation de refcount possible, l'algorithme place toutes les zval racines dans un "tampon de racines" (en les marquant en "violet"). Il s'assure aussi que chaque racine n'apparaisse qu'une seule fois dans le tampon. Le mécanisme de nettoyage n'intervient alors que lorsque le tampon est plein. Voyez l'étape A sur la figure ci-dessus. A l'étape B, l'algorithme lance une recherche sur toutes les racines possibles, afin de décrémenter de une unité les refcounts de toutes les zvals qu'il trouve, en faisant bien attention de ne pas décrémenter deux fois le refcount de la même zval (en les marquant comme "grises"). A l'étape C, l'algorithme relance une recherche sur toutes les racines possibles et scrute la valeur de refcount de chaque zval. S'il trouve un refcount à zéro, la zval est marquée comme "blanche" (bleu sur la figure). S'il trouve une valeur supérieure à zéro, il annule la décrémentation du refcount en refaisant une recherche à partir de ce nœud, et les marque comme "noires" à nouveau. Dans la dernière étape, D, l'algorithme parcourt tout le tampon des racines et les supprime, tout en scrutant chaque zval ; toute zval marquée comme "blanche" à l'étape précédente sera alors supprimée de la mémoire. Maintenant que vous savez globalement comment l'algorithme fonctionne, nous allons voir comment il a été intégré dans PHP. Par défaut, le ramasse-miettes de PHP est activé. Il existe cependant une options de &php.ini; pour changer cela : zend.enable_gc. Lorsque le ramasse-miettes est activé, l'algorithme de recherche des cycles décrit ci-dessus est exécuté à chaque fois que le tampon est plein. Le tampon de racines a une taille fixée à 10.000 racines (ce paramètre est changeable grâce à GC_ROOT_BUFFER_MAX_ENTRIES dans Zend/zend_gc.c dans le code source de PHP, une recompilation est donc nécessaire). Si le ramasse- miettes est désactivé, la recherche des cycles l'est aussi. Cependant, les racines possibles seront toujours enregistrées dans le tampon, ceci ne dépend pas de l'activation du ramasse-miettes. Si le tampon est plein alors que le mécanisme de nettoyage est désactivé, les racines ne seront plus enregistrées. Ces racines ne seront donc jamais analysées par l'algorithme, et si elles faisaient partie de références circulaires, elles ne seront jamais nettoyées, et elles causeront des fuites de mémoire. La raison pour laquelle les racines possibles sont enregistrées dans le tampon même si le mécanisme est désactivé est qu'il aurait été trop coûteux de vérifier l'activation éventuelle du mécanisme à chaque tentative d'ajout d'une racine dans le tampon. Le mécanisme de ramasse-miettes et d'analyse peut, lui, être très coûteux en temps. En plus de pouvoir changer la valeur du paramètre de configuration zend.enable_gc, vous pouvez aussi activer ou désactiver le mécanisme de ramasse-miettes en appelant les fonctions gc_enable ou gc_disable respectivement. Utiliser ces fonctions aura le même effet que de modifier le paramètre de configuration. Vous avez aussi la possibilité de forcer l'exécution du ramasse-miettes à un moment donné dans votre script, même si le tampon n'est pas encore complètement plein. Utilisez pour cela la fonction gc_collect_cycles, qui retournera le nombre de cycles alors collectés. Vous pouvez prendre le contrôle en désactivant le ramasse-miettes ou en le forçant à passer à un moment donné car certaines parties de votre application pourraient être fortement dépendantes du temps de traitement, auquel cas vous pourriez souhaiter que le ramasse-miettes ne se lance pas. Bien entendu, en désactivant le ramasse-miettes pour certaines parties de votre application, vous prenez le risque de créer des fuites de mémoire, puisque certaines racines probables pourraient ne pas être enregistrées dans le tampon mémoire de taille limitée. En conséquence, il est généralement recommandé de déclencher manuellement le processus grâce à gc_collect_cycles juste avant l'appel à gc_disable, pour libérer de la mémoire. Ceci laissera un tampon vidé, et il y aura plus d'espace pour des racines probables lorsque le mécanisme sera désactivé. Considerations sur les performances Nous avons déja vu dans les sections précédentes que la collecte des racines probables avait un impact très léger sur les performances, mais c'est lorsque l'on compare PHP 5.2 à PHP 5.3. Même si l'enregistrement des racines probables est plus lent que de ne pas les enregistrer du tout, comme dans PHP 5.2, d'autres améliorations apportées par PHP 5.3 font que cette opération ne se ressent pas au niveau des performances. Il y a principalement deux niveaux pour lesquels les performances sont affectées. Le premier est l'empreinte mémoire réduite, et le second est le délai à l'exécution, lorsque le mécanisme de nettoyage effectue son opération de libération de mémoire. Nous allons étudier ces deux axes. Empreinte mémoire réduite Avant tout, la raison principale de l'implémentation du mécanisme de collecte des déchets est la réduction de la mémoire consommée, en nettoyant les références circulaires lorsque les conditions requises sont remplies. Avec PHP, ceci arrive dès que le tampon de racines est plein, ou lorsque la fonction gc_collect_cycles est appelée. Sur le graphe ci-après, nous affichons l'utilisation mémoire du script suivant, avec PHP 5.2 et avec PHP 5.3, en excluant la mémoire obligatoire que PHP consomme pour lui-même au démarrage. Exemple d'utilisation mémoire self = $a; if ( $i % 500 === 0 ) { echo sprintf( '%8d: ', $i ), memory_get_usage() - $baseMemory, "\n"; } } ?> ]]> Comparaison de la consommation mémoire entre PHP 5.2 et PHP 5.3 Dans cet exemple quelque peu académique, nous créons un objet possédant un attribut le référençant lui-même. Lorsque la variable $a dans le script est réassignée à l'itération suivante, une fuite mémoire apparaitra. Dans ce cas, les deux conteneurs zval fuient (la zval de l'objet et celle de l'attribut), mais une seule racine possible est trouvée : la variable qui a été supprimée. Lorsque le tampon de racines est plein après 10.000 itérations (avec un total de 10.000 racines possibles), le mécanisme de collecte des déchets entre en jeu et libère la mémoire associée à ces racines probables. Cela se voit très clairement sur les graphes d'utilisation mémoire de PHP 5.3. Après chaque 10.000 itérations, le mécanisme se déclenche et libère la mémoire associée aux variables circulairement référencées. Le mécanisme en question n'a pas énormément de travail dans cet exemple, parce que la structure qui a fuit est extrêmement simple. Le diagramme montre que l'utilisation maximale de mémoire de PHP 5.3 est d'environ 9Mo, là où elle n'arrête pas d'augmenter avec PHP 5.2. Ralentissements durant l'exécution Le second point où le mécanisme de collecte des déchets (GC) affecte les performances est lorsqu'il est exécuté pour libérer la mémoire "gaspillée". Pour quantifier cet impact, nous modifions légérement le script précédent afin d'avoir un nombre d'itérations plus élevé et de supprimer la collecte de l'usage mémoire intermédiaire. Le second script est reproduit ci-dessous : Impact de GC sur les performances self = $a; } echo memory_get_peak_usage(), "\n"; ?> ]]> Nous allons lancer ce script 2 fois, une fois avec zend.enable_gc à on, et une fois à off: Lancement du script ci-dessus Sur ma machine, la première commande semble durer tout le temps 10,7 secondes, alors que la seconde commande prend environ 11,4 secondes. Cela correspond à un ralentissement de 7% environ. Cependant, la quantité totale de mémoire utilisée par le script est réduite de 98%, passant de 931Mo à 10Mo. Ce benchmark n'est pas très scientifique ou même représentatif d'applications réelles, mais il démontre concrètement en quoi le mécanisme de collecte des déchets peut être utile au niveau de la consommation mémoire. Le bon point est que le ralentissement est toujours de 7%, dans le cas particulier de ce script, alors que la mémoire préservée sera de plus en plus importante au fur et à mesure que des références circulaires apparaitront durant l'éxécution. Statistiques internes du GC de PHP Il est possible d'obtenir quelques informations supplémentaires concernant le mécanisme de collecte des déchets interne à PHP. Mais pour cela, il vous faut recompiler PHP avec le support du benchmarking et de la collecte de données. Vous devrez renseigner la variable d'environnement CFLAGS avec -DGC_BENCH=1 avant de lancer ./configure avec les options qui vous intéressent. L'exemple suivant démontre cela : Recompiler PHP pour activer le support du benchmark du GC Lorsque vous ré-éxécutez le code du script ci-dessus avec le binaire PHP fraichement reconstruit, vous devriez voir le résultat suivant après l'exécution : Statistiques GC Les statistiques les plus intéressantes sont affichées dans le premier bloc. Vous voyez ici que le mécanisme de collecte des déchets a été déclenché 110 fois, et qu'au total ce sont plus de 2 millions d'allocations mémoire qui ont été libérées durant ces 110 passages. Dès lors que le mécanisme est intervenu au moins une fois, le pic du buffer racine est toujours de 10000. Conclusion De manière générale, la collecte des déchets de PHP ne causera un ralentissement que lorsque l'algorithme de collecte de cycles tournera, ce qui signifie que dans les scripts normaux (plus courts), il ne devrait pas du tout y avoir d'impact sur les performances. Cependant, lorsque le mécanisme de collecte de cycles sera déclenché dans des scripts normaux, la réduction de l'empreinte mémoire permettra l'exécution parallèle d'un nombre plus important de ces scripts, puisque moins de mémoire sera utilisée au total. Les avantages se sentent plus nettement dans le cas de scripts démons ou devant tourner longtemps. Ainsi, pour les applications PHP-GTK qui tournent souvent plus longtemps que des scripts pour le Web, le nouveau mécanisme devrait réduire significativement les fuites mémoire sur le long terme.