35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Ruby Ractors: Echte Parallelle Verwerking Zonder de GVL

Ruby Ractors: Echte Parallelle Verwerking Zonder de GVL

TTB Software
ruby

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:

  1. require binnen 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.

  2. 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.

  3. Debuggen is lastig. Stack traces van gecrashte Ractors zijn minimaal. Ractor::RemoteError wrapt de oorspronkelijke exceptie, maar je verliest context.

  4. Constante-toegang is beperkt. Ractors kunnen geen mutable constanten uit de hoofd-Ractor benaderen. Gebruik Ractor.make_shareable voor 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
  1. 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:

  1. Identificeer een CPU-gebonden hotspot met ruby-prof of stackprof
  2. Extraheer de berekening in een pure functie die frozen input neemt en frozen output retourneert
  3. Benchmark het met Benchmark.ips — vergelijk sequentieel vs Ractor
  4. Begin met 2-4 Ractors gelijk aan je aantal cores; meer is niet beter
  5. 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.

#ruby #ractors #parallellisme #concurrency #performance
T

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 Touch

Share this article

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