Controlling the Thread Scheduler in Ruby: Thread Management, Fiber, and Concurrency Control Explained

Controlling the Thread Scheduler in Ruby

Concurrency in Ruby is one of those topics that trips up even experienced developers, mostly because Ruby offers several different concurrency primitives — threads, fibers, and the newer Fiber::Scheduler — that solve overlapping but distinct problems. Understanding how Ruby actually schedules work, what the Global VM Lock does, and when to reach for a Thread versus a Fiber is the difference between writing concurrent Ruby code that scales and writing concurrent Ruby code that quietly serializes everything anyway.

This guide covers Ruby’s concurrency model end to end: how the thread scheduler works internally, how to manage threads directly, how fibers give you fine-grained control over execution, and how modern Ruby lets you influence scheduling decisions that used to be entirely opaque.

Ruby’s Concurrency Model: The Big Picture

Before touching any code, it’s worth being precise about terms Ruby developers often use loosely:

  • Concurrency is about structuring a program to handle multiple tasks that can be in progress at once, even if only one is actually executing at any given instant.
  • Parallelism is about literally executing multiple tasks at the same instant, on multiple CPU cores.

In MRI (Matz’s Ruby Interpreter, the reference implementation most people mean when they say “Ruby”), threads give you concurrency but generally not parallelism for pure Ruby code, because of the Global VM Lock.

The Global VM Lock (GVL/GIL)

MRI has a Global VM Lock (historically called the GIL, Global Interpreter Lock) that ensures only one thread executes Ruby code at a time, even on a multi-core machine. This exists because MRI’s internal object model and garbage collector are not thread-safe by design, and the GVL is the mechanism that protects them.

require 'benchmark'

def cpu_heavy_task
  1_000_000.times { |i| i * i }
end

# Two threads doing CPU-bound work
time = Benchmark.realtime do
  t1 = Thread.new { cpu_heavy_task }
  t2 = Thread.new { cpu_heavy_task }
  t1.join
  t2.join
end

puts "Two threads: #{time.round(3)}s"

Run this and you’ll find it’s roughly the same speed as running the two tasks sequentially — sometimes even slightly slower due to context-switching overhead. That’s the GVL at work: only one thread actually runs Ruby bytecode at any instant, so CPU-bound work doesn’t parallelize across threads in MRI.

Where threads do help is I/O-bound work. The GVL is released whenever a thread performs a blocking I/O operation (reading a file, waiting on a network socket, querying a database), letting another thread run during that wait:

require 'net/http'
require 'benchmark'

urls = ['https://example.com'] * 5

time = Benchmark.realtime do
  threads = urls.map do |url|
    Thread.new { Net::HTTP.get(URI(url)) }
  end
  threads.each(&:join)
end

puts "Five requests concurrently: #{time.round(3)}s"

Here, while one thread waits on a network response, the GVL is free for another thread to make progress — so five concurrent HTTP requests complete in roughly the time of the slowest one, not the sum of all five.

Thread Management Fundamentals

Creating and Controlling Threads

thread = Thread.new do
  puts "Running in a new thread"
  sleep 1
  puts "Thread finishing"
end

puts "Main thread continues immediately"
thread.join  # Block main thread until 'thread' finishes
puts "Both threads are done"

Output:

Main thread continues immediately
Running in a new thread
Thread finishing
Both threads are done

Key thread lifecycle methods:

t = Thread.new { sleep 2 }

t.alive?     # => true, while running
t.status     # => "sleep", "run", "aborting", false (finished normally), or nil (finished with exception)
t.join(1)    # wait up to 1 second, then return even if not finished
t.kill       # forcibly terminate the thread (use sparingly)
t.value      # blocks until the thread finishes, then returns its final expression's value

Passing Data Into Threads

Always pass data into a Thread.new block as arguments rather than relying on closures over loop variables, especially in older Ruby versions or tight loops:

threads = (1..5).map do |i|
  Thread.new(i) do |n|
    sleep(rand(0.1..0.3))
    puts "Thread #{n} finished"
  end
end
threads.each(&:join)

Passing i explicitly as a block parameter (Thread.new(i) { |n| ... }) avoids subtle bugs where all threads end up referencing the same final value of a shared loop variable.

Handling Exceptions in Threads

By default, an exception raised inside a thread does not crash the main program — it silently terminates just that thread, unless you check for it:

Thread.abort_on_exception = true  # globally: propagate thread exceptions to main thread

t = Thread.new do
  raise "Something broke inside the thread"
end

begin
  t.join
rescue => e
  puts "Caught: #{e.message}"
end

Output:

Caught: Something broke inside the thread

Without abort_on_exception = true (or calling t.join/t.value, which re-raises the stored exception), a failing thread can fail completely silently — a classic source of “why didn’t this work?” bugs in production systems.

Thread Synchronization: Mutex, ConditionVariable, and Queue

Even with the GVL, race conditions are very real in Ruby, because the GVL can switch between threads at almost any bytecode boundary — including in the middle of a “simple” operation like += on a shared variable.

