dimanche 9 avril 2023

Les deux dispositifs de gestion mémoire de CPython

 CPython est la la machine virtuelle de référence d'exécution des programmes Python. CPython compile et interprète les sources d'une programme python pour son exécution.

A ce titre, il prend en charge la gestion de la mémoire pour les variables ou les objets Python. 

Dans un but de rationnaliser l'empreinte mémoire, il va mettre en place deux mécanismes distincts pour gérer au mieux cette mémoire.

Un objet est composé de deux choses: un nom (1) de variable qui fait référence à une zone mémoire (2).

Quand un objet est détruit (commande del ?, fin de bloc ?) , le nom de l'objet (variable) est enlevé de l'espace de nommage mais la zone mémoire ne sera forcement rendue disponible. C'est à ce moment que les deux mécanismes évoqués vont entrer en jeu.

 1) La gestion par compteur de référence.

Ce dispositif est à la fois simple, robuste et efficace.

Chaque fois un objet est référencé par un autre, un compteur de référence est incrémenté de 1. Le mécanisme inverse s'applique chaque fois qu'un objet est déréférencé par un autre objet. Quand le compteur arrive à 0 , l'espace mémoire occupé par l'objet se récupéré.

Remarque : une opération comme celle-ci : a = b * 4 va incrémenter de 1 le compteur de référence de la variable b puis à la fin de l'operation le décrémenter. 


Exemple: utilisation de getrefcount pour afficher le nombre de référence.

import sys
>
a = 50000
b = ['etoile', a, 'neige']
c = (a, b)
d ='srer'
print('a', sys.getrefcount(a))
print('b', sys.getrefcount(b))
print('c', sys.getrefcount(c))
print('d', sys.getrefcount(d))
 
=>
a 4
b 3
c 2
d 2   

On supprime un objet qui en référençait un autre
del(c)
print('a', sys.getrefcount(a))
print('b', sys.getrefcount(b))
print('d', sys.getrefcount(d))
 
=> 
a 3
b 2
d 2
(mise en forme avec https://highlight.hohli.com/)

Mais parfois le dispositif peut être en pris en défaut par la présence de références cycliques:)

Soit deux listes
y = ['un' , 'deux' ]
z = [3, 5, 7]
print('y', sys.getrefcount(y))
print('z', sys.getrefcount(z))
 
=>
y 2
z 2
On ajoute à chaque liste , l'autre liste.
y.append(z)
z.append(y)
print('y', sys.getrefcount(y))
print('z', sys.getrefcount(z))
 
=>
y 3
z 3
On supprime la liste 'z'
del(z)
 
 
print('y', sys.getrefcount(y))
print(y)
 
=>
y 3
['un', 'deux', [3, 5, 7, [...]]]
Le compteur de référence sur y n'a pas bougé car même si la variable n'est plus accessible,  son contenu persiste en mémoire et il pointe toujours sur y.

image de https://pythontutor.com/
Avant la suppression de la liste Z



Apres suppression de Z , l nom de variable 'z' nest plus reconnu mais la zone mémoire est toujours utilisée.

On peut avoir le même phénomène avec un objet qui se référence lui même:


Exemple ici en faisant:
x =[]
x.append(x)

Pour pallier à cette carence, CPython utilise un deuxième dispositif: le garbage collector.

 2) Le garbage collector de CPython.


Il est doté de trois collections ou conteneurs  permettant de stocker des éléments du plus récent (collection 0) au plus ancien (collection 2): il est dit 'générationnel' .

Chaque fois qu'un objet devient  injoignable et que le dispositif de comptage n'a pas pu libérer la zone, il sera collecté et examiné finement type par type pour résoudre le dilemme.  

Le module gc permet de manipuler le garbage collector.
La méthode get_threshold() retourne les seuils pour les 3 générations: lorsque le nombre d'objet d'une génération  dépasse le seuil, le garbage collector se met en œuvre pour examiner chaque objet et résoudre les incohérences (références circulaires) . Un objet récent passera de la collection 0 , à la collection 1 puis enfin à la 2 qui contiendra de fait les objets les plus anciens. 

import gc
gc.get_threshold()
 
=>
(700, 10, 10)
 
gc.get_stats()
 
=>
[{'collections': 370, 'collected': 32693, 'uncollectable': 0},
 {'collections': 33, 'collected': 3952, 'uncollectable': 0},
 {'collections': 3, 'collected': 1561, 'uncollectable': 0}]
La méthode get_stats() retourne le nombre d'objet surveillé par génération.
Pour illustration:
On va créer une référence circulaire , puis supprimer l'objet et voir ainsi le travail du garbage collector
Le garbage collector est placé en mode debug.
# Préparation 
gc.set_debug( gc.DEBUG_COLLECTABLE| gc.DEBUG_SAVEALL )
n = gc.collect()
zorro = [1, 2,'autre chose']
maliste = ['ert', 4, 'divers']
 
# Création d'une référence circulaire
zorro.append(zorro)
# suppression de l'objet
del zorro
 
# Mesures
avant = [ str(x) for x in  gc.garbage]
print(len(avant))
avant= set(avant)
n = gc.collect()
print(n)
apres =  [ str(x) for x in  gc.garbage]
print(len(apres))
apres = set(apres)
dif = apres- avant
print("result ", dif)
 
=> 
1753
1
1754
result  {"[1, 2, 'autre chose', [...]]"}
gc: collectable <list 0x0000017E6AFDD480>
La zone mémoire sera libérée et sera disponible pour un autre stockage.
 MAIS ce n'est pas pour autant que CPython rendra de l'espace mémoire au système d'exploitation.

Conclusion.

Il est possible de modifier le seuil de déclenchement du garbage collector (méthode set_threshold) 
ou encore de le désactiver. Il n'y aucune raison a priori de modifier le comportement par défaut.

Aucun commentaire: