Ruby concurrency

Ruby Fibers and cooperative concurrency

A practical explanation of Ruby Fibers, non-preemptive scheduling, fiber schedulers, and where cooperative concurrency helps or hurts.

Fibers are explicit

Ruby Fibers are lightweight execution contexts. Unlike operating-system threads, they do not preempt each other by default. A fiber runs until it yields, waits through a scheduler-aware operation, or finishes.

That makes fiber concurrency cooperative. The code has to reach points where control can move elsewhere. This is powerful for I/O-heavy programs, but it is not a way to make CPU-bound Ruby code run in parallel.

The mental model is closer to many small tasks sharing one thread than to many threads racing at once.

The scheduler

Ruby's fiber scheduler interface lets libraries hook blocking operations such as sleep, socket waits, and process waits. When a scheduler is installed, compatible operations can yield control while waiting for I/O.

This is the foundation used by async Ruby libraries. A program can write code that looks direct, while the scheduler multiplexes many waiting operations on a small number of threads.

The key phrase is compatible operations. If a library blocks in a way the scheduler cannot intercept, the whole thread may still be blocked.

Where it helps

Fibers are a good fit for many concurrent network operations: HTTP calls, database waits, websocket connections, stream handling, and background tasks that spend most of their time waiting.

They can reduce the overhead of large thread pools and make high-concurrency I/O code easier to structure. The code can stay mostly sequential instead of being split into callbacks.

They also make cancellation and timeouts more important. When thousands of small tasks share a runtime, forgotten waits and unbounded queues can become system-wide pressure.

Where it hurts

Fibers do not make CPU-heavy work parallel. A fiber that parses a huge file, compresses data, or runs a tight loop without yielding will monopolize its thread. Other fibers on that thread wait.

They can also expose hidden blocking in dependencies. One gem using ordinary blocking I/O can break the expected concurrency of an otherwise scheduler-friendly program.

Debugging can be different too. Stack traces remain useful, but timing bugs, cancellation paths, and shared mutable state still require discipline.

Design rules

Use fibers when the workload is mostly I/O and the libraries involved cooperate with the scheduler. Keep CPU-heavy work in separate processes, native extensions, Ractors, or ordinary background jobs where appropriate.

Put timeouts at the edges. Bound queues. Make cancellation explicit. Treat every external call as something that can wait forever unless the code says otherwise.

Cooperative concurrency is simple when every participant cooperates. The design job is to make that assumption visible and tested.

Observability

Fiber-based programs need observability around waits, not only errors. Log or measure queue depth, time spent waiting on sockets, timeout counts, cancellation counts, and the number of active tasks. Otherwise a stalled dependency can look like a slow Ruby process rather than a scheduling problem.

Names help too. A thousand anonymous fibers are hard to reason about. A small wrapper that records purpose, start time, and deadline can make production behavior much easier to inspect when concurrency grows.

A cooperative example

Fibers are easiest to understand as explicit pause points. Nothing preempts the current fiber. Another fiber runs only when the current one yields, awaits scheduler-managed I/O, or returns.

producer = Fiber.new do
  3.times do |index|
    Fiber.yield "job-#{index}"
  end
end

while producer.alive?
  job = producer.resume
  puts "processing #{job}" if job
end

That example is intentionally small. Real async Ruby usually relies on a scheduler-aware library so reads, writes, sleeps, and accepts become yield points. The design lesson is the same: cooperative concurrency works well when blocking operations are explicit and the code does not pretend it has CPU parallelism.

Fiber.schedule do
  response = HTTP.get("https://example.com/feed.json")
  process(response)
end

Fiber.schedule do
  response = HTTP.get("https://example.com/status.json")
  process(response)
end

Used this way, fibers help overlap waiting. They do not make a CPU-heavy Ruby loop faster, and they do not remove the need for timeouts, cancellation, and back-pressure.

Where fibers fit

Fibers fit best around I/O-heavy programs that spend much of their time waiting: crawlers, chatty API clients, lightweight servers, and pipelines that multiplex many slow resources. They are less useful for CPU-bound image processing, compression, or numeric work, where parallel processes or native extensions are usually more honest.

The design risk is hidden blocking. One library call that does not cooperate with the scheduler can stall the whole fiber-based flow. That means dependency choice matters. Async code is only as cooperative as the slowest operation in the path.

A good fiber design also keeps cancellation in mind. If the caller times out or the user leaves, scheduled work should stop or become detached deliberately. Cooperative code still needs ownership rules for unfinished work.

That ownership question is often more important than the syntax. Concurrency bugs usually come from unclear lifetime, so the surrounding code should make scheduling, cancellation, and resource cleanup visible.

Sources

  1. Ruby documentation, Fiber. https://docs.ruby-lang.org/en/master/Fiber.html
  2. Ruby documentation, Fiber::SchedulerInterface. https://docs.ruby-lang.org/en/master/Fiber/SchedulerInterface.html
  3. Async Ruby project. https://github.com/socketry/async