Ruby Ractors: Echte Parallelle Verwerking Zonder de GVL
Ruby’s Global VM Lock (GVL, voorheen GIL) was jarenlang het standaardargument om naar Go of Elixir te grijpen voor echte parallelle verwerking. Ractors, geïntroduceerd in Ruby 3.0, doorbreken die beperking. Ze bieden parallelle executie over CPU-cores binnen één Ruby-proces — zonder externe tools of fork-trucs.
Hier lees je hoe Ractors er in de praktijk uitzien, waar ze helpen, en waar ze je in de problemen brengen.
Wat Ractors Zijn (en Niet Zijn)
Een Ractor is een actor-model concurrency primitief. Elke Ractor krijgt zijn eigen GVL, waardoor meerdere Ractors tegelijkertijd Ruby-code uitvoeren op verschillende CPU-cores. Dit is echte parallellisme, niet het coöperatieve multitasking dat je krijgt met Threads onder de GVL.
De afweging: Ractors kunnen geen mutable objecten delen. Communicatie verloopt via message passing (verzenden en ontvangen), of door expliciet frozen (onveranderlijke) objecten te delen.
# Ruby 3.3+
ractor = Ractor.new do
msg = Ractor.receive
msg.upcase
end
ractor.send("hello")
puts ractor.take # => "HELLO"
Als je ervaring hebt met Erlang-processen of Elixir’s GenServer, voelt dit model vertrouwd.
Wanneer Ractors Echt Helpen
Ractors zijn sterk bij CPU-gebonden werk dat geïsoleerd kan worden. Denk aan:
- Beeldverwerking of transformatiepipelines
- Zware berekeningen (cryptografische hashing, data-aggregatie)
- Parsen van grote bestanden waarbij elk deel onafhankelijk is
- Wiskundige simulaties
Ze helpen niet bij I/O-gebonden werk. Ruby Threads geven de GVL al vrij tijdens I/O-operaties (netwerkverzoeken, bestandslezen, databasequeries), dus Threads zijn prima voor I/O-concurrency. Ractors toevoegen voor I/O geeft alleen complexiteit zonder snelheidswinst.
Een Praktische Benchmark: CPU-Gebonden Werk
Laten we 100.000 strings hashen met SHA256 — puur CPU-werk — en sequentiële, threaded en Ractor-aanpakken vergelijken.
require "digest"
require "benchmark"
DATA = Array.new(100_000) { |i| "string_#{i}" }.freeze
def hash_batch(batch)
batch.map { |s| Digest::SHA256.hexdigest(s) }
end
# Sequentieel
sequential_time = Benchmark.realtime do
hash_batch(DATA)
end
# Threads (4)
threaded_time = Benchmark.realtime do
threads = DATA.each_slice(25_000).map do |batch|
Thread.new { hash_batch(batch) }
end
threads.map(&:value)
end
# Ractors (4)
ractor_time = Benchmark.realtime do
ractors = DATA.each_slice(25_000).map do |batch|
frozen_batch = batch.map(&:freeze).freeze
Ractor.new(frozen_batch) do |b|
require "digest"
b.map { |s| Digest::SHA256.hexdigest(s) }
end
end
ractors.map(&:take)
end
puts "Sequentieel: #{sequential_time.round(3)}s"
puts "Threaded: #{threaded_time.round(3)}s"
puts "Ractors: #{ractor_time.round(3)}s"
Op een 4-core machine met Ruby 3.3.0, typische resultaten:
| Aanpak | Tijd | Versnelling |
|---|---|---|
| Sequentieel | 0.42s | 1x |
| Threaded (4) | 0.41s | ~1x |
| Ractors (4) | 0.13s | ~3.2x |
Threads maken nauwelijks verschil omdat de GVL de SHA256-berekening serialiseert. Ractors omzeilen dit volledig en leveren bijna lineaire schaalbaarheid met het aantal cores.
De Regels voor Deelbare Objecten
Hier lopen de meeste mensen vast. Ractors handhaven strikte isolatie, en Ruby 3.3 is strenger dan je zou verwachten.
Objecten die je tussen Ractors kunt sturen:
- Frozen strings, arrays, hashes (en geneste frozen structuren)
- Numerieke typen (Integer, Float, Rational, Complex)
- Symbols,
true,false,nil - Ractor-objecten zelf
Objecten die je niet kunt delen:
- Unfrozen strings of collecties
- Procs en lambda’s (ze vangen binding-context)
- De meeste gem-objecten (ActiveRecord-modellen, HTTP-clients, etc.)
# Dit werkt — frozen data
Ractor.new(["a".freeze, "b".freeze].freeze) do |arr|
arr.map(&:upcase)
end
# Dit geeft een Ractor::IsolationError
Ractor.new(["a", "b"]) do |arr|
arr.map(&:upcase)
end
Je kunt objecten ook verplaatsen (eigendom overdragen) in plaats van kopiëren:
str = "hello"
ractor = Ractor.new do
Ractor.receive
end
ractor.send(str, move: true)
# str is nu ontoegankelijk in de verzendende Ractor
Een Worker Pool Bouwen
Voor verwerkingswachtrijen of batchjobs houdt een Ractor-poolpatroon alles beheersbaar:
WORKER_COUNT = 4
def ractor_pool(items, worker_count: WORKER_COUNT)
pipe = Ractor.new do
loop do
Ractor.yield(Ractor.receive)
end
end
workers = Array.new(worker_count) do
Ractor.new(pipe) do |source|
loop do
item = source.take
break if item == :done
result = yield_result(item)
Ractor.yield(result)
end
end
end
items.each { |item| pipe.send(item.freeze) }
worker_count.times { pipe.send(:done) }
workers.flat_map do |w|
results = []
loop do
results << w.take
rescue Ractor::ClosedError
break
end
results
end
end
Bekende Beperkingen in Ruby 3.3
Ractors zijn nog steeds als experimenteel gemarkeerd. Enkele echte beperkingen:
-
requirebinnen Ractors is fragiel. Veel gems falen bij require binnen een Ractor vanwege constanten of globale state. Doe alle requires vóór het starten van Ractors. -
Geen gedeelde databaseverbindingen. ActiveRecord-verbindingen kunnen niet over Ractor-grenzen. Elke Ractor heeft een eigen verbinding nodig. Voor Rails database-werk blijf je beter bij Threads.
-
Debuggen is lastig. Stack traces van gecrashte Ractors zijn minimaal.
Ractor::RemoteErrorwrapt de oorspronkelijke exceptie, maar je verliest context. -
Constante-toegang is beperkt. Ractors kunnen geen mutable constanten uit de hoofd-Ractor benaderen. Gebruik
Ractor.make_shareablevoor frozen constante data.
CONFIG = Ractor.make_shareable({
timeout: 30,
retries: 3,
batch_size: 1000
})
# Nu toegankelijk vanuit elke Ractor
Ractor.new do
puts CONFIG[:timeout] # => 30
end
- Overhead per Ractor. Een Ractor aanmaken is zwaarder dan een Thread — ruwweg 10-50x de opstartkosten in Ruby 3.3. Maak geen duizenden kortlevende Ractors. Gebruik een pool.
Ractors vs Threads vs Processen
| Threads | Ractors | Processen (fork) | |
|---|---|---|---|
| Echt parallellisme | Nee (GVL) | Ja | Ja |
| Geheugen-isolatie | Gedeeld | Geïsoleerd | Geïsoleerd (CoW) |
| Communicatie | Gedeelde state | Message passing | IPC/pipes |
| Opstartkosten | Laag (~10μs) | Middel (~500μs) | Hoog (~10ms) |
| Geschikt voor | I/O-gebonden | CPU-gebonden | Volledige isolatie |
| Gem-compatibiliteit | Volledig | Beperkt | Volledig |
Voor productie Ruby-debugging blijven Threads eenvoudiger. Ractors zijn de juiste keuze wanneer je hebt geprofileerd en bevestigd dat CPU het knelpunt is.
Vandaag Beginnen
Als je wilt experimenteren met Ractors in een bestaand project:
- Identificeer een CPU-gebonden hotspot met
ruby-profofstackprof - Extraheer de berekening in een pure functie die frozen input neemt en frozen output retourneert
- Benchmark het met
Benchmark.ips— vergelijk sequentieel vs Ractor - Begin met 2-4 Ractors gelijk aan je aantal cores; meer is niet beter
- Houd Ractors langlevend door een poolpatroon te gebruiken in plaats van per-taak aanmaken
Het Ruby core team (met name Koichi Sasada, de maker van Ractors) werkt actief aan verbeteringen. Ruby 3.4 belooft betere constante-afhandeling en minder overhead. Voor nu werken Ractors goed voor geïsoleerde, CPU-zware taken waarbij je de dataflow beheerst.
FAQ
Zijn Ruby Ractors productie-klaar?
In Ruby 3.3 zijn Ractors nog als experimenteel gemarkeerd. Ze werken betrouwbaar voor geïsoleerde CPU-gebonden taken met eenvoudige datatypen. Complexe applicaties met veel gem-afhankelijkheden zullen compatibiliteitsproblemen tegenkomen. Verschillende bedrijven gebruiken ze in productie voor specifieke workloads (batchverwerking, datatransformatie) terwijl de rest van hun stack op Threads draait.
Kan ik Ractors gebruiken met Ruby on Rails?
Niet direct voor request-afhandeling — ActiveRecord, ActionController en de meeste Rails-internals zijn afhankelijk van gedeelde mutable state die Ractor-isolatieregels schendt. Je kunt Ractors gebruiken in achtergrondtaken of standalone scripts die data onafhankelijk van het Rails-framework verwerken, maar je moet databaseverbindingen zorgvuldig beheren.
Hoeveel Ractors moet ik aanmaken?
Stem af op je CPU-cores. Op een 4-core machine geven 4 Ractors bijna optimale doorvoer voor CPU-gebonden werk. Meer toevoegen geeft scheduling-overhead zonder voordeel, omdat het OS niet meer echt parallelle threads kan draaien dan fysieke cores. Gebruik Etc.nprocessors in Ruby om beschikbare cores programmatisch te detecteren.
Wat is het verschil tussen Ractors en Fibers?
Fibers bieden coöperatieve concurrency binnen een enkele thread — ze geven controle expliciet af en draaien nooit parallel. Ractors bieden echt parallellisme over CPU-cores. Fibers zijn ideaal voor het beheren van veel gelijktijdige I/O-operaties (zoals async database queries), terwijl Ractors CPU-intensieve berekeningen afhandelen.
Gaan Ractors Threads vervangen in Ruby?
Nee. Ze dienen verschillende doelen. Threads zijn goed voor I/O-concurrency omdat de GVL vrijgegeven wordt tijdens I/O. Ractors zijn voor CPU-parallellisme. De meeste Ruby-applicaties zijn I/O-gebonden (webverzoeken, databasequeries), dus Threads blijven het primaire concurrency-gereedschap. Ractors vullen de leemte voor werk dat echt CPU-gelimiteerd is.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation