skip to content

Fibers & Schedulers

Fiber.new, resume and Fiber.yield pass control cooperatively, and a Fiber scheduler turns blocking I/O into non-blocking waits. Interviewers ask how async Ruby avoids callbacks and threads.

on this pageshow

explore

questions

4

In Ruby, how do Fiber.new, Fiber#resume and Fiber.yield pass values back and forth, and what happens when you resume a finished fiber?

level: juniorimportance: should knowfreq 40%

answer

  1. created paused, runs on first resume
  2. first resume args become block params
  3. Fiber.yield(x) makes resume return x
  4. next resume(y) makes Fiber.yield return y
  5. finished fiber: alive? false, FiberError

basics

~20 s

Fiber.new creates a paused fiber. resume runs it until Fiber.yield, whose argument becomes resume's return value; the next resume's argument becomes Fiber.yield's return value. When the block ends, alive? is false and resuming raises FiberError.

solid answer

~40 s

A `Fiber` is a block with its own stack that runs only when told to. `Fiber.new { |x| ... }` creates it paused. The first `fiber.resume(10)` starts the block with `x = 10` and runs until the block calls `Fiber.yield(value)`; that pauses the fiber, and `resume` returns `value` to the caller. A later `fiber.resume(20)` continues right after the `Fiber.yield`, which now returns `20`. When the block finishes, its last expression becomes the value of that final `resume`, `alive?` turns `false`, and another `resume` raises `FiberError` ("attempt to resume a terminated fiber"). Nothing is preempted: control changes hands only at `resume` and `Fiber.yield`, and an exception raised inside the fiber comes out of the `resume` call that was running it.

code

ruby · 14 lines
ruby
numbers = Fiber.new do
  n = 0
  loop { Fiber.yield(n += 1) }
end

numbers.resume  # => 1
numbers.resume  # => 2
numbers.alive?  # => true (loop never ends)

once = Fiber.new { :done }
once.resume     # => :done
once.alive?     # => false

Fiber.yield     # FiberError: attempt to yield on a not resumed fiber

go deeper

for a junior

Recall that a fiber starts paused, resume runs it until Fiber.yield, and values travel both ways through those two calls.

for a middle

Explain the value flow step by step, the FiberError cases, and why fibers need no locks between switch points yet stall when one never yields.

for a senior

Relate raw fibers to what production code actually uses: external enumerators and scheduler-driven libraries, and when neither is worth the complexity.

for a principal

Judge when cooperative concurrency suits a service better than threads or processes, considering libraries that block and team familiarity.

