Async Programming Concepts

Build a clear conceptual vocabulary for asynchronous programming — blocking vs. non-blocking I/O, event loops, and promises/futures — independent of any specific PHP library.

Before touching any specific async library, it pays to have solid, language-agnostic vocabulary for what "async" even means. This lesson is deliberately conceptual: it explains blocking versus non-blocking I/O, what an event loop actually does, and what a promise or future represents as an abstraction — including building a tiny toy Promise-like class in plain PHP so the concept has a concrete shape in your head. The libraries that implement these ideas for real (ReactPHP, Amp) are covered in depth in "Event-Driven PHP"; this lesson gives you the concepts those libraries are built out of.

Blocking versus non-blocking I/O

A blocking call is one where the calling code stops and waits until the operation completes before it can do anything else. file_get_contents('https://example.com') is blocking: the thread of execution is parked, doing nothing useful, for however long the network round-trip takes. This is the default behavior of almost every I/O function in PHP's standard library, and it's exactly why a classic PHP script handling one request at a time is fine — the web server (or FPM) simply runs many such scripts in parallel processes, so one blocked request doesn't stall another.

A non-blocking call instead returns immediately, before the operation is finished, handing back something you can check on later (a file descriptor to poll, a callback to invoke, or — as covered below — a promise/future). The actual waiting for data to arrive still happens, but it happens *without* parking the entire thread of execution: other work can run in the meantime on the same thread.

Non-blocking I/O only becomes useful once something is watching many pending operations at once and running whichever one becomes ready — which is precisely the job of an event loop.

Event loops, conceptually

An event loop is a single-threaded scheduler that runs forever, doing roughly this: check which pending I/O operations (sockets, timers, file handles) are ready; run the callback associated with each ready operation; repeat. Because it is a plain loop on a single thread, only one callback ever runs at a time — there is no true parallelism, only interleaving. The concurrency comes from the fact that while one operation is waiting on the network or disk, the loop is free to run other callbacks whose data has already arrived.

This is the mental model behind Node.js, and it's exactly the model that ReactPHP's and Amp's event loops bring to PHP. "Event-Driven PHP" walks through those specific implementations, how you register timers and stream watchers, and how ReactPHP and Amp differ in API style. Here, the point is only the shape: a loop, a set of watched things, and callbacks that fire when those things become ready.

• Single-threaded: no data races between callbacks, because only one callback body ever executes at a time. • Cooperative: a callback that blocks (e.g. calls a synchronous sleep() or a blocking file_get_contents()) freezes the *entire* loop, not just itself — this is the most common way to accidentally defeat an event loop's benefits. • Concurrency without parallelism: many operations are *in flight* at once, but CPU work never overlaps; the benefit is entirely about not wasting time waiting on I/O.

Promises and futures: a value that will exist later

A promise (sometimes called a future, depending on the language and library) is an object that represents the eventual result of an asynchronous operation. It exists so calling code doesn't need to block waiting for a result — instead of $result = doSlowThing() returning the answer directly, $promise = doSlowThingAsync() returns immediately, and code registers what should happen once the value shows up: $promise->then(fn ($result) => ...).

A promise is always in exactly one of three states: pending (no result yet), fulfilled (succeeded, holding a value), or rejected (failed, holding a reason). Once a promise settles (fulfills or rejects), it stays in that state forever and its callbacks fire exactly once — this is what makes promises composable: you can attach a callback before or after the promise settles and get consistent behavior either way.

PHP has no native promise type in the language (unlike JavaScript, where Promise is built in). ReactPHP and Amp each ship their own Promise/Future implementation, and PHP 8.1+'s native Fiber class (covered in the Fibers lessons and in the next lesson of this section) is what those libraries increasingly use *under the hood* to let code that looks synchronous actually suspend and resume around a promise. But the concept of "an object standing in for a not-yet-available value" is worth understanding on its own, separate from any one library's API.

A toy Promise class, for intuition only

Real async libraries integrate their promises with an event loop so that resolving one actually happens as a result of I/O becoming ready. The class below does none of that — it has no event loop, no I/O, nothing non-blocking about it at all. It exists purely so you can see the *shape* of the pending/fulfilled/rejected state machine and the callback-registration pattern in plain, synchronous PHP you can run and trace through line by line.

Promise state machine (conceptual shape)

This is the same three-state shape used by JavaScript's Promise, ReactPHP's PromiseInterface, and Amp's Future — only the API surface differs between them.

pending --resolve(value)--> fulfilled --> onFulfilled callbacks run once, in order
pending --reject(reason)---> rejected  --> onRejected callbacks run once, in order

