Gestione della memoria di Python: The Essential Guide

Arwin Lashawn on December 04, 2020

Background

Python non è noto per essere un linguaggio di programmazione “veloce”. Tuttavia, secondo i risultati del 2020 Stack Overflow Developer Survey, Python è il secondo linguaggio di programmazione più popolare dietro JavaScript (come avrete capito). Questo è in gran parte dovuto alla sua sintassi super amichevole e alla sua applicabilità per quasi tutti gli scopi. Mentre Python non è il linguaggio più veloce là fuori, la sua grande leggibilità unita all’impareggiabile supporto della comunità e alla disponibilità di librerie lo ha reso estremamente attraente per fare cose con il codice.

Anche la gestione della memoria di Python gioca un ruolo nella sua popolarità. In che modo? La gestione della memoria di Python è implementata in un modo che ci rende la vita più facile. Avete mai sentito parlare del gestore della memoria di Python? È il gestore che tiene sotto controllo la memoria di Python, permettendovi così di concentrarvi sul vostro codice invece di dovervi preoccupare della gestione della memoria. A causa della sua semplicità, tuttavia, Python non fornisce molta libertà nella gestione dell’uso della memoria, a differenza di linguaggi come il C++ dove è possibile allocare e liberare manualmente la memoria.

Tuttavia, avere una buona comprensione della gestione della memoria di Python è un ottimo inizio che vi permetterà di scrivere codice più efficiente. In ultima analisi, si può imporre come un’abitudine che può potenzialmente essere adottata in altri linguaggi di programmazione che si conoscono.

Quindi cosa si ottiene scrivendo codice efficiente dal punto di vista della memoria?

  • Porta ad un’elaborazione più veloce e ad una minore necessità di risorse, vale a dire l’uso della memoria ad accesso casuale (RAM). Più RAM disponibile significa generalmente più spazio per la cache, che aiuterà a velocizzare l’accesso al disco. Il bello di scrivere codice efficiente in termini di memoria è che non richiede necessariamente di scrivere più righe di codice.
  • Un altro vantaggio è che previene la perdita di memoria, un problema che fa sì che l’uso della RAM aumenti continuamente anche quando i processi vengono uccisi, portando alla fine a prestazioni rallentate o compromesse del dispositivo. Questo è causato dal fallimento di liberare la memoria usata dopo che i processi terminano.

Nel mondo della tecnologia, potreste aver sentito dire che “fatto è meglio che perfetto”. Tuttavia, diciamo che avete due sviluppatori che hanno usato Python per sviluppare la stessa applicazione e l’hanno completata nello stesso lasso di tempo. Uno di loro ha scritto un codice più efficiente in termini di memoria che risulta in un’app più veloce. Preferireste scegliere l’app che funziona senza problemi o quella che è notevolmente più lenta? Questo è un buon esempio in cui due individui passerebbero la stessa quantità di tempo a codificare e tuttavia avrebbero prestazioni del codice notevolmente diverse.

Ecco cosa imparerete nella guida:

  • Come viene gestita la memoria in Python?
  • Python Garbage Collection
  • Monitoraggio dei problemi di memoria di Python
  • Migliori pratiche per migliorare le prestazioni del codice Python

Come viene gestita la memoria in Python?

Secondo la documentazione di Python (3.9.0) per la gestione della memoria, la gestione della memoria di Python comporta un heap privato che viene utilizzato per memorizzare gli oggetti e le strutture dati del programma. Inoltre, ricordate che è il gestore di memoria Python che gestisce la maggior parte del lavoro sporco relativo alla gestione della memoria in modo che voi possiate concentrarvi solo sul vostro codice.

Allocazione della memoria Python

Tutto in Python è un oggetto. Affinché questi oggetti siano utili, hanno bisogno di essere immagazzinati nella memoria per essere accessibili. Prima che possano essere immagazzinati in memoria, una porzione di memoria deve essere allocata o assegnata per ciascuno di essi.

Al livello più basso, l’allocatore di memoria grezza di Python si assicura che ci sia spazio disponibile nell’heap privato per immagazzinare questi oggetti. Lo fa interagendo con il gestore di memoria del vostro sistema operativo. Consideratelo come se il vostro programma Python richiedesse al sistema operativo un pezzo di memoria con cui lavorare.