## What a Fiber is A `Fiber` is a piece of code, given as a block, that can pause part-way through and continue later. Each fiber has its own stack, so it can pause from inside nested method calls, not just at the top of its block. Fibers are **cooperative**: the VM never switches away from a fiber on its own; the code decides when to hand over control. All fibers created by one thread run on that thread. ## The control flow 1. `f = Fiber.new { |first| ... }` creates the fiber. Nothing in the block runs yet. 2. `f.resume(a)` starts the block; `first` is `a`. The caller is suspended. 3. Inside, `Fiber.yield(b)` pauses the fiber. The caller's `resume` returns `b`. 4. `f.resume(c)` continues after the `Fiber.yield`, which returns `c` inside the fiber. 5. When the block ends, the value of its last expression is returned by the `resume` that was running it, and the fiber is finished. ```ruby f = Fiber.new do |first| second = Fiber.yield(first + 2) second * 10 end f.resume(10) # => 12 (first = 10, paused at Fiber.yield) f.resume(5) # => 50 (Fiber.yield returned 5; block finished) f.alive? # => false f.resume # FiberError: attempt to resume a terminated fiber ``` ## Values in both directions | Call | Value delivered | |---|---| | first `resume(*args)` | `args` become the block's parameters | | `Fiber.yield(v)` | `v` becomes the return value of the caller's `resume` | | later `resume(v)` | `v` becomes the return value of `Fiber.yield` inside the fiber | | block ends with `v` | `v` becomes the return value of the last `resume` | With no argument the value is `nil`; with several (`Fiber.yield(1, 2)`) the other side receives an Array. ## Errors to recognise All of these are `FiberError`, a subclass of `StandardError`: - resuming a finished fiber: `attempt to resume a terminated fiber`; - calling `Fiber.yield` outside any resumed fiber, for example at the top level of a script: `attempt to yield on a not resumed fiber`; - resuming a fiber that is already running further up the resume chain: `attempt to resume a resumed fiber (double resume)`; - resuming a fiber from a different thread than the one that created it: `fiber called across threads`. An ordinary exception raised inside the block ends the fiber and propagates out of the `resume` call, so it can be rescued by the caller. ## alive? and friends - `alive?` is `true` until the block finishes; it says whether `resume` can still succeed. - `Fiber.current` returns the running fiber; the thread's original fiber is its **root fiber**. - `Fiber#kill` (Ruby 3.3+) terminates a fiber, running its `ensure` blocks. - `Fiber#raise` resumes a fiber by raising an exception at the point where it is paused. ## resume versus transfer `resume` and `Fiber.yield` form a strict parent-child pair: a resumed fiber always yields back to whoever resumed it. `Fiber#transfer` is the symmetric alternative: it switches directly to another fiber, and that fiber must later transfer control onward explicitly. Mixing the two styles on one fiber is restricted; for example, resuming a fiber that was entered with `transfer` raises `FiberError` (`attempt to resume a transferring fiber`). Application code almost always uses `resume`/`Fiber.yield`; `transfer` is a tool for scheduler authors. ## Memory per fiber Each fiber gets a VM stack and a machine stack, sized by `RUBY_FIBER_VM_STACK_SIZE` and `RUBY_FIBER_MACHINE_STACK_SIZE` (the `ruby(1)` manual gives defaults of 64 or 128 KiB for the VM stack and 256 or 512 KiB for the machine stack, depending on platform). That is smaller than the default stack of a native thread on common platforms, which is why a process can hold many thousands of paused fibers. ## Where you meet fibers without writing Fiber.new Most Ruby developers use fibers indirectly: - **External enumerators**: `enum.next` runs the enumeration in a fiber and pauses it after each element, which is how iteration can be driven one step at a time. - **Fiber schedulers**: libraries such as async run each task as a fiber and switch fibers whenever one would block on I/O. Compared with threads, fibers are cheap to create and switch, and they never interleave unexpectedly, because switches happen only where the code says so. The price is that one fiber that computes without yielding keeps every other fiber on that thread waiting.

  • What happens to an exception raised inside a Ruby fiber's block?
    It ends the fiber, which is then no longer alive, and propagates out of the `resume` call that was running it. The caller can rescue it there like any other exception. Resuming the fiber again afterwards raises `FiberError`.
  • Why can one busy fiber stall all the others on its thread?
    Fibers are never preempted. Control changes only at `resume`, `Fiber.yield`, or, under a Fiber scheduler, at a blocking operation that the scheduler intercepts. A fiber running a long computation without any of those keeps the thread, and every other fiber on it waits.

A fiber is a relay baton passed between two runners who agree on the hand-off points: each runner keeps going until they reach a marked spot, hands the baton over together with a note, and waits until it is handed back with a reply.

saying these in an interview costs you the question

  • Fiber.new starts running its block immediately.
  • The Ruby VM preempts a fiber that runs too long.
  • Resuming a finished fiber just returns nil again.
  • Fibers created on one thread can be resumed from any thread.
  • Fiber.yield returns the value that was passed to it.
open as a page

With Ruby's async gem, how do Async and Sync let one thread serve thousands of chat-socket connections, and what still blocks every connection?

level: middleimportance: should knowfreq 36%

basics

~20 s

Async runs each connection as a task, a non-blocking fiber under a reactor that is the Fiber scheduler, so a socket wait parks only that task. Sync runs a block and returns its value. CPU-heavy work or blocking C calls stall every task.

open as a page

In Ruby, what does Fiber.set_scheduler do, and how does a non-blocking fiber's socket read become a wait the scheduler can multiplex?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Fiber.set_scheduler installs an object that Ruby calls whenever a non-blocking fiber would block: a socket not ready calls io_wait, sleep calls kernel_sleep. The scheduler parks that fiber, runs others, and resumes it when the event loop reports readiness.

open as a page

In Ruby 3.2+, how does Fiber[] storage behave across new fibers, threads and Enumerator#next, and why was it added beside Thread.current[]?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

Fiber[:key] reads and Fiber[:key] = value writes the current fiber's storage. Each new fiber or thread starts with a copy of its creator's storage, so values flow down into enumerator fibers and tasks, but a child's writes never reach the parent.

open as a page