Ruby ist eine dynamische, interpretierte und quelloffene Programmiersprache, die für ihre einfache und gut lesbare Syntax bekannt ist. Besonders im Bereich der Webentwicklung hat Ruby durch das Ruby on Rails Framework große Bedeutung erlangt. Neben seiner Flexibilität in der Programmierparadigma unterstützt Ruby objektorientierte, funktionale sowie imperative Ansätze. Doch wenn es um Threads, Nebenläufigkeit und Parallelität geht, stellen sich viele Entwickler die Frage: Wie parallel ist Ruby wirklich? Und welche Mechanismen bietet Ruby für gleichzeitige Ausführung? Um das zu verstehen, muss man die Facetten der Ruby-Implementierung kennen, vor allem des sogenannten Matz Ruby Interpreter (MRI), der meistgenutzten Ruby-Virtual-Machine, die von Yukihiro Matsumoto, dem Schöpfer von Ruby, entwickelt wurde. Ein zentrales Konzept, das MRI mit sich bringt, ist der Global Interpreter Lock, kurz GIL.
Dieser sorgt dafür, dass zu jedem Zeitpunkt nur ein Thread innerhalb des Ruby-Interpreters tatsächlich ausgeführt wird. Obwohl Ruby somit Mehrfach-Threads unterstützt, beschränkt der GIL die echte Parallelität. Das bedeutet, dass in der Praxis zwar mehrere Threads parallel laufen können, aber ihre Ausführung vom Interpreter seriell abgewickelt wird. In einfachen Worten hat Ruby durch den GIL nur eine begrenzte Parallelitätsfähigkeit auf Thread-Ebene, was insbesondere bei CPU-lastigen Anwendungen spürbar wird. Interessanterweise gibt es neben Threads noch weitere Ebenen der gleichzeitigen Ausführung in Ruby, die häufig übersehen werden.
Jede Ruby-Anwendung läuft in einer verschachtelten Umgebung: Zu jeder Zeit gibt es einen Prozess, darin laufen Raktoren (Ractor), darin wiederum Threads, und innerhalb der Threads laufen Fibers. Jeder dieser Bestandteile hat seine eigene Rolle im Bereich Parallelität und Nebenläufigkeit. Diese verborgene Struktur erklärt, warum man von Ruby oft hört, dass es „nebenläufig“ aber nur begrenzt „parallel“ ist. Der grundlegende Terminus, der außerhalb von Ruby auch häufig benutzt wird, ist der Prozess. Ein Prozess besitzt eigenen, isolierten Speicher und wird vom Betriebssystem verwaltet.
Dabei können mehrere Prozesse komplett unabhängig voneinander parallel auf verschiedenen CPU-Kernen laufen. Für Ruby bedeutet das: Wenn man mehrere Ruby-Programme in unterschiedlichen Prozessen startet, laufen sie parallel und getrennt voneinander. Innerhalb eines Prozesses ist allerdings die Speichertrennung strikt, was bedeutet, dass Datenaustausch zwischen Prozessen komplizierter ist und spezielle Kommunikationsmechanismen wie Pipes oder Sockets erfordert. Ruby bietet jedoch eine neuere, experimentelle Lösung, um echte Parallelität innerhalb eines Prozesses zu ermöglichen: Raktoren – benannt nach dem Actor-Modell, welches für verteilte und nebenläufige Systeme konzipiert wurde. Raktoren können als virtuelle Maschinen innerhalb eines Prozesses betrachtet werden, die jeweils eigene, isolierte Speicherbereiche haben.
Im Gegensatz zu Threads, die sich Speicher teilen, kommunizieren Raktoren ausschließlich über Nachrichten, was typische Probleme wie Race Conditions vermeidet. Durch ihre Isolation besitzen Raktoren jeweils einen eigenen GIL, wodurch sie theoretisch unabhängig voneinander und parallel laufen können. Der Nachteil ist, dass Raktoren aktuell noch experimentell sind und nicht in allen verwendeten Ruby-Gems etabliert sind. Ein praktisches Beispiel verdeutlicht die Leistungsfähigkeit der Parallelität mit Raktoren. Wird eine rechenintensive Aufgabe, zum Beispiel das Summieren von Zahlen in einem großen Bereich, in mehrere Teilaufgaben unterteilt und diese innerhalb von getrennten Raktoren ausgeführt, kann die Gesamtausführungszeit signifikant verbessert werden.
Während eine serielle Ausführung aller Teilbereiche entsprechend länger dauert, zeigt die parallele Bearbeitung mit Raktoren, dass die Arbeitslast auf die CPU-Kerne verteilt wird und somit echtes Parallelverhalten entsteht. Trotzdem sollte man sich der Limitationen bewusst sein, denn Raktoren sind noch nicht vollständig ausgereift und können unerwartete Schwierigkeiten in komplexen Anwendungen bereiten. Anders als Raktoren verwaltet MRI Ruby Threads intern selbst und versteckt dabei die tatsächliche Thread-Verwaltung des Betriebssystems. Das führt dazu, dass Ruby-Threads leichter und weniger ressourcenintensiv sind als native Betriebssystem-Threads, aber auch nicht wirklich parallel ausgeführt werden, sondern oft als sogenannte „Green Threads“ bezeichnet werden. Diese Eigenschaft macht Ruby-Threads besonders gut für Aufgaben geeignet, die I/O-gebunden sind, wie Datenbankabfragen oder Netzwerkanfragen, da der Interpreter während der Wartezeiten threadübergreifend wechseln kann und so die Anwendung reaktionsfähig bleibt.
In der Praxis zeigt ein simpler Vergleich die Vorteile von Threads bei I/O-lastigen Aufgaben: Werden zwei zeitaufwändige Aktionen nacheinander ausgeführt, dauert die Gesamtlaufzeit ungefähr doppelt so lange wie eine einzelne Aktion. Mit Threads hingegen starten beide Aktionen gleichzeitig und die gesamte Wartezeit entspricht in etwa einer einzelnen Aktion, da beide in gewisser Weise gleichzeitig verarbeitet werden. Für CPU-intensive Aufgaben bieten Ruby-Threads aufgrund des GIL jedoch wenig Vorteile. Eine große Herausforderung bei der Arbeit mit Threads ist das Teilen von Speicher und Ressourcen. Da Threads innerhalb desselben Prozesses existieren und denselben Speicherbereich verwenden, besteht die Gefahr von Race Conditions.
Diese treten auf, wenn mehrere Threads gleichzeitig auf gemeinsame Daten schreiben oder lesen und nicht angemessen synchronisiert sind. Ein anschauliches Beispiel ist ein gemeinsamer Zähler, der von mehreren Threads erhöht wird: Durch kleine Zeitverzögerungen und Kontextwechsel können Threads dieselbe Zähloperation mehrfach ausführen oder Werte überschreiben, sodass das Ergebnis am Ende inkorrekt ist. Daher sind sorgfältige Synchronisationsmechanismen wie Mutexes notwendig, um Race Conditions zu vermeiden, was den Programmieraufwand erhöht und Fehleranfälligkeit mit sich bringt. Neben Threads bietet Ruby eine noch feinere Ebene der Nebenläufigkeit: Fibers. Fibers sind leichtgewichtige, kooperative Einheiten, die innerhalb desselben Threads laufen.
Im Gegensatz zu Threads wechseln Fibers nicht automatisch durch den Interpreter, sondern müssen explizit die Kontrolle durch Aufrufe von Fiber.yield und Fiber.resume abgeben. Dies ermöglicht ein bewusstes Multitasking, das jedoch keine echte Parallelität bietet, da Fibers immer noch sequentiell auf einem Kernel-Thread ausgeführt werden. Fibers eignen sich besonders gut zur Implementierung von Generatoren oder für asynchrone Programmiermodelle, bei denen die Kontrolle sorgfältig zwischen verschiedenen Aufgaben übergeben wird.
Der wesentliche Vorteil liegt in der geringen Speicherbelegung und der Kontrolle über den Ausführungsfluss. Die Entwicklung von Fibers in Ruby gewann an Bedeutung durch Gem-Projekte wie Async, die asynchrone Programmierung mit Fibers abstrahieren und deutlich elegantere Nutzungsschnittstellen ermöglichen. Für reguläre Anwendungen sind Fibers jedoch eher ein Spezialwerkzeug und werden häufig nicht direkt vom Entwickler eingesetzt. Zusammenfassend zeigt sich, dass Ruby verschiedene Modelle für Nebenläufigkeit und Parallelität bereitstellt, die jeweils ihre Stärken und Schwächen haben. Prozesse bieten maximale Isolation und echte Parallelität über den Betriebssystemkern hinweg, sind aber speicher- und ressourcenintensiv.
Threads ermöglichen nebenläufige Ausführung innerhalb eines Prozesses, jedoch sind sie durch den Global Interpreter Lock limitiert und teilen sich Speicher, was Synchronisation erforderlich macht. Raktoren sind ein vielversprechender neuer Ansatz, um echte Parallelität mit isoliertem Speicher in demselben Prozess zu ermöglichen, sie befinden sich aber noch in der experimentellen Phase. Fibers runden das Bild als leichtgewichtige, kooperative Mechanismen für Nebenläufigkeit ab und eignen sich gut für reaktive und asynchrone Programme. Die Kenntnis dieser Konzepte ist für Ruby-Entwickler essenziell, um fundierte Entscheidungen im Umgang mit Nebenläufigkeit treffen zu können. Je nach Anwendungsfall – ob rechenintensive Tasks, I/O-gebundene Prozesse oder asynchrone Steuerungsflüsse – entscheidet sich, welche Technik sinnvoll ist.