counter = 0
mutex = Mutex.new

threads = 10.times.map do
  Thread.new do
    1000.times do
      mutex.synchronize { counter += 1 }
    end
  end
end
threads.each(&:join)

puts counter  # => 10000, reliably, because of the mutex

Without the mutex.synchronize block, this same code will non-deterministically produce a number less than 10,000, because two threads can read the same value of counter before either writes back the incremented result — a textbook race condition.

Thread::Queue for Producer-Consumer Patterns

Thread::Queue is a thread-safe FIFO queue purpose-built for coordinating work between threads, and it’s usually a better tool than manually managing mutexes for this kind of pattern:

require 'thread'

queue = Queue.new

producer = Thread.new do
  5.times do |i|
    queue << "job-#{i}"
    puts "Produced job-#{i}"
    sleep 0.1
  end
  queue.close
end

consumer = Thread.new do
  while (job = queue.pop)
    puts "Consumed #{job}"
  end
end

producer.join
consumer.join

Queue#pop blocks automatically when empty and unblocks automatically once a producer adds work, so you get correct coordination without hand-rolling condition variables.

Fibers: Cooperative, Fine-Grained Control

Where Thread gives you preemptively scheduled concurrency managed by the VM, Fiber gives you cooperative concurrency that you control explicitly. A fiber only yields control when it chooses to, via Fiber.yield, and only resumes when explicitly told to via resume.

fiber = Fiber.new do
  puts "Fiber: step 1"
  Fiber.yield
  puts "Fiber: step 2"
  Fiber.yield
  puts "Fiber: step 3"
end

fiber.resume  # => "Fiber: step 1"
puts "Main: control returned to me"
fiber.resume  # => "Fiber: step 2"
fiber.resume  # => "Fiber: step 3"

Output:

Fiber: step 1
Main: control returned to me
Fiber: step 2
Fiber: step 3

This is fundamentally different from a thread: there’s no scheduler deciding when the fiber runs. Execution passes explicitly back and forth between resume and Fiber.yield, like a hand-off between two functions that remember exactly where they left off.

Passing Values Between Fiber and Caller

generator = Fiber.new do |start|
  value = start
  loop do
    value = Fiber.yield(value * 2)
  end
end

puts generator.resume(1)   # => 2
puts generator.resume(5)   # => 10
puts generator.resume(10)  # => 20

This pattern — a fiber acting as a lazily-evaluated generator — is exactly how Enumerator is implemented internally in Ruby. Every time you call .next on a lazy enumerator, you’re resuming a fiber under the hood.

fib_enum = Enumerator.new do |y|
  a, b = 0, 1
  loop do
    y << a
    a, b = b, a + b
  end
end

puts fib_enum.take(10).inspect
# => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Controlling the Thread Scheduler: What You Can and Can’t Influence

Ruby doesn’t expose a general-purpose “set thread priority and it will be honored precisely” API the way some lower-level languages do, but it does give you real levers.

Thread Priority

low = Thread.new { loop { Thread.pass } }
low.priority = -1

high = Thread.new { puts "High priority work" }
high.priority = 1

Thread#priority= is a hint to the VM’s scheduler, not a hard guarantee — MRI’s scheduler uses it to bias which runnable thread gets the GVL next, but it doesn’t preempt a thread mid-execution just because a higher-priority thread became runnable.

Thread.pass

Thread.new do
  5.times do |i|
    puts "Worker: #{i}"
    Thread.pass  # voluntarily yield remaining time slice to another thread
  end
end.join

Thread.pass is a cooperative hint that tells the scheduler “you can run someone else now if you want,” useful in tight loops where you want to be a good citizen toward other runnable threads.

Fiber::SchedulerInterface (Ruby 3.0+)

The most powerful and modern lever is the Fiber::Scheduler interface, introduced in Ruby 3.0. It lets you implement a custom scheduler object that intercepts blocking operations (sleep, I/O waits, mutex waits) and decides what happens during those waits — typically, running other fibers instead of blocking the whole thread.

require 'fiber'

class SimpleScheduler
  def initialize
    @waiting = []
  end

  def kernel_sleep(duration)
    @waiting << [Fiber.current, Time.now + duration]
    Fiber.yield
  end

  def block(blocker, timeout = nil)
    Fiber.yield
  end

  def unblock(blocker, fiber)
    fiber.resume
  end

  def run
    until @waiting.empty?
      @waiting.each do |fiber, ready_at|
        if Time.now >= ready_at
          @waiting.delete([fiber, ready_at])
          fiber.resume
        end
      end
    end
  end

  def close
    run
  end
end

Fiber.set_scheduler(SimpleScheduler.new)

Fiber.schedule do
  puts "Task A starts"
  sleep 1
  puts "Task A resumes"
end

Fiber.schedule do
  puts "Task B starts"
  sleep 0.5
  puts "Task B resumes"
end