Al livello successivo, diversi allocatori specifici per gli oggetti operano sullo stesso heap e implementano politiche di gestione distinte a seconda del tipo di oggetto. Come forse già sapete, alcuni esempi di tipi di oggetto sono le stringhe e gli interi. Mentre le stringhe e gli interi possono non essere così diversi considerando quanto tempo impieghiamo per riconoscerli e memorizzarli, sono trattati in modo molto diverso dai computer. Questo perché i computer hanno bisogno di diversi requisiti di memorizzazione e compromessi di velocità per gli interi rispetto alle stringhe.

Un’ultima cosa che dovreste sapere su come viene gestito l’heap di Python è che avete zero controllo su di esso. Ora vi starete chiedendo, come facciamo a scrivere codice efficiente dal punto di vista della memoria se abbiamo così poco controllo sulla gestione della memoria di Python? Prima di entrare nel merito, abbiamo bisogno di capire ulteriormente alcuni termini importanti riguardanti la gestione della memoria.

Allocazione della memoria statica vs. dinamica

Ora che avete capito cos’è l’allocazione della memoria, è il momento di familiarizzare con i due tipi di allocazione della memoria, cioè statica e dinamica, e distinguere tra i due.

Allocazione statica della memoria:

  • Come suggerisce la parola “statico”, le variabili allocate staticamente sono permanenti, cioè devono essere allocate in anticipo e durano finché il programma viene eseguito.
  • La memoria viene allocata durante la compilazione, o prima dell’esecuzione del programma.
  • Impiegata usando la struttura dati dello stack, cioè le variabili vengono memorizzate nella memoria dello stack.
  • La memoria che è stata allocata non può essere riutilizzata, quindi nessuna riutilizzabilità della memoria.

Allocazione dinamica della memoria:

  • Come suggerisce la parola “dinamico”, le variabili allocate dinamicamente non sono permanenti e possono essere allocate mentre un programma è in esecuzione.
  • La memoria viene allocata in fase di esecuzione o durante l’esecuzione del programma.
  • Implementato utilizzando la struttura dati heap, il che significa che le variabili sono memorizzate nella memoria heap.
  • La memoria che è stata allocata può essere rilasciata e riutilizzata.

Un vantaggio dell’allocazione dinamica della memoria in Python è che non dobbiamo preoccuparci in anticipo di quanta memoria abbiamo bisogno per il nostro programma. Un altro vantaggio è che la manipolazione della struttura dei dati può essere fatta liberamente senza doversi preoccupare della necessità di una maggiore allocazione di memoria se la struttura dei dati si espande.

Tuttavia, poiché l’allocazione dinamica della memoria viene fatta durante l’esecuzione del programma, consumerà più tempo per il suo completamento. Inoltre, la memoria che è stata allocata deve essere liberata dopo che è stata utilizzata. Altrimenti, possono potenzialmente verificarsi problemi come perdite di memoria.

Ci siamo imbattuti in due tipi di strutture di memoria sopra – memoria heap e memoria stack. Diamo un’occhiata più approfondita ad esse.

Memoria stack

Tutti i metodi e le loro variabili sono memorizzati nella memoria stack. Ricordate che la memoria dello stack è allocata durante la compilazione? Questo significa effettivamente che l’accesso a questo tipo di memoria è molto veloce.

Quando un metodo viene chiamato in Python, viene allocato uno stack frame. Questo stack frame gestirà tutte le variabili del metodo. Dopo che il metodo viene restituito, lo stack frame viene automaticamente distrutto.

Nota che lo stack frame è anche responsabile dell’impostazione dello scope per le variabili di un metodo.

Memoria heap

Tutti gli oggetti e le variabili di istanza sono memorizzati nella memoria heap. Quando una variabile viene creata in Python, viene memorizzata in un heap privato che permetterà poi l’allocazione e la deallocazione.

La memoria heap permette a queste variabili di essere accessibili globalmente da tutti i metodi del vostro programma. Dopo che la variabile viene restituita, il garbage collector di Python si mette al lavoro, il cui funzionamento sarà trattato più avanti.

Ora diamo un’occhiata alla struttura della memoria di Python.

Python ha tre diversi livelli quando si tratta della sua struttura di memoria:

  • Arene
  • Pools
  • Blocchi

Iniziamo con il più grande di tutti: le arene.

Arene

Immaginate una scrivania con 64 libri che coprono la sua intera superficie. La parte superiore della scrivania rappresenta un’arena che ha una dimensione fissa di 256KiB che è allocata nell’heap (si noti che KiB è diverso da KB, ma si può assumere che siano la stessa cosa per questa spiegazione). Un’arena rappresenta il più grande pezzo di memoria possibile.

