Wir bei
Phusion haben einen einfachen Multithread-HTTP-Proxy in Ruby (vertreibt DEB- und RPM-Pakete). Ich sah darauf einen Speicherverbrauch von 1,3 GB. Aber das ist verrückt nach einem staatenlosen Prozess ...
Frage: Was ist das? Antwort: Ruby nutzt den Speicher im Laufe der Zeit!Es stellt sich heraus, dass ich mit diesem Problem nicht allein bin. Ruby-Anwendungen können viel Speicher belegen. Aber warum? Laut
Heroku und
Nate Burkopek ist das
Aufblähen hauptsächlich auf Speicherfragmentierung und übermäßige
Heap- Verteilung zurückzuführen.
Berkopek kam zu dem Schluss, dass es zwei Lösungen gibt:
- Verwenden Sie entweder einen völlig anderen Speicherzuweiser als glibc - normalerweise jemalloc , oder:
- Setzen Sie die magische Umgebungsvariable
MALLOC_ARENA_MAX=2
.
Ich mache mir Sorgen um die Beschreibung des Problems und die vorgeschlagenen Lösungen. Hier stimmt etwas nicht ... Ich bin nicht sicher, ob das Problem vollständig beschrieben ist oder ob dies die einzigen verfügbaren Lösungen sind. Es ärgert mich auch, dass viele Jemalloc als magischen Silberpool bezeichnen.
Magie ist nur eine Wissenschaft, die wir noch nicht verstehen . Also machte ich eine Forschungsreise, um die ganze Wahrheit herauszufinden. Dieser Artikel behandelt die folgenden Themen:
- So funktioniert die Speicherzuordnung.
- Was ist diese "Fragmentierung" und "übermäßige Verteilung" des Gedächtnisses, von der alle sprechen?
- Was verursacht einen hohen Speicherverbrauch? Stimmt die Situation mit dem überein, was die Leute sagen, oder gibt es noch etwas anderes? (Spoiler: Ja, da ist noch etwas).
- Gibt es alternative Lösungen? (Spoiler: Ich habe einen gefunden).
Hinweis: Dieser Artikel ist nur für Linux und nur für Ruby-Anwendungen mit mehreren Threads relevant.Inhalt
Ruby Memory Allocation: Eine Einführung
Ruby ordnet Speicher auf drei Ebenen von oben nach unten zu:
- Ruby-Interpreter, der Ruby-Objekte verwaltet.
- Die Speicherzuordnungsbibliothek des Betriebssystems.
- Der Kern.
Lass uns durch jedes Level gehen.
Ruby
Auf seiner Seite organisiert Ruby Objekte in Speicherbereichen, die als
Ruby-Heap-Seiten bezeichnet werden . Eine solche Heap-Seite ist in Slots gleicher Größe unterteilt, wobei ein Objekt einen Slot belegt. Unabhängig davon, ob es sich um eine Zeichenfolge, eine Hash-Tabelle, ein Array, eine Klasse oder etwas anderes handelt, belegt sie einen Steckplatz.
Die Slots auf der Heap-Seite sind möglicherweise belegt oder frei. Wenn Ruby ein neues Objekt auswählt, versucht es sofort, einen freien Platz zu belegen. Wenn keine freien Slots vorhanden sind, wird eine neue Heap-Seite hervorgehoben.
Der Steckplatz ist klein, ungefähr 40 Bytes. Offensichtlich passen einige Objekte nicht hinein, z. B. 1 MB Zeilen. Dann speichert Ruby die Informationen an einer anderen Stelle außerhalb der Heap-Seite und platziert einen Zeiger auf diesen externen Speicherbereich im Steckplatz.
Daten, die nicht in den Steckplatz passen, werden außerhalb der Heap-Seite gespeichert. Ruby platziert einen Zeiger auf diese externen Daten im SteckplatzSowohl Ruby-Heap-Seiten als auch externe Speicherbereiche werden mithilfe des Systemspeicherzuordners zugewiesen.
Systemspeicherzuordnung
Der Speicherzuweiser des Betriebssystems ist Teil von glibc (C-Laufzeit). Es wird von fast allen Anwendungen verwendet, nicht nur von Ruby. Es hat eine einfache API:
- Der Speicher wird durch Aufrufen von
malloc(size)
zugewiesen. Sie geben ihm die Anzahl der Bytes, die Sie zuweisen möchten, und es gibt entweder die Zuordnungsadresse oder einen Fehler zurück. - Der zugewiesene Speicher wird durch Aufrufen von
free(address)
freigegeben.
Im Gegensatz zu Ruby, wo Slots derselben Größe zugewiesen werden, verarbeitet der Speicherzuweiser Anforderungen zum Zuweisen von Speicher beliebiger Größe. Wie Sie später erfahren werden, führt diese Tatsache zu einigen Komplikationen.
Der Speicherzuweiser greift wiederum auf die Kernel-API zu. Der Kernel benötigt viel größere Speicherblöcke als von seinen eigenen Abonnenten angefordert, da der Kernelaufruf teuer ist und die Kernel-API eine Einschränkung aufweist: Sie kann nur Speicher in Vielfachen von 4 KB zuweisen.
Der Speicherzuweiser weist große Blöcke zu - sie werden als Systemhaufen bezeichnet - und teilt ihren Inhalt, um Anforderungen von Anwendungen zu erfüllenDer Speicherbereich, den der Speicherzuweiser vom Kernel zuweist, wird als Heap bezeichnet. Beachten Sie, dass dies nichts mit den Seiten des Ruby-Heaps zu tun hat. Aus Gründen der Übersichtlichkeit wird der Begriff
System-Heap verwendet .
Der Speicherzuweiser weist dann seinen Anrufern Teile der Systemhaufen zu, bis freier Speicherplatz vorhanden ist. In diesem Fall weist der Speicherzuweiser einen neuen Systemheap vom Kernel zu. Dies ähnelt der Auswahl von Objekten aus den Seiten eines Ruby-Heaps durch Ruby.
Ruby reserviert Speicher vom Speicherzuweiser, der wiederum Speicher vom Kernel zuweistDer Kern
Der Kernel kann nur Speicher in 4-KB-Einheiten zuweisen. Ein solcher 4K-Block wird als Seite bezeichnet. Um Verwechslungen mit den Ruby-Heap-Seiten zu vermeiden, verwenden wir aus Gründen der Übersichtlichkeit den Begriff
Systemseite (Betriebssystemseite).
Der Grund ist schwer zu erklären, aber alle modernen Kernel funktionieren auf diese Weise.
Das Zuweisen von Speicher über den Kernel hat erhebliche Auswirkungen auf die Leistung, weshalb Speicherzuweiser versuchen, die Anzahl der Kernelaufrufe zu minimieren.
Definition der Speichernutzung
Somit wird Speicher auf mehreren Ebenen zugewiesen, und jede Ebene weist mehr Speicher zu, als er wirklich benötigt. Ruby-Heap-Seiten können sowohl freie Slots als auch System-Heaps enthalten. Daher die Antwort auf die Frage "Wie viel Speicher wird verwendet?" hängt ganz davon ab, welches Level du fragst!
Tools wie
top
oder
ps
zeigen die Speichernutzung aus
Kernel- Sicht. Dies bedeutet, dass höhere Ebenen zusammenarbeiten müssen, um Speicher aus Kernel-Sicht freizugeben. Wie Sie später erfahren werden, ist dies schwieriger als es sich anhört.
Was ist Fragmentierung?
Speicherfragmentierung bedeutet, dass Speicherzuordnungen zufällig verteilt sind. Dies kann interessante Probleme verursachen.
Ruby Level Fragmentation
Betrachten Sie Ruby Garbage Collection. Die Speicherbereinigung für ein Objekt bedeutet, dass der Ruby-Heap-Seitenschlitz als frei markiert wird, damit er wiederverwendet werden kann. Wenn die gesamte Seite des Ruby-Heaps nur aus freien Slots besteht, kann die gesamte Seite an den Speicherzuweiser (und möglicherweise an den Kernel) zurückgegeben werden.
Aber was passiert, wenn nicht alle Slots frei sind? Was ist, wenn wir viele Seiten des Ruby-Heaps haben und der Garbage Collector Objekte an verschiedenen Orten freigibt, sodass am Ende viele freie Slots vorhanden sind, jedoch auf verschiedenen Seiten? In dieser Situation verfügt Ruby über freie Steckplätze zum Platzieren von Objekten, aber der Speicherzuweiser und der Kernel weisen weiterhin Speicher zu!
Fragmentierung der Speicherzuordnung
Der Speicherzuweiser hat ein ähnliches, aber völlig anderes Problem. Er muss nicht sofort ganze Systemhaufen löschen. Theoretisch kann jede einzelne Systemseite freigegeben werden. Da sich der Speicherzuweiser jedoch mit Speicherzuordnungen beliebiger Größe befasst, kann es auf der Systemseite mehrere Zuordnungen geben. Die Systemseite kann erst freigegeben werden, wenn alle Auswahlen freigegeben sind.
Überlegen Sie, was passiert, wenn wir eine 3-KB-Zuordnung sowie eine 2-KB-Zuordnung haben, die in zwei Systemseiten unterteilt sind. Wenn Sie die ersten 3 KB freigeben, bleiben beide Systemseiten teilweise belegt und können nicht freigegeben werden.
Wenn die Umstände fehlschlagen, ist auf den Systemseiten viel freier Speicherplatz vorhanden, der jedoch nicht vollständig freigegeben wird.
Schlimmer noch: Was ist, wenn es viele freie Plätze gibt, aber keiner groß genug ist, um eine neue Zuteilungsanfrage zu erfüllen? Der Speicherzuweiser muss einen ganz neuen Systemheap zuweisen.
Verursacht die Fragmentierung der Ruby-Heap-Seite ein Aufblähen des Speichers?
Es ist wahrscheinlich, dass die Fragmentierung in Ruby zu einer Überbeanspruchung des Speichers führt. Wenn ja, welche der beiden Fragmentierungen ist schädlicher? Das…
- Ruby Heap Page Fragmentierung? Oder
- Speicherzuweisungsfragmentierung?
Die erste Option ist recht einfach zu überprüfen. Ruby bietet zwei APIs:
ObjectSpace.memsize_of_all
und
GC.stat
. Dank dieser Informationen können Sie den gesamten Speicher berechnen, den Ruby vom Allokator erhalten hat.
ObjectSpace.memsize_of_all
gibt den von allen aktiven Ruby-Objekten belegten Speicher zurück. Das heißt, der gesamte Speicherplatz in ihren Steckplätzen und alle externen Daten. Im obigen Diagramm ist dies die Größe aller blauen und orangefarbenen Objekte.
GC.stat
können
GC.stat
die Größe aller freien Slots ermitteln, d. H. Den gesamten grauen Bereich in der obigen Abbildung. Hier ist der Algorithmus:
GC.stat[:heap_free_slots] * GC::INTERNAL_CONSTANTS[:RVALUE_SIZE]
Zusammenfassend ist dies der gesamte Speicher, den Ruby kennt, und es geht darum, die Seiten des Ruby-Heaps zu fragmentieren. Wenn aus Kernel-Sicht die Speichernutzung höher ist, wird der verbleibende Speicher außerhalb der Kontrolle von Ruby gespeichert, z. B. für Bibliotheken oder Fragmentierung von Drittanbietern.
Ich habe ein einfaches Testprogramm geschrieben, das eine Reihe von Threads erstellt, von denen jeder Zeilen in einer Schleife auswählt. Hier ist das Ergebnis nach einer Weile:
es ist ... nur ... verrückt!
Das Ergebnis zeigt, dass Ruby einen so schwachen Effekt auf die Gesamtmenge des verwendeten Speichers hat, dass es keine Rolle spielt, ob die Seiten des Ruby-Heaps fragmentiert sind oder nicht.
Müssen woanders nach dem Täter suchen. Zumindest wissen wir jetzt, dass Ruby nicht schuld ist.
Studie zur Fragmentierung der Speicherzuordnung
Ein weiterer wahrscheinlicher Verdächtiger ist ein Speicherzuweiser. Am Ende stellten Nate Berkopek und Heroku fest, dass die Arbeit mit dem Speicherzuweiser (entweder das vollständige Ersetzen von jemalloc oder das Setzen der magischen Umgebungsvariablen
MALLOC_ARENA_MAX=2
) die Speichernutzung drastisch reduziert.
Mal sehen, was
MALLOC_ARENA_MAX=2
macht und warum es hilft. Dann untersuchen wir die Fragmentierung auf Verteilerebene.
Übermäßige Speicherzuordnung und Glibc
Der Grund, warum
MALLOC_ARENA_MAX=2
hilft, ist Multithreading. Wenn mehrere Threads gleichzeitig versuchen, Speicher von demselben Systemheap zuzuweisen, kämpfen sie um den Zugriff. Es kann jeweils nur ein Thread Speicher empfangen, was die Leistung der Multithread-Speicherzuweisung verringert.
Es kann jeweils nur ein Thread mit dem Systemheap arbeiten. Bei Multithread-Aufgaben tritt ein Konflikt auf und folglich nimmt die Leistung abIm Speicherzuweiser für einen solchen Fall gibt es eine Optimierung. Er versucht, mehrere System-Heaps zu erstellen und sie verschiedenen Threads zuzuweisen. Meistens arbeitet ein Thread nur mit seinem eigenen Heap, um Konflikte mit anderen Threads zu vermeiden.
Tatsächlich entspricht die maximale Anzahl der auf diese Weise zugewiesenen System-Heaps standardmäßig der Anzahl der virtuellen Prozessoren multipliziert mit 8. Das heißt, in einem Dual-Core-System mit zwei Hyper-Threads werden jeweils
2 * 2 * 8 = 32
System-Heaps erzeugt! Das nenne ich
übermäßige Verteilung .
Warum ist der Standardmultiplikator so groß? Weil der führende Entwickler des Speicherzuweisers Red Hat ist. Ihre Kunden sind große Unternehmen mit leistungsstarken Servern und einer Menge RAM. Mit der obigen Optimierung können Sie die durchschnittliche Multithreading-Leistung aufgrund einer signifikanten Erhöhung der Speichernutzung um 10% steigern. Für Red Hat-Kunden ist dies ein guter Kompromiss. Für die meisten anderen - kaum.
Nate argumentiert in ihrem Blog und in ihrem Heroku-Artikel, dass eine Erhöhung der Anzahl der Systemhaufen die Fragmentierung erhöht, und zitiert offizielle Dokumentation. Die Variable
MALLOC_ARENA_MAX
reduziert die maximale Anzahl von System-Heaps, die für Multithreading zugewiesen sind. Durch diese Logik wird die Fragmentierung reduziert.
Visualisierung von Systemhaufen
Stimmt die Aussage von Nate und Heroku, dass eine Erhöhung der Anzahl der Systemhaufen die Fragmentierung erhöht? Gibt es tatsächlich ein Problem mit der Fragmentierung auf der Ebene des Speicherzuweisers? Ich wollte keine dieser Annahmen für selbstverständlich halten, also begann ich mit der Studie.
Leider gibt es keine Tools zum Visualisieren von Systemhaufen, daher habe
ich selbst einen solchen Visualizer geschrieben .
Erstens müssen Sie das Verteilungsschema von System-Heaps irgendwie beibehalten. Ich habe die
Quelle des Speicherzuweisers untersucht und mir angesehen, wie er den Speicher intern darstellt. Dann schrieb er eine Bibliothek, die diese Datenstrukturen durchläuft und das Schema in eine Datei schreibt. Schließlich schrieb er ein Tool, das eine solche Datei als Eingabe verwendet und die Visualisierung als HTML- und PNG-Bilder (
Quellcode )
kompiliert .
Hier ist ein Beispiel für die Visualisierung eines bestimmten Systemheaps (es gibt viele weitere). Kleine Blöcke in dieser Visualisierung repräsentieren Systemseiten.
- Rote Bereiche werden als Speicherzellen verwendet.
- Grautöne sind freie Bereiche, die nicht in den Kern zurückgeführt werden.
- Weiße Bereiche werden für den Kern freigegeben.
Die folgenden Schlussfolgerungen können aus der Visualisierung gezogen werden:
- Es gibt eine gewisse Fragmentierung. Rote Flecken werden aus dem Speicher gestreut, und einige Systemseiten sind nur halbrot.
- Zu meiner Überraschung enthalten die meisten Systemhaufen eine erhebliche Anzahl vollständig freier Systemseiten (grau)!
Und dann dämmerte es mir:
Obwohl Fragmentierung ein Problem bleibt, ist es nicht der Punkt!Vielmehr ist das Problem sehr grau: Dieser Speicherzuweiser
sendet keinen Speicher zurück an den Kernel !
Nach einer erneuten Untersuchung des Quellcodes des Speicherzuweisers stellte sich heraus, dass standardmäßig nur Systemseiten am Ende des Systemheaps an den Kernel gesendet werden, und dies sogar
selten . Wahrscheinlich ist ein solcher Algorithmus aus Leistungsgründen implementiert.
Zaubertrick: Beschneidung
Zum Glück habe ich einen Trick gefunden. Es gibt eine Programmierschnittstelle, die den Speicherzuweiser zwingt, nicht nur die letzte, sondern
alle relevanten Systemseiten für den Kernel freizugeben. Es heißt
malloc_trim .
Ich wusste über diese Funktion Bescheid, hielt sie jedoch nicht für nützlich, da im Handbuch Folgendes steht:
Die Funktion malloc_trim () versucht, freien Speicher am oberen Rand des Heaps freizugeben.
Das Handbuch ist falsch! Die Analyse des Quellcodes besagt, dass das Programm alle relevanten Systemseiten freigibt, nicht nur die obersten.
Was passiert, wenn diese Funktion während der Speicherbereinigung aufgerufen wird? Ich habe den Ruby 2.6-Quellcode so geändert, dass
malloc_trim()
in der Funktion gc_start von gc.c aufgerufen wird, zum Beispiel:
gc_prof_timer_start(objspace); { gc_marks(objspace, do_full_mark);
Und hier sind die Testergebnisse:
Was für ein großer Unterschied! Ein einfacher Patch reduzierte den Speicherverbrauch auf fast
MALLOC_ARENA_MAX=2
.
So sieht es in der Visualisierung aus:
Wir sehen viele weiße Bereiche, die den im Kernel freigegebenen Systemseiten entsprechen.
Fazit
Es stellte sich heraus, dass Fragmentierung im Grunde nichts damit zu tun hatte. Die Defragmentierung ist immer noch nützlich, aber das Hauptproblem besteht darin, dass der Speicherzuweiser den Speicher nicht gerne an den Kernel zurückgeben möchte.
Glücklicherweise erwies sich die Lösung als sehr einfach. Die Hauptsache war, die Grundursache zu finden.
Visualizer-Quellcode
QuellcodeWas ist mit Leistung?
Die Leistung blieb eines der Hauptanliegen. Das Aufrufen von
malloc_trim()
kann nicht kostenlos
malloc_trim()
, aber gemäß dem Code arbeitet der Algorithmus in linearer Zeit. Also wandte ich mich an
Noah Gibbs , der den Rails Ruby Bench Benchmark startete. Zu meiner Überraschung führte der Patch zu einer leichten Leistungssteigerung.
Es hat mich umgehauen. Der Effekt ist unverständlich, aber die Nachrichten sind gut.
Benötigen Sie weitere Tests.
Im Rahmen dieser Studie wurde nur eine begrenzte Anzahl von Fällen verifiziert. Es ist nicht bekannt, wie sich dies auf andere Workloads auswirkt. Wenn Sie beim Testen helfen möchten,
kontaktieren Sie mich bitte.