skip to content

You are building a PHP chat server on an event-loop library such as Revolt, Amp or ReactPHP; how do Fibers let each connection handler call a plain-looking read while other connections keep being served?

level: seniorimportance: should knowfreq 22%

answer

  1. one fiber per connection
  2. non-blocking sockets via stream_set_blocking
  3. register readability, then suspend
  4. the loop waits on all streams at once
  5. readiness callback resumes the fiber

basics

~20 s

Each connection's handler runs in its own fiber. When a read would wait, the library registers the socket with the event loop and suspends that fiber; the loop waits on every socket at once and resumes the fiber whose socket became readable.

solid answer

~50 s

The library puts every socket in non-blocking mode and runs each connection handler inside its own `Fiber`. When the handler calls the library's read and no data is available, the library registers a readability callback for that socket with the event loop, then suspends the current fiber (in Revolt through a suspension object, which calls `Fiber::suspend()`). Control returns to the loop, which waits on all registered sockets together, with `stream_select()` or an extension-backed driver. When a socket becomes readable, its callback resumes the waiting fiber, and the read returns data as if it had blocked. Two production rules follow: never call a blocking function such as PDO or `sleep()` in a handler, because it freezes every connection, and never suspend a library-managed fiber with a raw `Fiber::suspend()`, because the loop would have no way to resume it.

code

php · 25 lines
php
<?php
declare(strict_types=1);
final class MiniLoop
{
    private static array $readers = []; // id => [stream, Fiber]
    public static function awaitReadable($stream): void
    {
        self::$readers[(int) $stream] = [$stream, Fiber::getCurrent()];
        Fiber::suspend();                  // park this connection
    }

    public static function run(): void
    {
        while (self::$readers !== []) {
            $read = array_column(self::$readers, 0);
            $write = $except = null;
            stream_select($read, $write, $except, null); // wait on all
            foreach ($read as $stream) {
                [, $fiber] = self::$readers[(int) $stream];
                unset(self::$readers[(int) $stream]);
                $fiber->resume();          // continue after awaitReadable()
            }
        }
    }
}

go deeper

for a junior

Recall that each connection runs in a fiber that pauses while its socket has no data, and an event loop resumes it when data arrives.

for a middle

Explain the sequence: non-blocking socket, readability registration, Fiber::suspend(), a select-style wait on all sockets, then resume() from the readiness callback.

for a senior

Enforce the rules that keep such a server alive: no blocking clients, no raw suspends in library-managed fibers, and a Throwable boundary around every handler.

for a principal

Weigh a long-running fiber-based server against share-nothing request handling: connection counts and latency favour it, but the non-blocking client ecosystem and operational discipline become the cost.

## The problem a chat server has A chat server keeps hundreds of client sockets open, and each client is idle most of the time. Classic PHP handles one request per process and blocks on every read, so one process per connection would be needed. An **event loop** instead waits on all sockets at once in a single process. The catch is that loop-based code is traditionally written as callbacks. **Fibers** let libraries keep the loop but give each connection straight-line code. ## How the pieces fit together In a library built on fibers, such as **Revolt** (the loop Amp v3 runs on) or **ReactPHP**'s async package, the flow for each connection is: 1. The listening socket and every accepted client socket are switched to **non-blocking mode** (`stream_set_blocking($stream, false)` in core terms), so a read with no data returns immediately instead of waiting. 2. Each connection's handler runs in **its own fiber**. With Amp that is what `async()` does; ReactPHP's async package offers the same through its own `async()` and `await()` functions. 3. The handler calls a read method. If data is buffered, it returns at once. 4. If not, the library **registers interest** with the loop (Revolt: `EventLoop::onReadable($stream, $callback)`), then **suspends the current fiber**. Revolt wraps this in a suspension object from `EventLoop::getSuspension()`; inside a fiber, its `suspend()` calls `Fiber::suspend()` underneath. 5. Control returns to the **loop**, which waits on every registered socket with one system call: `stream_select()` when no event extension is installed, or a driver built on an extension such as ev, event or uv. 6. When a client's socket becomes readable, the loop invokes that socket's callback, which **resumes** the waiting fiber. The read inside the handler now returns the data, and the handler continues on its next line. From the handler's point of view, `$line = $conn->read()` simply blocked; in reality the process served other clients the whole time. ## Why fibers instead of promise chains | Style | Handler code | Error handling | |---|---|---| | Callbacks / promise chains | split into `then()` steps per wait | errors passed along the chain | | Generators with `yield` | readable, but every layer must `yield` | exceptions via `Generator::throw()` | | Fibers | ordinary functions, ordinary return types | ordinary `try`/`catch`; the loop uses `Fiber::throw()` to deliver I/O errors | ## The production rules - **No blocking calls in a handler.** `sleep()`, PDO, mysqli, `file_get_contents()` on a URL and blocking cURL calls hold the single thread; while one runs, the loop cannot resume any other fiber, so every chat client freezes. Use the library's non-blocking clients. - **Do not suspend library-managed fibers yourself.** A raw `Fiber::suspend()` inside a handler hands control back to the loop, but no callback knows to resume that fiber, so the connection hangs forever. Wait through the library's primitives (suspensions, futures, deferreds). - **Catch at the handler boundary.** An exception that escapes a handler surfaces wherever its result is awaited or, if nobody awaits it, in the loop's error handling, where it can stop the whole server; wrap each connection handler in `try`/`catch (Throwable)` and close only that socket. - **Mind the per-fiber stack.** Each fiber reserves a native stack of `fiber.stack_size` (2 MiB by default on 64-bit builds). On Linux that memory is mapped lazily, so idle connections cost less than the reservation suggests, but deep recursion inside a handler hits the limit sooner than on the main stack. - **Remember there is still one thread.** Broadcasting to thousands of clients or encoding large payloads is CPU work; it delays every other connection until it finishes. ## Where Fiber::getCurrent() fits A library must know whether the code asking to wait is inside a fiber at all. `Fiber::getCurrent()` answers that: it returns the running `Fiber`, or `null` in the main script. Inside a fiber the library records that fiber and suspends it. In the main script there is nothing to suspend, so libraries instead run the loop right there until the awaited result arrives. The toy loop below skips that case: calling its `awaitReadable()` outside a fiber would store `null` and then throw `FiberError` from `Fiber::suspend()`. ## The mechanism in plain PHP The examples build the same idea with only core functions: a tiny loop class that parks fibers per socket and resumes them after `stream_select()`, and a chat server that broadcasts each message. Real libraries add timers, write buffering, cancellation and error handlers on top, but the suspend-on-wait, resume-on-ready core is exactly this.

  • A handler in this server calls PDO to store each chat message. What do other clients notice, and what is the fix?
    While the query runs, the process's only thread is blocked inside PDO, so the loop cannot resume any other fiber and every client stalls for the query's duration. Replace PDO with a database client built for the same loop, which speaks the protocol over non-blocking sockets and suspends, or hand the write to a separate worker process through a queue.
  • Why must a handler wait through the library's suspension or future instead of calling Fiber::suspend() directly?
    The loop resumes a fiber only when some registered callback, timer or future completion tells it to. A bare `Fiber::suspend()` registers nothing, so control goes back to the loop and nobody ever calls `resume()` on that fiber: the connection hangs and its stack stays allocated until the fiber object is destroyed.

saying these in an interview costs you the question

  • The event loop runs each connection handler on its own thread
  • Wrapping a PDO call in a Fiber makes it non-blocking
  • Handlers should call Fiber::suspend() directly to yield to the loop
  • Inside a fiber, every PHP function call yields to the loop automatically
  • Broadcasting is free because fibers spread it across cores