Once fulfilled or rejected, the state never changes again.

Code Examples

The class stores its state, its settled value, and lists of callbacks waiting for that state. then() either runs a callback immediately (if already settled) or queues it (if still pending) — exactly the composability guarantee real promise libraries provide. There is deliberately no event loop and no I/O here; a real library resolves promises like this one as a *side effect* of the event loop noticing that a socket or timer became ready. Expected result: Got total: 42 public function then(callable $onFulfilled, ?callable $onRejected = null): void

Common mistake: Do not mistake this toy class for something you should use in real code — it has no relationship to any I/O and blocks exactly like normal PHP, because resolve() is called synchronously by hand.

<?php
declare(strict_types=1);

final class ToyPromise
{
    private const PENDING = 'pending';
    private const FULFILLED = 'fulfilled';
    private const REJECTED = 'rejected';

    private string $state = self::PENDING;
    private mixed $value = null;
    private array $onFulfilled = [];
    private array $onRejected = [];

    public function resolve(mixed $value): void
    {
        if ($this->state !== self::PENDING) {
            return; // a settled promise never changes state again
        }
        $this->state = self::FULFILLED;
        $this->value = $value;
        foreach ($this->onFulfilled as $callback) {
            $callback($value);
        }
    }

    public function reject(mixed $reason): void
    {
        if ($this->state !== self::PENDING) {
            return;
        }
        $this->state = self::REJECTED;
        $this->value = $reason;
        foreach ($this->onRejected as $callback) {
            $callback($reason);
        }
    }

    public function then(callable $onFulfilled, ?callable $onRejected = null): void
    {
        if ($this->state === self::FULFILLED) {
            $onFulfilled($this->value);
            return;
        }
        if (
…

Blocking call vs. event-loop + promise

Blocking:
  code -> [ waiting for network ......... ] -> code continues
          (nothing else can run meanwhile)

Non-blocking + event loop + promise:
  code -> kick off request, get a pending promise, return immediately
  event loop -> runs OTHER callbacks while waiting
  event loop -> notices response arrived -> calls promise.resolve(data)
  registered ->then() callback runs with the data

Important Rules

Non-blocking I/O is only beneficial when paired with something (an event loop) that can do other work while waiting — non-blocking calls with nothing else scheduled provide no advantage by themselves.

A promise/future can only ever settle once; treat 'fulfilled' and 'rejected' as permanent, terminal states.

A single blocking call inside event-loop code stalls the entire loop, not just that one operation — this is the number one way async code accidentally becomes synchronous.

Where this vocabulary shows up

Once you have these three concepts (blocking vs non-blocking, event loop, promise/future) clearly separated, reading ReactPHP or Amp documentation becomes much easier: ReactPHP's React\Promise\Promise and Amp's Future are concrete implementations of exactly the state machine described here, wired up to their respective event loops. "Event-Driven PHP" covers those concrete APIs and how to register timers and stream watchers with each library.

Believing that calling an async-style function automatically makes the underlying work run in parallel or on another CPU core.

Understand that a single event loop is single-threaded: concurrency comes from interleaving many waiting operations, not from parallel execution.

Event-loop concurrency helps enormously with I/O-bound waiting (network, disk, database round-trips) but does nothing for CPU-bound work — a tight computational loop still blocks the whole event loop just as a blocking I/O call would.

What does it mean for an I/O call to be 'non-blocking'? A. It completes faster than a blocking call at the network level B. It returns immediately without waiting for the operation to finish, letting other work run in the meantime C. It runs on a separate CPU core automatically D. It never fails or raises an error

B. It returns immediately without waiting for the operation to finish, letting other work run in the meantime — Non-blocking means the calling code is not parked waiting; it gets control back immediately and other work can proceed while the operation completes in the background.

Once a promise/future has settled (fulfilled or rejected), what happens if you call resolve() or reject() on it again? A. It overwrites the previous result with the new one B. It throws a fatal error in all implementations C. It has no effect — a settled promise's state and value are permanent D. It queues the new value to be delivered after the first one

C. It has no effect — a settled promise's state and value are permanent — A defining property of the promise/future abstraction is that once settled, the state is final; further resolve/reject calls are no-ops, which is what makes attaching callbacks before or after settlement behave consistently.

Extend ToyPromise with a static all(array $promises): ToyPromise method that resolves with an array of all values once every input promise has fulfilled, or rejects as soon as any one of them rejects. Hint: Track a counter of remaining pending promises and a results array sized to match the input; call each input promise's then(), decrementing the counter on fulfillment and resolving the outer promise when it reaches zero.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.