Talk

Qui potete vedere il talk che ho fatto insieme a Francesco Panico durante il Pycon.it 2024

PDF

    / [pdf]

Elevator pitch

Un talk sul riciclaggio? In un certo senso si! Parleremo infatti del peggior spreco per un dev: quello di memoria. Esploreremo il funzionamento del GC e gli algoritmi alla base del suo funzionamento. Impareremo alcune tecniche per analizzare e ridurre il consumo di memoria del nostro codice.

Descrizione

Se avete mai scritto un programma in C appena più complesso del classico “Hello World”, quasi sicuramente avete utilizzato le funzioni “malloc” e “free”. In C infatti, gli sviluppatori sono responsabili dell’allocazione e del rilascio della memoria.

In Python invece, la gestione della memoria è delegata al Memory Manager, uno dei componenti dell’interprete che si occupa di allocare e rilasciare blocchi di memoria in un heap privato.

Il momento in cui la memoria deve essere allocata è chiaro; ma come sapere quando deve essere rilasciata? Questo task è gestito dal Garbage Collector. Il GC è un componente dell’interprete che rilascia la memoria di un oggetto quando questo diventa irraggiungibile.

Il meccanismo principale per determinare questo stato è il reference counting. Per ciascun oggetto, il GC tiene traccia del numero di riferimenti ad esso. Se questo numero raggiunge lo 0, l’oggetto può essere distrutto e la memoria rilasciata.

Questa strategia non funziona però in caso di riferimenti circolari. È necessario quindi un secondo algoritmo per identificare “isole” di oggetti irraggiungibili dall’esterno. Questo algoritmo lavora solo su “contenitori”, cioè oggetti che contengono riferimenti ad altri oggetti. Ad ogni esecuzione, il GC itera sui contenitori, decrementando il reference count di tutti gli oggetti a questi collegati. Se al termine dell’iterazione non esiste almeno un riferimento “esterno” ad uno degli oggetti, l’intero gruppo viene identificato come irraggiungibile e può essere distrutto.

Per ridurre il numero di esecuzioni di questo algoritmo, il GC suddivide gli oggetti in generazioni. L’assunto dietro questa ottimizzazione è che la maggior parte degli oggetti ha un ciclo di vita molto breve. Più un oggetto sopravvive, meno possibilità ha di diventare irraggiungibile. La suddivisione in generazioni sfrutta questa tendenza, suddividendo gli oggetti in tre gruppi. Un oggetto appena creato si trova nella generazione 0. Se sopravvive ad un ciclo di esecuzione del GC, viene spostato nella generazione 1, che viene controllata meno frequentemente; Se sopravvive ad un ulteriore ciclo, viene spostato nella generazione 2, che viene controllata ancora meno frequentemente.

Una gestione efficiente della memoria non significa soltanto distruggere gli oggetti non più in uso. Al contrario, dovremmo considerare questo aspetto durante tutto il corso di un programma. Scrivere codice di qualità, anche dal punto di vista dell’utilizzo di risorse, rimane la strategia migliore per evitare lo spreco di memoria.

Per ottenere questo risultato possiamo identificare due step: individuare le parti di codice con il consumo di memoria maggiore ottimizzare incrementalmente tali parti

Esistono diversi strumenti per monitorare l’utilizzo di memoria. In questo talk ci concentreremo su 3 moduli della libreria standard: sys, gc e tracemalloc.

Il modulo sys ha alcune funzioni che tracciano quanta memoria usa il nostro codice: getallocatedblocks() ritorna il numero di blocchi attualmente allocati dall’interprete; getsizeof() ritorna la dimensione in byte di un oggetto. La funzione getrefcount() ritorna il valore attuale del ref_count, utile per identificare riferimenti ad un oggetto non noti che ne prevengono la distruzione.

Il modulo gc fornisce funzioni per impostare la frequenza di esecuzione del GC e per conoscere gli oggetti attualmente allocati ed i loro riferimenti reciproci.

Il modulo tracemalloc è uno strumento di debug che ci permette di tracciare i blocchi di memoria allocati dall’interprete. Può tracciare l’occupazione di memoria in maniera incrementale catturando snapshot durante l’esecuzione.

Questi tool ci permettono di individuare le parti del nostro codice che consumano più memoria. Tutte le ottimizzazioni condividono una strategia di base: usare meno oggetti possibile, mantenendo il loro ciclo di vita breve. Un approccio efficace consiste nel partire dalle classi più utilizzate e introdurre miglioramenti in modo graduale.

Le variabili locali sono quelle con ciclo di vita più breve. Sono da preferire se l’oggetto che contengono non ha uno stato da persistere, e se istanziarlo non implica una computazione.

Nelle iterazioni, dovremmo cercare di utilizzare il più possibile dei generatori. Mentre la memoria necessaria per contenere una lista viene allocata tutta immediatamente, ad ogni iterazione di un generatore viene allocato solamente l’oggetto corrente del ciclo.

Le classi possono essere ottimizzate tramite l’attributo slots, che consente di ridurre la memoria allocata rendendo fissi gli attributi di istanza permessi. Se le nostre classi sono data object, potrebbero essere sostituite con delle NamedTuple, che richiedono meno memoria.

Potremmo valutare l’utilizzo di weakref al posto di riferimenti diretti per tutte le relazioni tra oggetti non critiche. Non essendo considerati per il computo dei reference count, permetterebbero al GC di deallocare più velocemente oggetti non critici.

Foto

Una piccola raccolta di foto fatte durante il talk