Fragmentierung und Out-of-Memory-Handling
Ein Out-of-Memory-Error (OOM) tritt auf, wenn ein Programm keinen Speicher mehr allozieren kann. Ursache ist entweder echter Speichermangel oder Fragmentierung.
Echter Speichermangel liegt vor, wenn der verfügbare physische Speicher (RAM) oder der virtuelle Adressraum erschöpft ist und das Betriebssystem keine weiteren Speicherseiten bereitstellen kann. Das tritt auf, wenn ein Programm große Datenmengen verarbeitet oder viele Prozesse gleichzeitig aktiv sind und zusammen den Speicher des Systems ausschöpfen.
Im zweiten Fall liegt meist Fragmentierung vor. Der Heap enthält zwar noch freie Bereiche, aber sie sind über den Speicher verteilt und nicht groß genug, um eine neue Anforderung zu erfüllen. Das Programm scheitert also nicht an zu wenig Speicher, sondern an dessen Aufteilung.
Man unterscheidet zwei Formen der Fragmentierung:
- Externe Fragmentierung: freie Bereiche sind nicht zusammenhängend.
- Interne Fragmentierung : allozierte Blöcke sind größer als nötig, und der überschüssige Platz bleibt ungenutzt.
Externe Fragmentation
Externe Fragmentierung entsteht durch wiederholte Allokation und Freigeben von Speicherblöcken unterschiedlicher Größe. Dadurch bilden sich Lücken (freie Blöcke) zwischen belegten Bereichen, die einzeln zu klein sind, um größere Anfragen zu erfüllen.
Stellen wir uns einen Heap mit 8 Speicherplätzen vor (· steht für freien Speicher):
| · | · | · | · | · | · | · | ·
Nach Allokationen (A: 3 Plätze, B: 2, C: 1, D: 2):
| A | A | A | B | B | C | D | D
Nach Freigabe von B und D:
| A | A | A | · | · | C | · | ·
Nun möchte das Programm einen Block EEEE (Größe 4) allozieren. Insgesamt sind 4 Plätze frei, aber kein zusammenhängender Bereich von 4 Plätzen. Die Allokation scheitert. Genau das ist externe Fragmentierung.
Hier ein C-Beispiel, das externe Fragmentierung demonstriert (beachte: Das Verhalten hängt vom Heap-Allocator ab, z. B. First-Fit, das den ersten passenden freien Block nimmt):
#include <stdlib.h>
#include <stddef.h>
int main() {
constexpr size_t SIZE = 100;
void *blocks[SIZE];
// Alloziere 100 kleine Blöcke
for (size_t i = 0; i < SIZE; i++) {
blocks[i] = malloc(SIZE);
}
// Gebe jeden zweiten Block frei
for (size_t i = 0; i < SIZE; i += 2) {
free(blocks[i]);
blocks[i] = nullptr;
}
// Jetzt gibt es 50 freie Blöcke à 100 Bytes (plus Overhead)
// Aber sie sind nicht zusammenhängend
// Versuche, einen ""großen"" Block zu allozieren
void *large = malloc(50 * SIZE);
if (!large) {
// Fragmentierung verhindert Allokation
} else {
free(large);
}
// Aufräumen
for (size_t i = 1; i < SIZE; i += 2) {
free(blocks[i]);
}
}
Die 50 freien Blöcke ergeben zusammen 5000 Bytes, sind aber nicht zusammenhängend. Die Allokation von 5000 Bytes schlägt fehl, da sich die 100-Byte-Blöcke nicht zu einem großen Block verschmelzen lassen. Das ist ein Out-of-Memory trotz ausreichend freiem Speicher.
Interne Fragmentierung
Interne Fragmentierung tritt innerhalb eines allozierten Blocks auf, wenn mehr Speicher zugewiesen wird, als angefordert. Gründe:
-
Rundung: Allocatoren runden auf feste Größen (z. B. Vielfache von 8/16 Bytes oder Zweierpotenzen).
-
Metadaten: Zusätzlicher Platz für Verwaltungsinfos (z. B. Blockgröße, Zeiger).
-
Alignment: CPU-spezifische Anforderungen erzwingen Füllbytes. In Abschnitt \ab{Alignment} dazu mehr.
Der ungenutzte Teil ist verschwendet, da er belegt, aber nicht genutzt wird. Im Gegensatz zur externen Fragmentierung geht es hier um Ineffizienz innerhalb von Blöcken, nicht um Lücken dazwischen.
Ein Beispiel: Ein Programm liest ein Wörterbuch mit unterschiedlich langen Wörtern ein. Wörter sind meist klein, oft unter 100 Byte. Nehmen wir das Wort ›Speicherfragmentierung‹. Es hat 22 Zeichen plus Null-Byte, also 23 Bytes Nutzdaten.
Der Allocator arbeitet jedoch mit festen Blockgrößen. Eine Anforderung von 23 Bytes wird auf den nächsten passenden Block, etwa 32 Bytes, aufgerundet. Die Differenz von 9 Bytes bleibt innerhalb des Blocks ungenutzt.
Diese ungenutzten 9 Bytes sind interne Fragmentierung, also knapp ein Drittel des allozierten Speichers. Sie gehören zum Block, sind aber unnutzbar. In großen Programmen mit vielen kleinen Objekten summiert sich dieser Effekt erheblich.
Reduktion von Fragmentation
Man kann Fragmentation nicht vollständig vermeiden, aber ihre Auswirkungen verringern. Eine wirksame Methode ist, Allokationen ähnlicher Größe zu gruppieren. Wenn viele kleine Objekte gleicher Größe benötigt werden, kann man einen großen Block allozieren und diesen selbst verwalten.
Für langlebige Programme bietet sich dafür Memory Pooling an: Speicher wird im Voraus blockweise reserviert, etwa für häufig genutzte Objekte. Statt jedes Mal malloc() und free() zu verwenden, werden Blöcke aus dem Pool entnommen und zurückgegeben, wenn sie frei sind. Das reduziert Fragmentation und macht Allokationen schneller und vorhersehbarer.
Eine andere Strategie ist, langlebige und kurzlebige Allokationen zu trennen. Langlebige Daten sollten früh allokiert werden, kurzlebige später. Das verhindert, dass langlebige Blöcke zwischen kurzlebigen stecken bleiben und Löcher hinterlassen, wenn die kurzlebigen freigegeben werden.
Umgang mit Out-of-Memory-Situationen
Ein Out-of-Memory tritt auf, wenn eine Speicherallokation fehlschlägt. Die Ursachen können vielfältig sein, und ein robustes Programm muss mit allen Szenarien umgehen können.
Zur grundlegenden Fehlerbehandlung gehört:
-
Die Basis jeder robusten Speicherverwaltung ist die konsequente Prüfung jeder Allokation. Bei fehlgeschlagener Allokation geben
malloc(),calloc()undrealloc()den Null-Pointer zurück. Daraufhin ist zu prüfen und angemessen zu reagieren. Diese Prüfung darf niemals weggelassen werden, auch nicht bei ›kleinen‹ Allokationen. Unter POSIX-konformen Systemen (Linux, macOS, BSD usw.) wirderrnozusätzlich aufENOMEMgesetzt, um den Fehler genauer zu kennzeichnen. Auf reinen C-Standard-Implementierungen (z. B. auf eingebetteten Plattformen ohne POSIX) darf man sich darauf nicht verlassen. -
Wenn eine tief verschachtelte Funktion eine Allokation nicht durchführen kann, muss sie den Fehler zur aufrufenden Funktion melden. Alle Funktionen im Aufrufpfad müssen diesen Fehler prüfen und entsprechend reagieren. Wichtig ist, dass im Fehlerfall alle bis dahin allokierten Ressourcen freigegeben werden, um Speicherlecks zu vermeiden und die Sache nicht noch schlimmer zu machen.
Es gibt eigene Strategien bei Speicherknappheit, die man anwenden kann:
- In manchen Fällen lässt sich Speicher sparsamer einsetzen. Anstatt einen großen Block zu allozieren, kann man schrittweise in kleineren Blöcken erweitern. Häufig wird die Kapazität bei wachsendem Bedarf etwa verdoppelt, weil sich damit Reallokationen reduzieren lassen. Das ist jedoch nur eine von vielen Strategien. Wenn bereits viel Speicher belegt ist oder Speicher knapp wird, kann man konservativer vorgehen: Kapazitäten wachsen dann in kleineren Schritten, oder Speicherbereiche werden exakt auf die tatsächlich genutzte Größe realloziert. Reservekapazität hält man in solchen Fällen nur eingeschränkt oder gar nicht vor.
Bei Speicherknappheit kann ein Programm in einen ›Low-Memory-Modus‹ wechseln:
- Caches leeren: Zwischengespeicherte Daten verwerfen, die bei Bedarf neu geladen werden.
- Interne Puffer verkleinern: Arbeitspuffer reduzieren.
- Dateibasierte Speicherung: Daten temporär auf Festplatte auslagern (Swapping auf Anwendungsebene).
- Funktionalität einschränken: Nicht-essenzielle Features deaktivieren.
- In kritischen Systemen kann man Notfall-Speicher reservieren. Beim Start allokiert man einen Block, der zunächst nicht verwendet wird, aber im Notfall freigegeben werden kann, um anderen Allokationen Platz zu schaffen.
Die beste Strategie gegen Out-of-Memory ist proaktive Überwachung (synonym: Monitoring) und Begrenzung des Speicherverbrauchs.
-
Mit den POSIX-Funktionen
getrlimit()undsetrlimit()können Programme ihre Ressourcengrenzen abfragen und begrenzen, darunter auch den maximal verfügbaren Adressraum (RLIMIT_AS) oder die Größe des Datensegments (RLIMIT_DATA). So lässt sich festlegen, wie viel Speicher ein Prozess maximal belegen darf. Wird dieses Limit erreicht, schlagen weitere Allokationen mitENOMEMfehl. Diese Mechanismen eignen sich, um unkontrolliertes Speicherwachstum zu verhindern oder Fehler früh zu erkennen, bevor das System insgesamt instabil wird. In Entwicklungsumgebungen kann man damit auch gezielt testen, wie ein Programm auf Speichermangel reagiert. -
Programme können ihren Speicherverbrauch selbst überwachen, indem sie jede Allokation erfassen und die belegte Gesamtmenge in einer globalen Variablen nachhalten. Dazu muss jede Speicheranforderung und -freigabe über eine Tracking-Funktion laufen, die die jeweilige Größe kennt. Makros können helfen, dieses Vorgehen zentral und konsistent umzusetzen.
-
In kritischen Systemen können Watchdogs eingesetzt werden, die bei Out-of-Memory-Situationen oder Programmblockaden einen Neustart auslösen. Ein Watchdog ist ein Überwachungsmechanismus, der regelmäßig Signale vom Programm erwartet. Bleibt ein Signal aus, geht der Watchdog davon aus, dass das Programm hängt, und führt eine definierte Wiederherstellungsaktion aus, meist einen Neustart des Prozesses oder des gesamten Systems. Dadurch bleibt das System funktionsfähig, auch wenn ein Teil ausfällt.
Fragmentierung und Speichermangel lassen sich nicht vollständig eliminieren, aber durch bewusste Allokationsstrategien, Pooling, Fehlerbehandlung und Ressourcenlimits kontrollieren. Ein robustes C-Programm behandelt Out-of-Memory nicht als Ausnahme, sondern als erwartbare Betriebsbedingung.