skip to content

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%

answer

  1. Async { } creates a reactor if needed
  2. each task is a non-blocking fiber
  3. Sync runs the block, waits for it
  4. task.wait returns the result
  5. CPU work and blocking C calls stall all

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.

solid answer

~50 s

The async gem (2.x, Ruby 3.3+) provides a Fiber scheduler plus a task API. At the top level, `Async { |task| ... }` creates a reactor, installs it as the thread's scheduler, runs the block as a **task** (a non-blocking fiber) and returns when all work is done; inside a task it starts a child task and returns it. `Sync { ... }` runs its block in the current task, creating a reactor if needed, and returns the block's value. A chat server starts `Async { handle(client) }` per connection; `client.gets` looks blocking but calls `io_wait`, so idle connections cost fibers, not threads. What still stalls **every** connection: CPU-bound work, C extensions that block outside Ruby's IO layer, and libraries relying on thread-locals. Move those to a thread (`Thread.new { ... }.value` waits without blocking the loop) or a process.

code

ruby · 12 lines
ruby
require "async"

result = Sync do
  greeting = Async { sleep 0.5; "hello" }   # child task; returns when it first sleeps
  count    = Async { sleep 0.5; 42 }
  [greeting.wait, count.wait]              # ~0.5 s total, not 1 s
end
result # => ["hello", 42]

Sync do
  Thread.new { 30.times.sum { |i| i ** 20 } }.value # waits without blocking the reactor
end

go deeper

for a junior

Recall that Async runs code as lightweight tasks on one thread and that waiting on sockets lets other tasks run.

for a middle

Explain Async at the top level versus inside a task, Sync's return value, task.wait, and why idle connections cost fibers rather than threads.

for a senior

Show you can find what stalls a reactor, such as CPU work, blocking C drivers or thread-local state, and isolate it with threads, processes or fiber-aware libraries.

for a principal

Decide whether a fiber-based stack fits a service, weighing connection counts, library compatibility and one reactor per process against thread pools.

## What async adds on top of Fiber Ruby provides fibers and a scheduler **interface**. The async gem provides the implementation: `Async::Scheduler`, an event loop built on the io-event gem, plus a task API. Version 2.x requires Ruby 3.3 or later. Its entry points are two methods it adds to `Kernel`: `Async` and `Sync`. ## Async `Async { |task| ... }` behaves differently depending on where it is called: - **At the top level** (no task and no scheduler): it creates a reactor, which installs itself with `Fiber.set_scheduler`, runs the block as the first task, keeps running until every task has finished, clears the scheduler, and returns. - **Inside a task**: it creates a **child task** and runs it eagerly until the child's first blocking operation (or its end), then returns the `Async::Task` to the caller, which continues concurrently with the child. A task is a non-blocking fiber with extra bookkeeping: a parent, children, a result and a status. `task.wait` suspends the caller until the task finishes and returns the block's value, re-raising its exception if it failed. `task.cancel` stops it. ## Sync `Sync { |task| ... }` is for code that wants to **use** async but return a value synchronously: - inside a task it simply yields to the block in the current task; - with no reactor it creates one, runs the block, waits for it, and returns its value. Library methods written with `Sync` work both from plain scripts and from inside an application that already runs a reactor. ## Thousands of chat sockets ```ruby require "async" require "socket" Async do server = TCPServer.new(9000) loop do client = server.accept # parks this task until a connection Async do # one task per connection while (line = client.gets) # io_wait while the client is idle client.write("echo: #{line}") end ensure client.close end end end ``` Each idle connection is a parked fiber with a small stack instead of a native thread; the event loop's selector (for example epoll or kqueue) wakes only the fibers whose sockets are ready. The handler reads like blocking code, with no callbacks. To bound fan-out, async offers `Async::Semaphore.new(limit)` (`semaphore.async { ... }` waits for a free slot) and `Async::Barrier` (`barrier.async { ... }` then `barrier.wait` for all). ## What still blocks every connection All tasks on a reactor share one thread, and switches happen only at operations the scheduler intercepts. These stall the whole loop: | Cause | Why | Remedy | |---|---|---| | CPU-heavy Ruby (parsing a large payload, rendering) | never reaches a hook | a thread or another process | | a C extension that blocks in its own code (some database drivers) | Ruby's IO layer never sees the wait | a fiber-aware driver, or `Thread.new { ... }.value` | | `Fiber.new(blocking: true)` or `Fiber.blocking { }` around slow work | blocking fibers bypass the hooks | remove the blocking scope | | libraries keeping state in `Thread.current[]` | every task has its own fiber-local store | fiber storage (`Fiber[]`) or explicit arguments | The async guide recommends a background thread for libraries that are blocking, processor-bound, or keep execution state in thread-locals: inside a task, `Thread.new { work }.value` waits through the scheduler, so the loop keeps serving other connections while the thread runs. ## Waiting and failures - `task.wait` returns the block's value; if the task failed with a `StandardError`, `wait` raises it in the waiter. - A cancelled task's `wait` returns `nil`. - `task.cancel` cancels a task and all of its children. - `Async::Barrier#wait` waits for every task started through the barrier. - `task.wait_all` waits for a task's children recursively. For a chat server, rescue per connection inside each connection's task, so one misbehaving client ends only its own task. ## Thread safety Most methods of a reactor and its tasks are not thread-safe. The usual deployment is one reactor per thread or per process, not tasks shared between threads.

  • In async, what is the difference between calling Async inside a task and calling Sync inside a task?
    `Async` creates a child task, runs it until its first blocking operation, and returns the task, so the caller continues concurrently and later calls `wait` for the result. `Sync` runs the block right away in the current task and returns its value, adding no concurrency. `Sync` is for code that must also work outside a reactor.
  • A handler in an async chat server runs a 300 ms JSON transform. What happens to other connections, and what do you do?
    For those 300 ms no other task on the reactor runs, because pure Ruby computation never reaches a scheduler hook; every connection's latency jumps. Move the work off the loop: run it in a `Thread` and call `value` (which waits through the scheduler), use a separate worker process, or split the work into smaller pieces.

saying these in an interview costs you the question

  • Async tasks run on multiple cores in parallel.
  • Every database driver automatically becomes non-blocking under async.
  • Async inside a task waits for the whole block to finish before returning.
  • Callbacks are needed to read a socket under async.
  • Thread.new { }.value inside a task blocks the whole reactor.