web-dev-qa-db-de.com

Warum ist malloc + memset langsamer als calloc?

Es ist bekannt, dass calloc sich von malloc darin unterscheidet, dass es den zugewiesenen Speicher initialisiert. Mit calloc wird der Speicher auf Null gesetzt. Mit malloc wird der Speicher nicht gelöscht.

In der täglichen Arbeit betrachte ich calloc als malloc + memset. Übrigens habe ich zum Spaß den folgenden Code für einen Benchmark geschrieben.

Das Ergebnis ist verwirrend.

Code 1:

#include<stdio.h>
#include<stdlib.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)calloc(1,BLOCK_SIZE);
                i++;
        }
}

Ausgabe von Code 1:

time ./a.out  
**real 0m0.287s**  
user 0m0.095s  
sys 0m0.192s  

Code 2:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)malloc(BLOCK_SIZE);
                memset(buf[i],'\0',BLOCK_SIZE);
                i++;
        }
}

Ausgabe von Code 2:

time ./a.out   
**real 0m2.693s**  
user 0m0.973s  
sys 0m1.721s  

Das Ersetzen von memset durch bzero(buf[i],BLOCK_SIZE) in Code 2 führt zum gleichen Ergebnis.

Meine Frage ist: Warum ist malloc + memset so viel langsamer als calloc? Wie kann calloc das tun?

244
kingkai

Die Kurzversion: Verwenden Sie immer calloc() anstelle von malloc()+memset(). In den meisten Fällen sind sie gleich. In einigen Fällen wird calloc() weniger Arbeit verrichten, da memset() vollständig übersprungen werden kann. In anderen Fällen kann calloc() sogar schummeln und keinen Speicher zuweisen! malloc()+memset() erledigt jedoch immer die gesamte Arbeit.

Um dies zu verstehen, ist eine kurze Tour durch das Speichersystem erforderlich.

Kurzer Überblick über die Erinnerung

Hier gibt es vier Hauptteile: Ihr Programm, die Standardbibliothek, den Kernel und die Seitentabellen. Sie kennen Ihr Programm bereits, also ...

Speicherzuordnungen wie malloc() und calloc() sind meistens dazu da, kleine Zuordnungen (von 1 Byte bis 100 KB) vorzunehmen und sie in größeren Speicherpools zu gruppieren. Wenn Sie beispielsweise 16 Bytes zuweisen, versucht malloc() zunächst, 16 Bytes aus einem seiner Pools abzurufen, und fordert dann mehr Speicher vom Kernel an, wenn der Pool leer ist. Da das Programm, nach dem Sie fragen, jedoch eine große Menge an Speicher auf einmal reserviert, fordern malloc() und calloc() diesen Speicher nur direkt vom Kernel an. Der Schwellenwert für dieses Verhalten hängt von Ihrem System ab, es wurde jedoch 1 MB als Schwellenwert verwendet.

Der Kernel ist dafür verantwortlich, jedem Prozess das aktuelle RAM zuzuweisen und sicherzustellen, dass Prozesse den Speicher anderer Prozesse nicht beeinträchtigen. Dies wird als Speicherschutz bezeichnet, er ist seit den 1990er Jahren ein verbreitetes Problem und der Grund, warum ein Programm abstürzen kann, ohne das gesamte System herunterzufahren. Wenn ein Programm also mehr Speicher benötigt, kann es nicht nur den Speicher beanspruchen, sondern fordert stattdessen den Speicher des Kernels mit einem Systemaufruf wie mmap() oder sbrk() an. Der Kernel gibt jedem Prozess RAM, indem er die Seitentabelle ändert.

Die Seitentabelle ordnet Speicheradressen dem tatsächlichen physischen RAM zu. Die Adressen Ihres Prozesses, 0x00000000 bis 0xFFFFFFFF auf einem 32-Bit-System, sind kein realer Speicher, sondern Adressen im virtuellen Speicher . Der Prozessor teilt diese Adressen auf Jede Seite kann durch Ändern der Seitentabelle einem anderen physischen Teil RAM zugewiesen werden. Nur der Kernel darf die Seitentabelle ändern.