Più specificamente, le arene sono mappature di memoria che sono utilizzate dall’allocatore Python, pymalloc, che è ottimizzato per piccoli oggetti (inferiori o uguali a 512 byte). Le arene sono responsabili dell’allocazione della memoria, e quindi le strutture successive non devono più farlo.

Questa arena può poi essere ulteriormente suddivisa in 64 pool, che è la struttura di memoria successiva più grande.

Pools

Tornando all’esempio della scrivania, i libri rappresentano tutti i pool all’interno di un’arena.

Ogni pool avrebbe tipicamente una dimensione fissa di 4Kb e può avere tre possibili stati:

  • Vuoto: Il pool è vuoto e quindi disponibile per l’allocazione.
  • Usato: Il pool contiene oggetti che non lo rendono né vuoto né pieno.
  • Full: Il pool è pieno e quindi non disponibile per ulteriori assegnazioni.

Nota che la dimensione del pool dovrebbe corrispondere alla dimensione predefinita della pagina di memoria del tuo sistema operativo.

Un pool è quindi suddiviso in molti blocchi, che sono le strutture di memoria più piccole.

Blocchi

Ritornando all’esempio della scrivania, le pagine all’interno di ogni libro rappresentano tutti i blocchi all’interno di un pool.

A differenza di arene e pool, la dimensione di un blocco non è fissa. La dimensione di un blocco varia da 8 a 512 byte e deve essere un multiplo di otto.

Ogni blocco può memorizzare solo un oggetto Python di una certa dimensione e avere tre possibili stati:

  • Intatto: Non è stato allocato
  • Free: È stato allocato ma è stato rilasciato e reso disponibile per l’allocazione
  • Allocated: È stato allocato

Nota che i tre diversi livelli di una struttura di memoria (arene, pool e blocchi) che abbiamo discusso sopra sono specificamente per i piccoli oggetti Python. Gli oggetti grandi sono indirizzati all’allocatore standard C all’interno di Python, che sarebbe una buona lettura per un altro giorno.

Python Garbage Collection

Garbage collection è un processo eseguito da un programma per rilasciare la memoria precedentemente allocata per un oggetto che non è più in uso. Potete pensare alla garbage allocation come al riciclo o al riutilizzo della memoria.

In passato, i programmatori dovevano allocare e deallocare manualmente la memoria. Dimenticarsi di deallocare la memoria portava a una perdita di memoria, con un conseguente calo delle prestazioni di esecuzione. Peggio ancora, l’allocazione e la deallocazione manuale della memoria possono persino portare alla sovrascrittura accidentale della memoria, che può causare il blocco totale del programma.

In Python, la garbage collection viene fatta automaticamente e quindi vi risparmia un sacco di mal di testa nel gestire manualmente l’allocazione e la deallocazione della memoria. In particolare, Python usa il conteggio dei riferimenti combinato con la garbage collection generazionale per liberare la memoria inutilizzata. Il motivo per cui il conteggio dei riferimenti da solo non è sufficiente per Python è che non pulisce efficacemente i riferimenti ciclici dangling.

Un ciclo di garbage collection generazionale contiene i seguenti passi –

  1. Python inizializza una “discard list” per gli oggetti inutilizzati.
  2. Viene eseguito un algoritmo per rilevare i cicli di riferimento.
  3. Se un oggetto manca di riferimenti esterni, viene inserito nella lista di scarto.
  4. Libera l’allocazione di memoria per gli oggetti nella lista di scarto.

Per saperne di più sulla garbage collection in Python, potete controllare il nostro post Python Garbage Collection: A Guide for Developers post.

Monitoraggio dei problemi di memoria in Python

Mentre tutti amano Python, esso non evita di avere problemi di memoria. Ci sono molte possibili ragioni.

Secondo la documentazione di Python (3.9.0) per la gestione della memoria, il gestore di memoria Python non rilascia necessariamente la memoria al sistema operativo. Si afferma nella documentazione che “in certe circostanze, il gestore di memoria Python potrebbe non innescare azioni appropriate, come la garbage collection, la compattazione della memoria o altre misure preventive.”

Come risultato, uno potrebbe dover liberare esplicitamente la memoria in Python. Un modo per farlo è forzare il garbage collector di Python a rilasciare la memoria inutilizzata facendo uso del modulo gc. Uno ha semplicemente bisogno di eseguire gc.collect() per farlo. Questo, tuttavia, fornisce benefici notevoli solo quando si manipola un numero molto grande di oggetti.

