What Fiber::Scheduler actually is, how it differs from a Thread
pool, and why it's a particularly good fit for an HTTP client.
A Ruby Fiber is a unit of execution you control by hand: it runs until it explicitly
yields, and nothing switches away from it involuntarily the way a Thread can be preempted mid-line.
On its own that's just cooperative scheduling — mildly interesting, not obviously useful for I/O.
What makes it useful is Fiber::Scheduler (Ruby 3.0+): a hook that lets a library
intercept blocking operations — a socket read, sleep, a Mutex wait — and, instead of
blocking the whole OS thread, yield control to another Fiber that has work to do, resuming
the first one automatically once its I/O is actually ready.
The async gem (the reactor Butler is built on) implements exactly that scheduler.
Nothing about writing Fiber-scheduled code looks different from writing ordinary synchronous Ruby
— no callbacks, no .then chains, no colored async/await functions — because the
yielding happens underneath a normal-looking blocking call, not in application code.
A thread-per-request model (the classic way to add concurrency to a blocking I/O client) gives every in-flight request its own OS thread. That works, but OS threads aren't free: each one carries real memory (a stack, kernel scheduling structures) and real context-switch cost, and Ruby's GVL means only one thread runs Ruby bytecode at a time regardless — threads help exactly because I/O waits release the GVL, not because Ruby got parallel.
Fibers pay almost none of that overhead. Thousands of them can be alive at once on a single OS thread, because a Fiber that's waiting on I/O isn't consuming a kernel thread's worth of resources while it waits — it's just a suspended, resumable stack the scheduler is holding onto. For a workload that's mostly "waiting on the network" (which describes almost every HTTP client call), that's the exact shape of overhead you don't need to pay.
An HTTP request spends the overwhelming majority of its wall-clock time waiting: DNS, TCP connect, TLS handshake, waiting for the server's response. A client library's job during almost all of that time is to do nothing but hold state and wait to be resumed — which is precisely what a Fiber does well and a Thread does at a real, measurable cost. Fanning out 50 concurrent requests as 50 Fibers instead of 50 Threads means avoiding 50 threads' worth of memory and scheduling overhead for work that's fundamentally "wait, then do a small amount of CPU work, then wait again."
client.async { |tasks| tasks.async { client.get("/a") } } gives you real structured
concurrency — an Async::Barrier underneath resolves any child task the block forgot to
.wait and cancels anything still running after an exception, so exiting the block
always leaves every child task resolved or cancelled. That's the shape you want for "fetch these 5
endpoints concurrently" without hand-rolling your own thread-pool bookkeeping.
The less obvious part is what happens for an ordinary, non-concurrent client.get
call. The naive way to make one async-native call "just work" synchronously is
Async { ... }.wait — spin up a whole reactor, run the block, tear it down — and that's
real, measurable overhead paid on every single bare call. Butler avoids it with one background
reactor (Butler::Async.background), running in one dedicated Thread, lazily started
once and shared by every Butler::Client in the process for its whole lifetime.
Client#request checks whether it's already inside a reactor first (a nested
client.async, or a Fiber-scheduler-based app server like Falcon) and runs inline with
zero dispatch overhead if so; otherwise the work is handed to that shared background reactor over a
plain Thread::Queue, and the calling thread blocks on a second Queue until the result
comes back — invisibly, so a bare client.get("/users") still looks and behaves like an
ordinary synchronous method call from the outside.