Wie es nicht geht

So funktioniert die Zuweisung von 256 MB nicht :

  1. Ihr Prozess ruft calloc() auf und fordert 256 MiB an.

  2. Die Standardbibliothek ruft mmap() auf und fordert 256 MiB an.

  3. Der Kernel findet 256 MiB nicht verwendeten RAM und gibt ihn an Ihren Prozess weiter, indem er die Seitentabelle ändert.

  4. Die Standardbibliothek setzt RAM mit memset() auf Null und gibt calloc() zurück.

  5. Ihr Prozess wird schließlich beendet, und der Kernel fordert das RAM zurück, damit es von einem anderen Prozess verwendet werden kann.

Wie es tatsächlich funktioniert

Der obige Prozess würde funktionieren, aber es passiert einfach nicht so. Es gibt drei Hauptunterschiede.

  • Wenn Ihr Prozess neuen Speicher vom Kernel erhält, wurde dieser Speicher wahrscheinlich zuvor von einem anderen Prozess verwendet. Dies ist ein Sicherheitsrisiko. Was ist, wenn dieser Speicher Passwörter, Verschlüsselungsschlüssel oder geheime Salsarezepte enthält? Um zu verhindern, dass vertrauliche Daten verloren gehen, löscht der Kernel immer den Speicher, bevor er ihn einem Prozess übergibt. Wir können den Speicher genauso gut bereinigen, indem wir ihn auf Null setzen, und wenn neuer Speicher auf Null gesetzt wird, können wir ihn auch als Garantie verwenden. mmap() garantiert also, dass der neue Speicher, den er zurückgibt, immer auf Null gesetzt wird.

  • Es gibt viele Programme, die Speicher zuweisen, aber den Speicher nicht sofort nutzen. Manchmal wird Speicher reserviert, aber nie verwendet. Der Kernel weiß das und ist faul. Wenn Sie neuen Speicher zuweisen, berührt der Kernel die Seitentabelle überhaupt nicht und gibt Ihrem Prozess kein RAM. Stattdessen findet es einen Adressraum in Ihrem Prozess, notiert sich, was dahin gehen soll, und verspricht, dass es RAM dort ablegt, wenn Ihr Programm es jemals tatsächlich verwendet. Wenn Ihr Programm versucht, von diesen Adressen zu lesen oder zu schreiben, löst der Prozessor einen Seitenfehler aus, und die Kernel-Schritte weisen RAM zu Diese Adressen und setzt Ihr Programm fort. Wenn Sie den Speicher nie verwenden, tritt der Seitenfehler nie auf, und Ihr Programm erhält nie den RAM.

  • Einige Prozesse reservieren Speicher und lesen ihn dann aus, ohne ihn zu ändern. Dies bedeutet, dass viele Seiten im Speicher über verschiedene Prozesse hinweg möglicherweise mit ursprünglichen Nullen gefüllt sind, die von mmap() zurückgegeben wurden. Da diese Seiten alle gleich sind, legt der Kernel fest, dass alle diese virtuellen Adressen auf eine einzelne gemeinsam genutzte 4-KB-Speicherseite verweisen, die mit Nullen gefüllt ist. Wenn Sie versuchen, in diesen Speicher zu schreiben, löst der Prozessor einen weiteren Seitenfehler aus, und der Kernel mischt sich ein, um Ihnen eine neue Seite mit Nullen zu geben, die nicht mit anderen Programmen geteilt wird.

Der endgültige Prozess sieht ungefähr so ​​aus:

  1. Ihr Prozess ruft calloc() auf und fordert 256 MiB an.

  2. Die Standardbibliothek ruft mmap() auf und fordert 256 MiB an.

  3. Der Kernel findet 256 MB unbenutzten Adressraum, notiert, wofür dieser Adressraum jetzt verwendet wird, und gibt ihn zurück.

  4. Die Standardbibliothek weiß, dass das Ergebnis von mmap() immer mit Nullen gefüllt ist (oder wird sein, sobald es tatsächlich etwas RAM erhält), so dass es sich nicht berührt der Speicher, so gibt es keinen Seitenfehler, und das RAM wird Ihrem Prozess nie gegeben.

  5. Ihr Prozess wird schließlich beendet, und der Kernel muss das RAM nicht zurückfordern, da es überhaupt nicht zugewiesen wurde.