A parte la natura occasionalmente errata del garbage collector di Python, specialmente quando si ha a che fare con grandi insiemi di dati, diverse librerie Python sono anche note per causare perdite di memoria. Pandas, per esempio, è uno di questi strumenti sul radar. Considerate di dare un’occhiata a tutti i problemi relativi alla memoria nel repository ufficiale GitHub di Pandas!

Una ragione ovvia che può sfuggire anche agli occhi attenti dei revisori di codice è che ci sono oggetti di grandi dimensioni persistenti nel codice che non vengono rilasciati. Sulla stessa nota, strutture di dati che crescono all’infinito sono un’altra causa di preoccupazione. Per esempio, una struttura dati a dizionario in crescita senza un limite di dimensione fisso.

Un modo per risolvere la struttura dati in crescita è quello di convertire il dizionario in una lista, se possibile, e impostare una dimensione massima per la lista. Altrimenti, impostate semplicemente un limite per la dimensione del dizionario e cancellatelo ogni volta che il limite viene raggiunto.

Ora vi starete chiedendo, come faccio a rilevare i problemi di memoria in primo luogo? Una possibilità è quella di sfruttare uno strumento di Application Performance Monitoring (APM). Inoltre, molti utili moduli Python possono aiutarvi a tracciare i problemi di memoria. Diamo un’occhiata alle nostre opzioni, iniziando dagli strumenti APM.

Strumenti di monitoraggio delle prestazioni dell’applicazione (APM)

Cos’è esattamente il monitoraggio delle prestazioni dell’applicazione e come aiuta a rintracciare i problemi di memoria? Uno strumento APM ti permette di osservare in tempo reale le metriche delle prestazioni di un programma, consentendoti un’ottimizzazione continua man mano che scopri i problemi che limitano le prestazioni.

Sulla base dei rapporti generati dagli strumenti APM, sarai in grado di avere un’idea generale su come il tuo programma sta funzionando. Dal momento che è possibile ricevere e monitorare le metriche delle prestazioni in tempo reale, è possibile intraprendere azioni immediate su qualsiasi problema osservato. Una volta che avete ristretto le possibili aree del vostro programma che possono essere i colpevoli dei problemi di memoria, potete poi immergervi nel codice e discuterne con gli altri collaboratori del codice per determinare ulteriormente le specifiche linee di codice che devono essere sistemate.

Trovare la radice dei problemi di perdita di memoria può essere un compito scoraggiante. Risolverlo è un altro incubo, poiché è necessario comprendere veramente il vostro codice. Se vi trovate in questa posizione, non cercate oltre perché ScoutAPM è uno strumento di APM competente che può analizzare e ottimizzare in modo costruttivo le prestazioni della vostra applicazione. ScoutAPM ti dà una visione in tempo reale in modo da poter rapidamente individuare &risolvere i problemi prima che i tuoi clienti li individuino.

Moduli di profilo

Ci sono molti moduli Python utili che puoi usare per risolvere i problemi di memoria, sia che si tratti di una perdita di memoria o che il tuo programma vada in crash a causa di un uso eccessivo della memoria. Due di quelli raccomandati sono:

  1. tracemalloc
  2. memory-profiler

Nota che solo il modulo tracemalloc è integrato, quindi assicurati di installare prima l’altro modulo se vuoi usarlo.

tracemalloc

Secondo la documentazione di Python (3.9.0) per tracemalloc, usare questo modulo può fornire le seguenti informazioni:

  • Traceback dove un oggetto è stato allocato.
  • Statistiche sui blocchi di memoria allocati per nome di file e per numero di linea: dimensione totale, numero e dimensione media dei blocchi di memoria allocati.
  • Computa la differenza tra due istantanee per rilevare perdite di memoria.

Un primo passo raccomandato che dovresti fare per determinare la fonte del problema di memoria è visualizzare prima i file che allocano più memoria. Potete farlo facilmente usando il primo esempio di codice mostrato nella documentazione.

Questo però non significa che i file che allocano una piccola quantità di memoria non cresceranno indefinitamente per causare perdite di memoria in futuro.

memory_profiler

Questo modulo è divertente. Ho lavorato con questo ed è uno dei miei preferiti perché fornisce la possibilità di aggiungere semplicemente il decoratore @profile a qualsiasi funzione che si desidera indagare. L’output dato come risultato è anche molto facilmente comprensibile.