This is a simplified illustration — production schedulers (like the ones inside the async gem) implement the full Fiber::SchedulerInterface, covering io_wait, io_read, io_write, process_wait, and more. But the core idea is exactly what’s shown here: you get to define what “waiting” means, replacing thread-blocking I/O with fiber-yielding I/O, so thousands of lightweight fibers can share a single OS thread efficiently.

This is precisely the mechanism modern async Ruby frameworks (like the async gem, and Falcon web server) use to achieve extremely high concurrency without the memory overhead of one OS thread per connection.

Internal Working: What Actually Happens During a Context Switch

  1. Thread context switches in MRI involve the VM saving the current thread’s execution context (its call stack, instruction pointer, and local state) and releasing the GVL, then another runnable thread acquires the GVL and resumes. This happens either voluntarily (blocking I/O, Thread.pass, sleep) or involuntarily, at periodic checkpoints the VM inserts between bytecode instructions (roughly every 100ms of execution by default, configurable via RUBY_THREAD_TIMESLICE in some builds).
  2. Fiber context switches are far cheaper because there’s no GVL release/reacquire involved and no OS-level thread switch — a fiber switch just swaps the Ruby-level call stack pointer to a different pre-allocated stack. This is why fibers can comfortably scale into the tens of thousands, while OS threads realistically cap out in the low thousands.
  3. Memory footprint. Each Thread in MRI allocates a full native OS thread stack, typically defaulting to around 1MB depending on platform (tunable but not trivially so). Each Fiber, by contrast, uses a much smaller allocated stack (historically around 4KB–16KB depending on Ruby version and platform, growing as needed), which is why fiber-based concurrency is dramatically more memory-efficient at scale.

Common Mistakes and Debugging Tips

  • Assuming threads give CPU parallelism in MRI. They don’t, for pure Ruby code, because of the GVL. Use Process.fork or external worker processes (e.g., via Parallel gem or Sidekiq’s multi-process model) for genuine CPU-bound parallelism, or consider JRuby/TruffleRuby, which don’t have a GVL.
  • Forgetting Thread.abort_on_exception = true (or checking thread.value). Silent thread failures are one of the most common production surprises in Ruby.
  • Mutating shared state without a Mutex. Even simple operations like array << item or hash[key] += 1 are not guaranteed atomic across threads; wrap shared mutable state access in Mutex#synchronize.
  • Deadlocks from nested mutex locking. Locking the same, non-reentrant mutex twice from the same thread will deadlock. Keep lock scopes small and avoid calling code that might re-acquire the same lock.
  • Resuming a dead fiber. Calling .resume on a fiber that has already run to completion raises FiberError: dead fiber called. Track fiber state explicitly if you need to resume conditionally.
  • Mixing fibers and threads carelessly. A Fiber is tied to the thread that created it and cannot be resumed from a different thread. Passing a fiber across threads is a common source of confusing FiberError exceptions.

Debugging tools worth knowing:

Thread.list.each { |t| puts "#{t}: #{t.status}" }  # inspect all live threads

# Ruby's built-in tracing for deep debugging
Thread.new { ... }.report_on_exception = true  # per-thread exception reporting (default true since Ruby 2.5+)

Real-World Applications

  • Web servers (Puma) use a thread pool per worker process to handle concurrent requests, relying on the GVL releasing during I/O (database queries, external API calls) to serve many requests “at once.”
  • Background job processors (Sidekiq) use threads for I/O-bound job concurrency within each process, while using multiple OS processes for genuine parallelism.
  • Async I/O frameworks (the async gem, Falcon web server) use Fiber::Scheduler to handle tens of thousands of concurrent connections on a single thread, avoiding the memory cost of thread-per-connection.
  • Lazy data pipelines use fibers (via Enumerator) to process large or infinite sequences without loading everything into memory at once.
  • Rate limiters and batch processors commonly use Thread::Queue to coordinate producer/consumer work safely across a fixed pool of worker threads.

Summary

Ruby’s concurrency toolkit is more nuanced than a single “use threads” answer suggests. Threads give you real concurrency for I/O-bound work, governed by the GVL, which means they shine for network calls and database queries but don’t deliver CPU parallelism in MRI. Fibers give you cheap, cooperative, fully controllable concurrency — the same mechanism quietly powering Enumerator under the hood — and become genuinely powerful once combined with Fiber::Scheduler, which lets modern async frameworks handle massive connection counts on minimal OS resources. Getting comfortable with Mutex, Thread::Queue, and the cooperative yield/resume model of fibers, along with a clear mental model of what the GVL actually does and doesn’t protect you from, is what turns “I used threads and it kind of worked” into concurrent Ruby code you can actually trust in production.

References

Total
0
Shares

Leave a Reply

Previous Post
Talking to Networks in Ruby

Talking to Networks in Ruby: HTTP, FTP, and Client-Server Communication Complete Guide

Next Post
Socket and network libraries in Ruby

Socket and Network Libraries in Ruby: Complete Guide to Network Programming with TCPSocket and UDPSocket

Related Posts