JT Book a call

Blog

Ruby Concurrency Patterns: Threads, Fibers and Ractors

Author

Paul KeenChief Technology Officer & Open Source Contributor

Published

7 min read

In this post

Ruby gives you three ways to do more than one thing at a time: threads, fibers and Ractors. They solve different problems, and picking the wrong one is the usual mistake. This guide shows a small, working example of each and says when to use it.

The code targets Ruby 3.4. The thread and fiber examples ran on Ruby 4.0.6 with concurrent-ruby 1.3 and async 2.45. The Ractor examples use Ractor#take, which the 3.4 docs describe, and ran on Ruby 3.3.12; we did not run them on 3.4 itself. Ractors are experimental, so check the release notes before you upgrade.

The three tools in one paragraph #

  • Threads run Ruby code concurrently, but CRuby holds a Global VM Lock (GVL) so only one thread per Ractor runs Ruby code at a time. Threads help when the work spends its time waiting on I/O. Per the Ractor docs, the GVL is held per Ractor, so separate Ractors can run in parallel while threads inside one Ractor share a lock.
  • Fibers are cooperative: a fiber runs until it yields or blocks. With a fiber scheduler, blocking calls like sleep and socket reads switch to another fiber instead of stalling the thread. The async gem is a scheduler implementation.
  • Ractors give each unit of work its own isolated object space, so CPU-bound work can run in parallel. The cost is strict rules about what you can share.

Threads #

A thread pool #

Starting a thread per task is fine for a handful of tasks. For many tasks, a fixed pool with a queue keeps the thread count bounded:

class ThreadPool
  def initialize(size = 4)
    @size = size
    @jobs = Queue.new
    @pool = Array.new(size) do
      Thread.new do
        while (job = @jobs.pop) != :shutdown
          job.call
        end
      end
    end
  end

  def schedule(&block) = @jobs << block

  def shutdown
    @size.times { @jobs << :shutdown }
    @pool.each(&:join)
  end
end

pool = ThreadPool.new(4)
10.times { |i| pool.schedule { sleep 0.1; puts "job #{i} done" } }
pool.shutdown

Queue is thread-safe, so workers can pop from it without extra locking. One :shutdown sentinel per worker lets each loop exit cleanly. If a job raises, that worker thread dies; wrap job.call in rescue if you need the pool to survive bad jobs.

Thread-safe counters #

A plain Hash is not safe to mutate from several threads. The concurrent-ruby gem provides Concurrent::Map with an atomic compute:

require 'concurrent'

class MetricsCollector
  def initialize
    @counters = Concurrent::Map.new
    @timings = Concurrent::Array.new
  end

  def increment(key) = @counters.compute(key) { |current| (current || 0) + 1 }

  def record_timing(operation, duration)
    @timings << { operation: operation, duration: duration }
  end

  def statistics
    {
      counters: @counters.each_pair.to_h,
      average_timing: @timings.empty? ? 0 : @timings.sum { _1[:duration] } / @timings.size.to_f,
      total_operations: @timings.size
    }
  end
end

metrics = MetricsCollector.new
10.times.map { Thread.new { 100.times { metrics.increment(:hits) } } }.each(&:join)
p metrics.statistics[:counters] # => {hits: 1000}

Ten threads each add 100, and the total is exactly 1000. That is the point of using an atomic update instead of hash[key] += 1.

Fibers with the async gem #

Fibers shine when you have many tasks that mostly wait. With async, a sleep (or a network call through an async-aware client) yields to other tasks:

require 'async'

def fetch(i)
  sleep 0.1 # stands in for a network call
  i * 2
end

results = Sync do
  8.times.map { |i| Async { fetch(i) } }.map(&:wait)
end

p results # => [0, 2, 4, 6, 8, 10, 12, 14]

All eight tasks wait at the same time, so the block takes about as long as one sleep, not eight. For real HTTP calls, pair this with an async-aware client such as async-http; a client that is not scheduler-aware will block the whole thread.

What a scheduler does, by hand #

Fibers on their own are just resumable blocks. This round-robin loop shows the mechanism without any gem:

class FiberScheduler
  def initialize = @fibers = []

  def add_fiber(&block) = @fibers << Fiber.new(&block)

  def run
    until @fibers.empty?
      @fibers.each(&:resume)
      @fibers.select!(&:alive?)
    end
  end
end

scheduler = FiberScheduler.new
scheduler.add_fiber { 3.times { |i| puts "A#{i}"; Fiber.yield } }
scheduler.add_fiber { 2.times { |i| puts "B#{i}"; Fiber.yield } }
scheduler.run

This is a teaching toy, not a replacement for the real Fiber::Scheduler interface, which hooks into blocking operations automatically.

Ractors #