Wenn Sie memset() verwenden, um die Seite auf Null zu setzen, löst memset() den Seitenfehler aus, bewirkt, dass RAM zugewiesen wird, und setzt ihn dann auf Null, obwohl er bereits mit Nullen gefüllt ist. Dies ist ein enormer Mehraufwand und erklärt, warum calloc() schneller ist als malloc() und memset(). Wenn calloc() trotzdem den Speicher belegt, ist es immer noch schneller als malloc() und memset(), aber der Unterschied ist nicht ganz so lächerlich.


Das funktioniert nicht immer

Nicht alle Systeme verfügen über einen ausgelagerten virtuellen Speicher. Daher können nicht alle Systeme diese Optimierungen verwenden. Dies gilt sowohl für sehr alte Prozessoren wie den 80286 als auch für Embedded-Prozessoren, die für eine ausgeklügelte Speicherverwaltungseinheit einfach zu klein sind.

Dies funktioniert auch nicht immer mit kleineren Zuordnungen. Bei kleineren Zuordnungen ruft calloc() Speicher aus einem gemeinsam genutzten Pool ab, anstatt direkt zum Kernel zu gelangen. Im Allgemeinen sind in dem gemeinsam genutzten Pool möglicherweise Junk-Daten aus dem alten Speicher gespeichert, der mit free() verwendet und freigegeben wurde, sodass calloc() diesen Speicher belegen und memset() zum Löschen aufrufen kann. Bei allgemeinen Implementierungen wird nachverfolgt, welche Teile des gemeinsam genutzten Pools unberührt und immer noch mit Nullen gefüllt sind. Dies wird jedoch nicht bei allen Implementierungen durchgeführt.

Einige falsche Antworten zerstreuen

Abhängig vom Betriebssystem kann der Kernel in seiner Freizeit Speicher auf Null setzen oder nicht, falls Sie später Speicher auf Null setzen müssen. Linux setzt den Speicher nicht vorzeitig auf Null und Dragonfly BSD hat dieses Feature kürzlich auch aus seinem Kernel entfernt . Einige andere Kernel machen jedoch vorher keinen Speicherplatz. Das Nullsetzen von Seiten im Leerlauf reicht ohnehin nicht aus, um die großen Leistungsunterschiede zu erklären.

Die calloc()-Funktion verwendet keine spezielle speicherausgerichtete Version von memset(), und das würde sie sowieso nicht viel schneller machen. Die meisten memset()-Implementierungen für moderne Prozessoren sehen ungefähr so ​​aus:

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

Sie sehen also, memset() ist sehr schnell und Sie werden für große Speicherblöcke nicht wirklich etwas Besseres bekommen.

Die Tatsache, dass memset() einen Speicher auf Null setzt, der bereits auf Null gesetzt ist, bedeutet, dass der Speicher zweimal auf Null gesetzt wird, dies erklärt jedoch nur einen doppelten Leistungsunterschied. Der Leistungsunterschied ist hier viel größer (ich habe auf meinem System mehr als drei Größenordnungen zwischen malloc()+memset() und calloc() gemessen).

Partytrick

Schreiben Sie ein Programm, das Speicher zuweist, bis malloc() oder calloc() NULL zurückgibt, anstatt 10-mal in einer Schleife zu laufen.

Was passiert, wenn Sie memset() hinzufügen?

436
Dietrich Epp

Auf vielen Systemen setzt das Betriebssystem in der Freizeit den freien Speicher auf Null und markiert ihn als sicher für calloc(). Wenn Sie also calloc() aufrufen, ist dies möglicherweise bereits der Fall Habe freien, auf Null gesetzten Speicher, der dir zur Verfügung steht.

12
Chris Lutz

Auf einigen Plattformen initialisiert malloc in einigen Modi den Speicher vor der Rückgabe auf einen Wert ungleich Null, sodass die zweite Version den Speicher durchaus zweimal initialisieren könnte

1
Stewart