Un’altra ragione che lo rende il mio preferito è che questo modulo permette di tracciare un grafico dell’uso della memoria basato sul tempo. A volte, avete semplicemente bisogno di un rapido controllo per vedere se l’utilizzo della memoria continua ad aumentare indefinitamente o no. Questa è la soluzione perfetta per questo, dato che non c’è bisogno di fare un profiling della memoria linea per linea per confermarlo. Potete semplicemente osservare il grafico tracciato dopo aver lasciato il profiler in esecuzione per una certa durata. Ecco un esempio del grafico in uscita –

Secondo la descrizione nella documentazione di memory-profiler, questo modulo Python è per il monitoraggio del consumo di memoria di un processo così come un’analisi linea per linea dello stesso per i programmi Python. È un modulo Python puro che dipende dalla libreria psutil.

Consiglio di leggere questo blog Medium per esplorare ulteriormente come viene usato memory-profiler. Lì imparerete anche come usare un altro modulo Python, muppy (l’ultimo è muppy3).

Best Practices for Improving Python Code Performance

Basta con tutti i dettagli sulla gestione della memoria. Ora esploriamo alcune delle buone abitudini nello scrivere codice Python efficiente dal punto di vista della memoria.

Sfruttare le librerie Python e le funzioni integrate

Sì, questa è una buona abitudine che può essere spesso trascurata. Python ha un supporto comunitario senza rivali e questo si riflette nelle abbondanti librerie Python disponibili per quasi tutti gli scopi, dalle chiamate API alla scienza dei dati.

Se c’è una libreria Python là fuori che vi permette di fare la stessa cosa che avete già implementato, quello che potete fare è confrontare le prestazioni del vostro codice quando usate la libreria rispetto a quando usate il vostro codice personalizzato. È probabile che le librerie Python (specialmente quelle popolari) siano più efficienti in termini di memoria rispetto al vostro codice, perché sono continuamente migliorate in base al feedback della comunità. Preferireste affidarvi ad un codice creato in una notte o ad uno che è stato rigorosamente migliorato per un lungo periodo?

Meglio di tutto, le librerie Python vi faranno risparmiare molte righe di codice, quindi perché no?

Non usare “+” per la concatenazione di stringhe

A un certo punto, siamo stati tutti colpevoli di concatenare stringhe usando l’operatore “+” perché sembra così facile.

Nota che le stringhe sono immutabili. Quindi, ogni volta che aggiungete un elemento ad una stringa con l’operatore “+”, Python deve creare una nuova stringa con una nuova allocazione di memoria. Con stringhe più lunghe, l’inefficienza della memoria del codice diventerà più pronunciata.

Utilizzare itertools per un looping efficiente

Il looping è una parte essenziale dell’automazione. Continuando ad usare i loop sempre di più, alla fine ci troveremo a dover usare loop annidati, che sono noti per essere inefficienti a causa della loro elevata complessità di esecuzione.

Ecco dove il modulo itertools viene in soccorso. Secondo la documentazione di Python itertools, “Il modulo standardizza un nucleo di strumenti veloci ed efficienti in termini di memoria che sono utili da soli o in combinazione. Insieme, rendono possibile costruire strumenti specializzati in modo succinto ed efficiente in puro Python.”

In altre parole, il modulo itertools permette un looping efficiente in termini di memoria liberandosi dei loop inutili. È interessante notare che il modulo itertools è chiamato una gemma perché permette di comporre soluzioni eleganti per una miriade di problemi.

Sono abbastanza sicuro che lavorerete con almeno un ciclo nel vostro prossimo pezzo di codice, quindi provate ad implementare itertools allora!

Recap e pensieri di chiusura

Applicare buone abitudini di gestione della memoria in Python non è per il programmatore casuale. Se di solito ve la cavate con semplici script, non dovreste affatto incorrere in problemi legati alla memoria. Grazie all’hardware e al software che continuano a progredire rapidamente mentre leggete questo articolo, il modello base di quasi tutti i dispositivi là fuori, indipendentemente dalla loro marca, dovrebbe far girare bene i programmi di tutti i giorni. La necessità di un codice efficiente in termini di memoria inizia a mostrarsi solo quando si inizia a lavorare su una grande base di codice, specialmente per la produzione, dove le prestazioni sono fondamentali.

Tuttavia, questo non suggerisce che la gestione della memoria in Python sia un concetto difficile da afferrare, né significa che non sia importante. Questo perché l’enfasi sulle prestazioni delle applicazioni sta crescendo ogni giorno. Un giorno, non sarà più solo una semplice questione di “fatto”. Invece, gli sviluppatori saranno in competizione per fornire una soluzione che non solo è in grado di risolvere con successo le esigenze dei clienti, ma lo fa anche con una velocità incredibile e risorse minime.