Introduction
Ruby 3.1 introduced Fiber::Scheduler#io_select, making IO.select work seamlessly with fiber-based concurrency. Before diving in, let’s clarify some key concepts.
What’s a Fiber?
A fiber is a lightweight, pausable unit of execution in Ruby. Unlike threads, fibers don’t run in parallel—they cooperatively yield control to each other, making them predictable and efficient for I/O-bound tasks.
fiber = Fiber.new do
puts "Starting"
Fiber.yield # Pause here
puts "Resuming"
end
fiber.resume # => "Starting"
fiber.resume # => "Resuming"What’s a Scheduler?
A scheduler manages when fibers run. When a fiber performs I/O (like reading from a socket), the scheduler pauses it and runs other fibers instead of blocking. This enables non-blocking I/O without callbacks or threads.
Popular schedulers include the Async gem, which powers the Falcon web server.
Real-World Example
Imagine building a web scraper that fetches 100 URLs. With traditional blocking I/O, you’d wait for each request sequentially. With fibers and a scheduler, you can start all 100 requests concurrently—the scheduler switches between them as responses arrive.
require 'async'
require 'async/http/internet'
Async do
internet = Async::HTTP::Internet.new
urls = 100.times.map { |i| "https://example.com/page#{i}" }
# All requests run concurrently
responses = urls.map do |url|
Async { internet.get(url) }
end.map(&:wait)
puts "Fetched #{responses.size} pages"
endThe Problem Before Ruby 3.1
IO.select is a system call that monitors multiple file descriptors (sockets, files, etc.) and returns when any become ready for reading or writing. It’s commonly used in networking code.
Before Ruby 3.1, calling IO.select inside a fiber blocked the entire thread, preventing the scheduler from managing other fibers. This broke concurrency for any library using IO.select.
# Before: blocks all fibers
IO.select([socket_a, socket_b])
# Scheduler can't run other fibers hereThe Solution: io_select Hook
Ruby 3.1 added Fiber::Scheduler#io_select, allowing schedulers to handle IO.select non-blockingly. When IO.select is called, Ruby delegates to the scheduler instead of blocking.
# After: scheduler manages the wait
IO.select([socket_a, socket_b])
# Other fibers continue runningSchedulers implement this method:
def io_select(readable, writable, exceptional, timeout)
# Non-blocking multiplexing logic
# Returns arrays of ready descriptors
endPractical Impact
This change means:
- Legacy compatibility: Old libraries using
IO.selectnow work with fiber schedulers - No code changes needed: Existing code becomes non-blocking automatically when a scheduler is active
- Better performance: Applications can handle thousands of concurrent I/O operations efficiently
For example, the redis-rb gem uses IO.select internally. With this feature, it works seamlessly with fiber-based web servers like Falcon without modifications.
Why This Matters
Ruby is moving toward fiber-based concurrency as the standard for I/O-bound applications. Features like io_select ensure the entire ecosystem—including older libraries—can participate in this model without rewrites.
This gives us the best of both worlds: simple, synchronous-looking code that performs like async systems.
