Ruby Async Gem: Fiber-Based Concurrency for I/O
The async gem is built for Ruby programs that spend more time waiting than computing. When your app waits on network requests, database queries, or file operations, traditional synchronous code leaves CPUs idle. The Ruby async gem takes a different approach to concurrency: it runs thousands of concurrent operations in a single thread using fibers, giving you the throughput of threads without the memory cost.
intro context
The async gem is easiest to understand when you think about waiting rather than raw speed. It does not make CPU-heavy work magically faster. Instead, it keeps the program busy while one fiber waits for I/O and another fiber is ready to continue. That is why the gem fits network requests, file reads, and other work that spends a lot of time idle.
If you have only used threads, the model can feel different at first. The reactor is the traffic controller, tasks are the small units of work, and the scheduler only switches when a task yields at a blocking point. Once that clicks, async code starts to feel less like magic and more like a compact way to coordinate many I/O waits.
Another useful way to think about the async gem is that it keeps your Ruby process busy without turning every wait into a separate thread. That matters when you need to fan out across many endpoints, because the work can overlap while still staying inside one lightweight reactor.
tl;dr
Use async when your bottleneck is waiting on I/O. The reactor handles scheduling, tasks can pause without blocking the whole thread, and primitives like semaphores and queues help you shape concurrency without a large thread pool.
installing the gem
Add it to your Gemfile with bundle add async, or install directly:
gem install async
That’s all you need. The gem works with Ruby 3.0 and later, since it hooks into Ruby’s Fiber Scheduler interface.
the reactor
Every Async { } block creates an Async::Reactor under the hood. The reactor is an event loop that watches for I/O readiness and wakes up fibers when their blocking operations are ready to continue.
You rarely create a reactor directly. The Async block does it for you:
require 'async'
Async do |task|
# The reactor is running here
end
# Reactor stops when the block finishes
Inside the block, blocking operations like sleep, read, and write are intercepted by the scheduler. When one of these yields control, the reactor picks another ready fiber to run. From the outside it looks like everything runs at once; internally, the reactor jumps between fibers cooperatively.
That cooperative design is what keeps async lightweight. Fibers only give up control at predictable yield points, so you avoid the overhead of pre-emptive thread switching. For many network-heavy Ruby programs, the result is code that scales up without the memory pressure of a large thread pool. The reactor can juggle thousands of fibers with minimal overhead because each fiber only consumes a few kilobytes of stack space.
async tasks
The block argument task is an Async::Task. A fiber that can wait for other fibers, spawn children, and propagate results.
waiting for results
Call task.wait (or task.await) to suspend the current fiber until the target task finishes and retrieve its return value:
require 'async'
task = Async do
sleep 0.5
"hello"
end
result = task.wait
puts result # prints "hello"
This is non-blocking from the reactor’s perspective. While this fiber is suspended waiting for the task to finish, the reactor can execute other fibers. This is the core idea behind async concurrency: blocking operations yield control instead of blocking the entire process.
spawning child tasks
Call task.async to create a child task that shares the parent’s reactor:
require 'async'
Async do |parent|
child = parent.async do
sleep 0.2
"child done"
end
parent.async do
sleep 0.1
"parent's other child"
end
puts child.wait # prints "child done"
end
Child tasks inherit the reactor’s scheduler, so all of them cooperate within the same event loop. There’s no need for locks or mutexes between fibers. A fiber only yields at blocking calls, so there’s no pre-emptive scheduling to worry about.
That makes fibers a nice fit for smaller pieces of I/O work. You can break a larger network job into several steps, let them cooperate in one event loop, and keep the code compact without introducing a thread pool just to wait on remote services. The async gem really shines once you start combining tasks for outbound network calls. Since each task yields cooperatively at I/O boundaries, you can fan out to dozens of endpoints and collect responses without any of the synchronization machinery that threads would require.
concurrent HTTP requests
One of the most practical uses for the async gem is running many HTTP requests simultaneously. The async-http gem provides an async-aware HTTP client built on top of the reactor:
require 'async'
require 'async/http/internet'
urls = [
"https://example.com",
"https://example.org",
"https://example.net",
]
Async do
internet = Async::HTTP::Internet.new
tasks = urls.map do |url|
Async { internet.get(url).read }
end
responses = tasks.map(&:wait)
puts responses.map { |r| r[0..80] }.join("\n")
end
Each Async { } block runs its HTTP request concurrently. The reactor switches between them as they wait for data, so you get parallelism without threads. The tasks.map(&:wait) call collects results once all requests have completed, preserving the order of the original urls array.
For a single request, the Sync helper is simpler:
require 'async/http/internet'
Sync do
internet = Async::HTTP::Internet.new
body = internet.get("https://example.com").read
puts body[0..100]
end
Sync runs in the current reactor if one exists, or creates one if not. It’s lightweight and fits well in scripts or tests where you just need one-shot async behavior.
limiting concurrency with semaphore
Sometimes you want concurrent execution but need to cap how many tasks run at the same time; for example, to avoid overwhelming a downstream API. Async::Semaphore does exactly that:
require 'async'
require 'async/http/internet'
semaphore = Async::Semaphore.new(limit: 3)
urls = 10.times.map { |i| "https://example.com/?id=#{i}" }
Async do
internet = Async::HTTP::Internet.new
urls.each do |url|
semaphore.async do
# At most 3 of these run simultaneously
internet.get(url).read
end
end
end
When a task enters a semaphore beyond the limit, the semaphore yields control back to the reactor until a slot frees up. This gives you a clean way to rate-limit without managing a thread pool manually.
async queues for producer-consumer patterns
Async::Queue is a fiber-safe, reactor-aware queue. Producers push items and consumers pop them; consumers suspend when the queue is empty instead of polling:
require 'async'
queue = Async::Queue.new
producer = Async do
5.times do |i|
queue.push(i)
sleep 0.1
end
queue.close
end
consumer = Async do
while (item = queue.pop)
puts "got: #{item}"
end
end
The consumer suspends at queue.pop when there’s nothing to read, freeing the reactor to run the producer. When the producer closes the queue, pop returns nil and the loop exits.
timeouts that respect the scheduler
Ruby’s standard library Timeout.timeout uses a separate thread to raise an exception, which can interrupt the wrong fiber unpredictably when you’re using the Fiber Scheduler. The async gem provides Async::Timeout as a safe alternative:
require 'async'
Sync do
Async::Timeout.timeout(2) do
sleep 10
end
end
# After 2 seconds, the task is cancelled
Async::Timeout respects the fiber scheduler; it cancels only the targeted task at a safe yield point. This is significantly more reliable than Timeout in async Ruby code.
why fibers over threads?
The async gem is built on fibers rather than threads for good reasons. Fibers have stack sizes around 1 KB versus ~1 MB for threads, meaning you can have thousands of them in memory at once. Fibers are cooperatively scheduled. A fiber only yields at blocking calls, so there’s no risk of race conditions between fibers sharing the reactor. Threads, by contrast, are pre-emptively scheduled by the OS, requiring mutexes and careful synchronization for any shared state.
The tradeoff is that fibers only help with I/O-bound concurrency. If you need true parallel execution to saturate multiple CPU cores, threads are still the answer (and Ruby’s GVL limits even that). But for I/O-bound workloads; HTTP requests, database queries, file operations; async fibers give you far more concurrency per thread than threads ever could.
forward link
Once the async model feels familiar, compare it with the lower-level building blocks in the Ruby Threads Basics tutorial and the Ruby Fiber Basics tutorial. Seeing all three side by side makes it easier to decide whether you need threads, fibers, or a higher-level event loop for the task at hand.
If the workload is mostly I/O waits, the async gem is usually a good fit. If the job is CPU-heavy, a different concurrency model will usually be simpler and more effective.
summary
The async gem gives Ruby a lightweight way to handle many I/O-bound waits in a single thread. The reactor coordinates the work, the tasks stay small, and the higher-level primitives keep the concurrency readable. If your app waits on remote services more than it crunches CPU, async is worth a close look.
See Also
- Ruby Fiber Basics; learn how raw Ruby fibers work with
Fiber.yieldandFiber.resume - Ruby Threads Basics; pre-emptive threading,
Mutex, and thread pools in Ruby - Ruby Error Handling; exception propagation and handling in Ruby programs