La versione breve: utilizzare sempre calloc()
anziché malloc()+memset()
. Nella maggior parte dei casi, saranno gli stessi. In alcuni casi, calloc()
farà meno lavoro perché può saltare memset()
completamente. In altri casi, calloc()
puoi persino imbrogliare e non allocare memoria! Tuttavia, malloc()+memset()
farà sempre l'intero lavoro.
Capire questo richiede un breve tour del sistema di memoria.
Tour rapido della memoria
Ci sono quattro parti principali qui: il tuo programma, la libreria standard, il kernel e le tabelle delle pagine. Conosci già il tuo programma, quindi ...
Gli allocatori di memoria amano malloc()
e calloc()
sono principalmente lì a prendere piccole allocazioni (da 1 byte a 100s di KB) e raggrupparle in pool di memoria più grandi. Ad esempio, se si allocano 16 byte, malloc()
si tenterà innanzitutto di ottenere 16 byte da uno dei suoi pool, quindi chiedere più memoria dal kernel quando il pool si esaurisce. Tuttavia, poiché il programma di cui stai chiedendo si sta allocando per una grande quantità di memoria in una volta, malloc()
e calloc()
chiederà semplicemente quella memoria direttamente dal kernel. La soglia per questo comportamento dipende dal tuo sistema, ma ho visto 1 MiB usato come soglia.
Il kernel è responsabile per l'allocazione della RAM effettiva a ciascun processo e per assicurarsi che i processi non interferiscano con la memoria di altri processi. Si chiama protezione della memoria, è stato molto diffuso dagli anni '90 ed è la ragione per cui un programma può andare in crash senza far cadere l'intero sistema. Quindi quando un programma ha bisogno di più memoria, non può semplicemente prendere la memoria, ma invece richiede la memoria dal kernel usando una chiamata di sistema come mmap()
o sbrk()
. Il kernel darà RAM ad ogni processo modificando la tabella delle pagine.
La tabella delle pagine mappa gli indirizzi di memoria sulla RAM fisica effettiva. Gli indirizzi del processo, da 0x00000000 a 0xFFFFFFFF su un sistema a 32 bit, non sono memoria reale ma sono invece indirizzi nella memoria virtuale. Il processore divide questi indirizzi in 4 pagine KiB e ogni pagina può essere assegnata a un diverso pezzo di RAM fisica modificando la tabella delle pagine. Solo il kernel è autorizzato a modificare la tabella delle pagine.
Come non funziona
Ecco come l'allocazione di 256 MiB non funziona:
Il processo chiama calloc()
e richiede 256 MiB.
La libreria standard chiama mmap()
e richiede 256 MiB.
Il kernel trova 256 MiB di RAM inutilizzata e lo fornisce al tuo processo modificando la tabella delle pagine.
La libreria standard azzera la RAM con memset()
e ritorna da calloc()
.
Alla fine il processo termina e il kernel recupera la RAM in modo che possa essere utilizzata da un altro processo.
Come funziona davvero
Il processo sopra descritto funzionerebbe, ma non succede in questo modo. Vi sono tre differenze principali.
Quando il tuo processo ottiene nuova memoria dal kernel, quella memoria è stata probabilmente usata da qualche altro processo in precedenza. Questo è un rischio per la sicurezza. E se quella memoria avesse password, chiavi di crittografia o ricette segrete di salsa? Per evitare la perdita di dati sensibili, il kernel pulisce sempre la memoria prima di consegnarla a un processo. Potremmo anche cancellare la memoria azzerandola, e se la nuova memoria viene azzerata potremmo anche renderla una garanzia, in modo da mmap()
garantire che la nuova memoria che restituisce sia sempre azzerata.
Esistono molti programmi che allocano la memoria ma non la usano subito. Alcune volte la memoria viene allocata ma mai utilizzata. Il kernel lo sa ed è pigro. Quando si alloca nuova memoria, il kernel non tocca affatto la tabella delle pagine e non fornisce RAM al processo. Invece, trova dello spazio degli indirizzi nel tuo processo, prende nota di ciò che dovrebbe andare lì e promette che metterà RAM lì se il tuo programma lo utilizza realmente. Quando il programma tenta di leggere o scrivere da tali indirizzi, il processore attiva un errore di pagina e il kernel passa a assegnare la RAM a tali indirizzi e riprende il programma. Se non si utilizza mai la memoria, l'errore di pagina non si verifica mai e il programma non ottiene mai effettivamente la RAM.
Alcuni processi allocano la memoria e quindi la leggono senza modificarla. Ciò significa che molte pagine in memoria attraverso processi diversi possono essere riempite con zero incontaminati restituiti mmap()
. Poiché queste pagine sono tutte uguali, il kernel fa in modo che tutti questi indirizzi virtuali puntino a una singola pagina di memoria condivisa da 4 KiB piena di zero. Se provi a scrivere su quella memoria, il processore innesca un altro errore di pagina e il kernel interviene per darti una nuova pagina di zero che non è condivisa con nessun altro programma.
Il processo finale è più simile a questo:
Il processo chiama calloc()
e richiede 256 MiB.
La libreria standard chiama mmap()
e richiede 256 MiB.
Il kernel trova 256 MiB di spazio di indirizzi inutilizzato , prende nota di ciò per cui ora viene utilizzato tale spazio di indirizzi e restituisce.
La libreria standard sa che il risultato mmap()
è sempre pieno di zero (o lo sarà una volta che avrà effettivamente un po 'di RAM), quindi non tocca la memoria, quindi non c'è nessun errore di pagina e la RAM non viene mai assegnata al processo .
Alla fine il tuo processo termina e il kernel non ha bisogno di recuperare la RAM perché non è mai stato allocato in primo luogo.
Se si utilizza memset()
per azzerare la pagina, memset()
verrà attivato l'errore della pagina, verrà allocata la RAM e quindi azzerata anche se è già piena di zero. Questa è un'enorme quantità di lavoro extra e spiega perché calloc()
è più veloce di malloc()
e memset()
. Se finisce per usare la memoria comunque, calloc()
è ancora più veloce di malloc()
e memset()
ma la differenza non è così ridicola.
Questo non funziona sempre
Non tutti i sistemi dispongono di memoria virtuale paginata, quindi non tutti i sistemi possono utilizzare queste ottimizzazioni. Questo vale per processori molto vecchi come l'80286 e per processori integrati che sono troppo piccoli per una sofisticata unità di gestione della memoria.
Anche questo non funzionerà sempre con allocazioni più piccole. Con allocazioni più piccole, calloc()
ottiene la memoria da un pool condiviso invece di andare direttamente al kernel. In generale, il pool condiviso potrebbe contenere dati spazzatura memorizzati nella vecchia memoria utilizzata e liberata free()
, quindi calloc()
potrebbe richiedere quella memoria e chiamare memset()
per cancellarla. Le implementazioni comuni monitoreranno quali parti del pool condiviso sono incontaminate e ancora piene di zero, ma non tutte le implementazioni lo fanno.
Dissolvere alcune risposte sbagliate
A seconda del sistema operativo, il kernel può o meno azzerare la memoria nel suo tempo libero, nel caso in cui sia necessario recuperare un po 'di memoria azzerata in seguito. Linux non azzera la memoria in anticipo e Dragonfly BSD ha recentemente rimosso anche questa funzione dal proprio kernel . Tuttavia, altri kernel eseguono zero memoria in anticipo. L'azzeramento delle pagine durante il periodo di inattività non è sufficiente per spiegare comunque le grandi differenze di prestazioni.
La calloc()
funzione non utilizza una versione speciale allineata di memoria di memset()
, e ciò non lo renderebbe comunque molto più veloce. La maggior parte delle memset()
implementazioni per i processori moderni sembrano in questo modo:
function memset(dest, c, len)
// one byte at a time, until the dest is aligned...
while (len > 0 && ((unsigned int)dest & 15))
*dest++ = c
len -= 1
// now write big chunks at a time (processor-specific)...
// block size might not be 16, it's just pseudocode
while (len >= 16)
// some optimized vector code goes here
// glibc uses SSE2 when available
dest += 16
len -= 16
// the end is not aligned, so one byte at a time
while (len > 0)
*dest++ = c
len -= 1
Quindi puoi vedere, memset()
è molto veloce e non otterrai nulla di meglio per grandi blocchi di memoria.
Il fatto che l' memset()
azzeramento della memoria sia già azzerato significa che la memoria viene azzerata due volte, ma ciò spiega solo una differenza di prestazioni 2x. La differenza di prestazioni qui è molto più grande (ho misurato più di tre ordini di grandezza sul mio sistema tra malloc()+memset()
e calloc()
).
Trucco di festa
Invece di loop 10 volte, scrivere un programma che alloca la memoria fino a quando malloc()
o calloc()
restituisce null.
Cosa succede se aggiungi memset()
?