skip to content

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%

answer

  1. one scheduler per thread
  2. Fiber.new(blocking: false) is the default
  3. io_wait, kernel_sleep, block/unblock hooks
  4. Fiber.schedule delegates to #fiber
  5. root fiber is blocking, bypasses hooks

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.

solid answer

~50 s

`Fiber.set_scheduler(scheduler)` installs a scheduler object for the **current thread**. Ruby itself ships no scheduler class; gems such as async provide one implementing the `Fiber::Scheduler` interface. When code in a **non-blocking** fiber (the default for `Fiber.new`, and what `Fiber.schedule` creates) hits an operation that would block, Ruby calls a hook instead: a read on a socket with no data calls `io_wait(io, events, timeout)`, `sleep` calls `kernel_sleep`, waiting on a `Mutex`, `Queue` or `Thread#join` calls `block`, and waking such a waiter calls `unblock`. The scheduler records what the fiber waits for, switches to another ready fiber, and its event loop resumes the parked fiber when the socket becomes readable. Application code stays sequential. The thread's root fiber is blocking, so code there bypasses the hooks, and at thread exit Ruby calls the scheduler's `scheduler_close` or `close` to finish pending fibers.

code

ruby · 12 lines
ruby
require "async"   # async 2.x implements Fiber::Scheduler

Async do
  Fiber.scheduler.class      # => Async::Reactor (an Async::Scheduler subclass)
  Fiber.current.blocking?    # => false (task fibers are non-blocking)

  3.times.map do |i|
    Async { sleep 1; i }     # sleep -> scheduler.kernel_sleep, not a real block
  end.map(&:wait)            # => [0, 1, 2] after about 1 second, not 3
end

Fiber.scheduler             # => nil again once Async returns

go deeper

for a junior

Recall that a Fiber scheduler lets many fibers share one thread by switching whenever one would wait on I/O or sleep.

for a middle

Explain Fiber.set_scheduler per thread, blocking versus non-blocking fibers, and which operations call io_wait, kernel_sleep, block and unblock.

for a senior

Show you can predict what stalls an event loop: root-fiber code, CPU work, and C extensions that block outside Ruby's IO layer, and how to isolate them.

for a principal

Weigh adopting a fiber-scheduler stack for a service against threads or processes, including library compatibility and operational visibility.

## The problem the scheduler solves A chat server with thousands of open sockets spends almost all its time waiting for bytes. One thread per connection works but costs a native thread and its stack for each. A **Fiber scheduler** lets thousands of fibers share one thread: every fiber is written as ordinary blocking code (`socket.gets`, `sleep`), and whenever one would wait, the scheduler switches to another. ## Installing a scheduler - `Fiber.set_scheduler(obj)` sets the scheduler for the current thread and returns it. Each thread has its own. - `Fiber.scheduler` returns the scheduler set for the thread; `Fiber.current_scheduler` returns it only when the current fiber is non-blocking. - When the thread exits, Ruby calls the scheduler's `scheduler_close` (or `close` if that is absent), so it can run remaining fibers to completion. - Ruby provides the **interface**, documented as `Fiber::Scheduler`, but no implementation; async is the widely used one, and Ruby's own test suite contains a toy version. ## Blocking and non-blocking fibers Only **non-blocking** fibers use the hooks: | Fiber | Uses scheduler hooks? | |---|---| | `Fiber.new { }` (default `blocking: false`) | yes, if the thread has a scheduler | | `Fiber.schedule { }` | yes; delegates to the scheduler's `fiber` hook, which creates and starts it | | `Fiber.new(blocking: true) { }` | no | | the thread's root fiber (top-level code) | no | | any fiber, no scheduler set | no; behaves exactly like blocking | `Fiber.blocking { ... }` runs a block with the current fiber marked blocking, which scheduler implementations use for their own event-loop code. ## The hooks When a non-blocking fiber reaches a blocking operation, Ruby calls the matching method on the scheduler: - **`io_wait(io, events, timeout)`**: an IO is not ready for reading or writing. This is the heart of non-blocking sockets. - **`kernel_sleep(duration = nil)`**: `sleep` was called. - **`block(blocker, timeout = nil)`** and **`unblock(blocker, fiber)`**: waiting on and waking from `Mutex`, `ConditionVariable`, `Queue`/`SizedQueue` and `Thread#join`. `unblock` may be called from another thread. - **`process_wait(pid, flags)`**: waiting for a child process. - **`address_resolve(hostname)`**: DNS lookups (3.1+). - **`timeout_after(duration, klass, *args)`**: used by `Timeout.timeout` when a scheduler is active. - **`io_read`/`io_write`** and friends: experimental buffer-level hooks. - **`blocking_operation_wait`** (3.4+): lets the scheduler move a blocking operation off the event loop. - **`fiber_interrupt`** and **`yield`** (4.0): interrupting a fiber blocked on an IO that is closed, and yielding to the scheduler. Hooks the interface marks optional may be omitted; the others are expected by the operations that call them. ## A socket read, step by step 1. A fiber calls `client.gets` on a socket with no data yet. 2. Ruby's IO layer sees the operation would block and calls `scheduler.io_wait(client, IO::READABLE, nil)`. 3. The scheduler registers the socket with its selector (epoll, kqueue or similar) and switches to another ready fiber. 4. Its event loop later learns the socket is readable and resumes the parked fiber. 5. `io_wait` returns, and `gets` reads the line as if it had simply waited. ## Code that works with and without a scheduler The interface was designed so ordinary Ruby code does not need to know whether a scheduler is present: - `IO#read`, `IO#write`, `sleep`, `Queue#pop` and `Thread#join` behave identically from the caller's point of view; only the waiting strategy changes. - Libraries built on Ruby's own IO objects become concurrent under a scheduler without modification. - Code that checks `Fiber.scheduler` itself is rare and usually belongs in infrastructure, not application code. The corollary is that switch points are invisible in the source: any IO call can let another fiber run, so shared state still needs care, even though only one fiber executes at a time. ## What the scheduler does not change - Everything still runs on **one thread** under the GVL; there is no CPU parallelism. - A fiber that computes for a long time, or calls a C extension that blocks without going through Ruby's IO layer, stalls every fiber on that thread. - Switches happen only at intercepted operations, so code between them runs without interruption.

  • Why does sleep 1 in top-level code still block even after Fiber.set_scheduler is called?
    Top-level code runs in the thread's root fiber, which is a blocking fiber. Only non-blocking fibers route blocking operations through the scheduler, so the root fiber's `sleep` blocks the thread. Wrap the work in `Fiber.schedule` or a scheduler library's task to get non-blocking behaviour.
  • How do Mutex and Queue behave inside non-blocking fibers?
    They are fiber-aware. When a non-blocking fiber must wait for a `Mutex`, a `Queue` item or a `Thread#join`, Ruby calls the scheduler's `block` hook, so only that fiber is parked; the waker calls `unblock`, which may come from another thread. The rest of the event loop keeps running.

saying these in an interview costs you the question

  • Ruby ships a built-in Fiber scheduler class you instantiate.
  • Setting a scheduler makes fibers run in parallel on several cores.
  • Top-level code becomes non-blocking once a scheduler is set.
  • Fiber.set_scheduler applies to every thread in the process.
  • A scheduler can interrupt a fiber stuck in a pure-Ruby loop.