A Ractor cannot see outer local variables. You pass data in as arguments, and the block’s last value comes back through take:

def parallel_map(items, &block)
  items.map { |item| Ractor.new(item, &block) }.map(&:take)
end

p parallel_map([1, 2, 3]) { |n| n * n } # => [1, 4, 9]

Ruby prints a warning that Ractors are experimental. The 3.4 documentation still describes Ractor#take as the way to collect a result.

A two-stage pipeline #

Ractors can hand values to each other with Ractor.yield and take. Here stage 1 doubles each number and stage 2 adds 10:

stage1 = Ractor.new { loop { Ractor.yield(Ractor.receive * 2) } }
stage2 = Ractor.new(stage1) { |source| loop { Ractor.yield(source.take + 10) } }

[1, 25, 50].each { |n| stage1.send(n) }
p 3.times.map { stage2.take } # => [12, 60, 110]

Sending a value with send pushes it in; take pulls the next yielded value out. Because Ractors cannot share mutable objects, the values are copied or moved between them.

Sharing configuration #

Only shareable objects can be used from several Ractors without copying. Ractor.make_shareable freezes an object deeply:

config = Ractor.make_shareable({ api_url: "https://api.example.com", retries: 3 })

workers = 2.times.map do |i|
  Ractor.new(config, i) { |cfg, id| "worker #{id}: #{cfg[:api_url]}, retries #{cfg[:retries]}" }
end

puts workers.map(&:take)

A frozen outer object is not enough if it holds unfrozen children; the docs note that [Object.new].freeze is not shareable. That is why make_shareable freezes the whole structure.

A note on the Ractor API #

Ractor#take and Ractor.yield are the API in the 3.4 docs. Ractors are marked experimental and the API has been changing, so treat the Ractor code above as written for 3.3/3.4 and re-read the docs for your target version before upgrading. On Ruby 4.0.6, Ractor#take no longer exists (NoMethodError), so these examples will not run there.

Measure before you choose #

The right choice depends on the workload, so benchmark it. This script compares CPU-bound work (threads, sequential) and I/O-bound work (threads, async):

require 'benchmark'
require 'async'

def cpu(n) = (1..n).reduce(0) { |sum, i| sum + Math.sqrt(i) }

Benchmark.bm(12) do |x|
  x.report("cpu serial")  { 8.times { cpu(2_000_000) } }
  x.report("cpu threads") { 8.times.map { Thread.new { cpu(2_000_000) } }.each(&:join) }
  x.report("io threads")  { 8.times.map { Thread.new { sleep 0.1 } }.each(&:join) }
  x.report("io async")    { Sync { 8.times.map { Async { sleep 0.1 } }.each(&:wait) } }
end

When we ran it locally, the CPU-bound threads took about as long as the serial run (the GVL at work), while the eight waiting tasks overlapped under both threads and async. Your numbers will differ by machine and Ruby version, and we are not claiming a speedup figure here.

Do not assume Ractors win either. Spawning a Ractor costs time, and in a quick local test on Ruby 3.3 four Ractors running CPU work were not faster than running it serially. Ractors pay off when the work per Ractor is large enough to outweigh the setup, and only a benchmark on your workload will tell you.

Picking one #

WorkloadStart withWhy
Many I/O waits, few dependenciesThreads or async fibersWaiting does not hold the GVL
Thousands of concurrent connectionsFibers with asyncFibers are lighter than threads
CPU-bound work that can be isolatedRactors, after benchmarkingSeparate GVL per Ractor
Shared mutable stateThreads with concurrent-rubyRactors forbid sharing it
UnsureSequential codeConcurrency adds bugs; add it when a measurement asks for it

Debugging #

For threads, Thread.list and Thread#backtrace show where each thread is stuck:

Thread.list.each do |thread|
  puts "#{thread.inspect} status=#{thread.status.inspect}"
  puts thread.backtrace&.first(3)&.map { "  #{_1}" }
end

Ruby also raises fatal: No live threads left. Deadlock? when every thread is blocked, which catches the simplest deadlocks for you. For Ractors, Ractor.count tells you how many are alive.

Summary #

  • Use threads for I/O waits and for code that shares state; protect shared data with Queue, Mutex or concurrent-ruby.
  • Use fibers with async when you have many waiting tasks and your clients are scheduler-aware.
  • Use Ractors for isolated CPU work, and benchmark first; the API is experimental and changes after 3.4.
  • Measure on your own workload before choosing.

Sources #

Read next

Tell us what you need built

JetThoughts has been building software since 2008. Book a call, or write to sales@jetthoughts.com. You'll leave the call with a written one-page scope within 2 working days.

#01 Paul Keen
Paul KeenChief Technology Officer & Open Source Contributor

A 30-minute call; pick a time.