Mit der zunehmenden Nachfrage nach paralleler Verarbeitung und hochperformanten Anwendungen rücken in der Programmiersprache Ruby sogenannte Ractors immer mehr in den Fokus. Ractors sind eine relativ neue Funktion, die sogenannten „Ruby Actors“, welche paralleles Programmieren durch isolierte, nebenläufige Einheiten ermöglichen. Trotz ihrer enormen Potenziale gibt es jedoch immer noch Hemmnisse und Herausforderungen, speziell im Umgang mit Klassen-Instanzvariablen, die oft eine Flaschenhalswirkung auf die Performance einnehmen. Ruby verfolgt mit Ractors die Idee, echte Parallelität ohne die Grenzen des Global Interpreter Locks (GIL) zu ermöglichen. Dadurch soll effektives Multithreading möglich werden, vor allem für CPU-intensive Operationen.
Doch das Ziel, eine komplette Anwendung vollständig innerhalb von Ractors auszuführen, ist noch nicht realisiert, da es je nach Implementation und Szenario immer wieder zu Problemen wie Interpreter-Abstürzen und unerwarteten Verzögerungen kommt. Eine der zentralen Ursachen für Performanceeinbußen bei Ractors liegt im Contention-Problem rund um Klassen- und Modul-Instanzvariablen. Diese Variablen gelten in Ruby global, weil Klassen selbst global sind. Das bedeutet, sobald mehrere Ractors parallel auf diese Instanzvariablen zugreifen, entsteht ein globaler Engpass. Dabei greifen alle Ractors auf die gleichen Speicherstellen zu, wodurch es zur Sperrung des gemeinsamen VM-Locks kommt.
Was zur Folge hat, dass parallel ausgeführte Codes im schlechtesten Fall sogar langsamer sind als ihr vergleichbarer sequenzieller Gegenpart. Ein einfaches Benchmarking zeigt, dass selbst ein minimalistisches Programm, welches nur ein paar Modul-Instanzvariablen addiert und dies parallel über mehrere Ractors ausführt, bei bestehendem Locking-Gemeinschaftsspeicher klar im Nachteil ist. Im Gegensatz zur Erwartung, dass mehrere Ractors die Laufzeit beschleunigen sollten, führt das Sperren und Warten auf den VM-Lock zu einer drastischen Verlangsamung – bis zum Achtfachen der ursprünglichen Laufzeit. Die Verhaltensregeln für Klassen-Instanzvariablen innerhalb von Ractors sind klar definiert: Nur der Haupt-Ractor besitzt das Recht, diese Variablen zu setzen. Sekundäre Ractors dürfen lediglich lesend darauf zugreifen – und das auch nur, wenn die Inhalte als „shareable“ deklariert sind, also gefahrlos zwischen Ractors ausgetauscht werden können.
Diese Beschränkung ist essenziell, um die Isolation von Ractors nicht zu verletzen und Datenrassen zu vermeiden. Die offensichtliche Lösung, nämlich die Umwandlung des globalen Locks in mehrere feinere Lockeinheiten, bringt in realen Anwendungen oft wenig, da häufig dieselben Module und ihre Instanzvariablen mehrfach von verschiedenen Ractors genutzt werden. Auch der Einsatz von sogenannten Read-Write-Locks erscheint wenig sinnvoll, da die Leseoperationen im Ruby-VM-Kontext extrem schnell sind und die Lock-Overheads die Vorteile schnell wieder neutralisieren. Ein Blick unter die Haube offenbart die komplexe Natur der Instanzvariablen-Verwaltung: Ruby verwendet sogenannte Shapes, welche die Struktur von Instanzvariablen in einem Objekt in einem unveränderlichen Baum repräsentieren. Die tatsächlichen Daten werden in einem Array namens @fields abgelegt, wobei jeder Eintrag einer Instanzvariablen entspricht.
Dieses System ist hinsichtlich Performance durchdacht, verlangt aber Stabilität bei gleichzeitigen Lese- und Schreibzugriffen, weshalb bisher Locks eingesetzt werden mussten. Beim Schreiben einer Instanzvariablen, etwa beim Hinzufügen einer neuen, kann es notwendig sein, ein größeres Array für @fields anzulegen und die Form (Shape) des Objekts entsprechend zu aktualisieren. Dabei werden kritische Abschnitte des Speichers verändert, die potenziell andere Ractors während eines Lesezugriffs stören könnten. Ohne geeignete Synchronisation besteht Gefahr von Use-After-Free-Fehlern, Out-of-Bounds-Lesefehlern oder uninitialisiertem Speicherzugriff. Das Problem wird zusätzlich durch die Speicherarchitektur der modernen CPUs und deren Cache-Systeme erschwert.
Speicheroperationen können in verschiedenen Reihenfolgen auftreten oder verzögert sichtbar werden, was Multithreading besonders anspruchsvoll macht. Hier spielen sogenannte Speicherbarrieren oder atomare Operationen eine wichtige Rolle, um Reihenfolgen bei Lese- und Schreibzugriffen zu garantieren. Ein innovativer Lösungsansatz besteht darin, die bisher manuell verwalteten und über malloc belegten Arrays für @fields durch reguläre Ruby-Arrays zu ersetzen, welche automatisch durch den Garbage Collector überwacht werden. So werden Use-After-Free Fehler vermieden, da nicht mehr explizit Speicher freigegeben werden muss, sondern die Lebenszeit der Arrays durch Referenzzählung und GC geregelt wird. Eine weitere Herausforderung entsteht durch die Möglichkeit, Instanzvariablen zu entfernen, was in Ruby durch remove_instance_variable unterstützt wird.
Diese Operation verändert intern die Shapes und führt zum Verschieben von Feldern im Array. Diese dynamische Umstrukturierung im Speicher ist kaum ohne Locking oder ähnliche Synchronisationsmechanismen thread-sicher umzusetzen, weil sie die grundlegende statische Struktur der Shapes verletzt. Wenn zu viele Varianten von Shapes entstehen, etwa durch häufiges dynamisches Hinzufügen und Entfernen von Instanzvariablen in unterschiedlicher Reihenfolge, gilt eine Klasse als „zu komplex“. In diesem Zustand nutzt Ruby anstelle eines Arrays eine Hash-basierte Speicherung der Instanzvariablen, was zwar Speicherbedarf und Zugriffsgeschwindigkeit negativ beeinflusst, aber gleichzeitig das dynamische Shape-Wachstum einschränkt. Die parallele Handhabung von regulären und komplexen Shapes ist besonders problematisch, da sie komplett unterschiedliche interne Datenstrukturen verwenden.
Kleine Synchronisationsfehler können daher zu einem Vermischen führen, das beispielsweise Hashes wie Arrays behandelt oder umgekehrt, was zum Absturz der Ruby-VM führen kann. Um dies zu beheben, wäre eine atomare Aktualisierung von Shape und Feldern zusammen erforderlich. Doch da diese beiden Felder zwei voneinander unabhängige 64-Bit-Pointer sind, für die eine 128-Bit-Atomarität nötig wäre, ist dies systemabhängig und auf den von Ruby unterstützten Plattformen nicht zuverlässig realisierbar. Eine elegante Lösung besteht darin, Shape und Felder in einem eigenen, vom Garbage Collector verwalteten Objekt zu bündeln. Dadurch können alle Änderungen an Instanzvariablen als Kopie dieses Objekts durchgeführt werden.
Nach Abschluss der Aktualisierung wird mittels atomarer Operation der Verweis auf das neue Objekt gesetzt. So ist kein Lock mehr erforderlich, und Lesezugriffe können weiterhin ohne Verzögerung und ohne Risiko auf den alten unveränderten Zustand zugreifen. Auch wenn die naive Umsetzung dieser Verdopplung der Objekte zunächst eine erhöhte Anzahl an Speicherallokationen bedeutet, optimieren aktuelle Implementierungen diese durch Fallunterscheidungen, bei denen nur dann kopiert wird, wenn es unbedingt notwendig ist. So bleibt der Speicherbedarf moderat, während die Performance in Multithread-/Ractor-Kontexten enorm steigt. Der Effekt dieser Verbesserungen ist beeindruckend: Benchmarks zeigen, dass die zuvor massiv verlangsamte parallele Ausführung mittels Ractors nach der Einführung des lockfreien Ansatzes auf Klassen-Instanzvariablen durchaus eine mehrfache Beschleunigung gegenüber der seriellen Variante erreichen kann — in manchen Szenarien sogar fast dreifach schneller.
Im Vergleich zur Ruby-Version 3.4 ist sie sogar über 13 Mal schneller. Ein interessantes Nebenelement dieser neuen Architektur ist die bessere Unterstützung von Namespaces. In Ruby sollen Klassen und Module in verschiedenen Namespaces unterschiedliche Instanzvariable und Zustände besitzen können, was nahtlos mit der neuen Trendarchitektur zusammenpasst, da der Attributzustand jetzt in einem externen Objekt gekapselt liegt und dadurch einfach pro Namespace variiert werden kann. Während der Übergang weg von globalem Locking hin zu atomarem Austausch von Zustandsobjekten eine bedeutende Herausforderung im Ruby-VM-Design war, stellt sie einen Wendepunkt für Leistung und Stabilität dar, der den Weg für breitere und effektivere Nutzung von Ractors ebnet.
Mit diesen Erkenntnissen ist absehbar, dass die Nutzung von Ractors im Ruby-Ökosystem zunehmend verbreitet und praktikabel werden könnte – sei es für parallele Algorithmen, nebenläufige I/O-Operationen oder andere anspruchsvolle Aufgaben. Für Entwickler bedeutet das: Bewusstsein für die Besonderheiten von Klassen-Instanzvariablen im Ractor-Kontext ist unerlässlich. Die direkte Modifikation dieser Variablen in sekundären Ractors bleibt untersagt, und der Zugriff sollte immer so gestaltet sein, dass Werte „shareable“ sind. Wer eigene parallele Ruby-Anwendungen entwickelt, tut gut daran, diese neuen Entwicklungen zu verfolgen, da sie die Grundlage für performantere und sichere parallele Programmierung bilden. In der Summe zeigt die Reise hin zu effizienten Klassen-Instanzvariablen in Ruby mit Ractors, wie tiefgreifend System- und Speicherarchitektur das Verhalten höherer Sprachelemente prägen.
Ruby wird damit nicht nur für den Entwickler einfacher handhabbar, sondern auch robuster und schneller. Die Kombination aus innovativen Speicherstrategien, Garbage Collection und atomaren Operationen bildet die Grundlage für die nächsten Generationen von Ruby-Anwendungen im Multi-Core-Zeitalter.