DEV Community

Cover image for Building JavaScript-Like `async` / `await` in PHP with Swoole and OpenSwoole
sebk69
sebk69

Posted on

Building JavaScript-Like `async` / `await` in PHP with Swoole and OpenSwoole

PHP does not have native JavaScript-style async and await syntax.

But with Swoole or OpenSwoole coroutines, we already have most of the runtime primitives needed to build something very close to it.

Recently, while working on small/swoole-patterns, I wanted an API that would let me write concurrent PHP code like this:

use Small\SwoolePatterns\Async\Async;
use function Small\SwoolePatterns\Async\async;
use function Small\SwoolePatterns\Async\await;

$result = Async::main(function (): array {
    $user = async(fn() => loadUser(42));
    $orders = async(fn() => loadOrders(42));

    return [
        'user' => await($user),
        'orders' => await($orders),
    ];
});
Enter fullscreen mode Exit fullscreen mode

The important part is that loadUser() and loadOrders() start immediately and concurrently.

await() does not start the operation. It only waits for an already-running asynchronous operation.

That distinction is what makes the model feel similar to JavaScript promises.

This article explains the architecture behind it.


The goal

I did not want to build another event loop.

Swoole and OpenSwoole already provide an efficient coroutine scheduler.

The goal was therefore much smaller:

Build a thin abstraction over coroutines that represents an eventual value.

The architecture ended up looking like this:

Application
    │
    ▼
Async::main()
    │
    ▼
Runtime::run()
    │
    ├───────────────┐
    ▼               ▼
 async()           async()
    │               │
    ▼               ▼
Future<T>       Future<T>
    │               │
    ▼               ▼
Coroutine       Coroutine
    │               │
    └───────┬───────┘
            ▼
          await()
            │
            ▼
       Channel::pop()
Enter fullscreen mode Exit fullscreen mode

There are three main concepts:

  • Future<T> represents a result that may not exist yet.
  • async() starts an operation in another coroutine.
  • await() suspends the current coroutine until the result becomes available.

The AwaitableInterface

The lowest-level abstraction is deliberately tiny:

namespace Small\SwoolePatterns\Async;

/**
 * @template-covariant T
 */
interface AwaitableInterface
{
    /**
     * @return T
     */
    public function await(?float $timeout = null): mixed;
}
Enter fullscreen mode Exit fullscreen mode

Anything implementing this interface can be awaited.

Today that means Future.

But this abstraction could later represent things such as:

HTTP responses
database queries
timers
signals
pool acquisitions
message queue operations
Enter fullscreen mode Exit fullscreen mode

without changing user code.


Implementing Future<T>

A Future has only three possible states:

enum FutureState
{
    case pending;
    case fulfilled;
    case rejected;
}
Enter fullscreen mode Exit fullscreen mode

Internally, the Future stores:

private FutureState $state = FutureState::pending;
private mixed $value = null;
private ?Throwable $exception = null;
private Channel $signal;
Enter fullscreen mode Exit fullscreen mode

The important architectural choice is that the Channel does not carry the result.

The result is stored directly inside the Future.

The Channel exists only as a completion signal.

That makes multiple awaiters possible.

$future = async(fn() => expensiveOperation());

async(fn() => consume(await($future)));
async(fn() => logResult(await($future)));
async(fn() => cache(await($future)));
Enter fullscreen mode Exit fullscreen mode

All three coroutines can await the same Future.

The value is calculated only once.


Why close the Channel?

When the Future resolves or rejects, we store the state first:

private function settle(
    FutureState $state,
    mixed $value,
    ?Throwable $exception,
): void {
    if ($this->state !== FutureState::pending) {
        return;
    }

    $this->state = $state;
    $this->value = $value;
    $this->exception = $exception;

    $this->signal->close();
}
Enter fullscreen mode Exit fullscreen mode

Closing the Channel wakes the waiting coroutines.

The Channel therefore behaves like a broadcast notification:

Future pending
      │
      ├── coroutine A waiting
      ├── coroutine B waiting
      └── coroutine C waiting
             │
             ▼
        Future settles
             │
             ▼
        Channel closes
             │
      ┌──────┼──────┐
      ▼      ▼      ▼
      A      B      C
Enter fullscreen mode Exit fullscreen mode

No polling loop is required.

No repeated usleep().

No result duplication through the Channel.


Implementing await()

The core implementation is quite small:

public function await(?float $timeout = null): mixed
{
    if ($timeout !== null && $timeout < 0) {
        throw new InvalidArgumentException(
            'Future timeout must be null or greater than or equal to 0.'
        );
    }

    if ($this->isPending()) {
        if (!Runtime::isInCoroutine()) {
            throw new AsyncException(
                'A pending Future can only be awaited inside Async::main() or Runtime::run().'
            );
        }

        $this->signal->pop($timeout ?? -1);

        if ($this->isPending()) {
            throw new TimeoutException(
                'Future did not complete before the timeout.'
            );
        }
    }

    if ($this->state === FutureState::rejected) {
        throw $this->exception;
    }

    return $this->value;
}
Enter fullscreen mode Exit fullscreen mode

If the Future has already completed:

$value = await($future);
Enter fullscreen mode Exit fullscreen mode

does not interact with the scheduler at all.

It essentially becomes:

check state
return value
Enter fullscreen mode Exit fullscreen mode

This is important because awaiting an already-resolved Future should be cheap.


Starting asynchronous work

The equivalent of invoking a JavaScript async function is Async::start().

Conceptually:

public static function start(
    Closure $callback,
    mixed ...$arguments,
): Future {
    $future = new Future(
        function (Closure $resolve, Closure $reject)
            use ($callback, $arguments): void {

            Runtime::create(
                function () use (
                    $callback,
                    $arguments,
                    $resolve,
                    $reject
                ): void {
                    try {
                        $result = $callback(...$arguments);
                        $resolve($result);
                    } catch (Throwable $exception) {
                        $reject($exception);
                    }
                }
            );
        }
    );

    return $future;
}
Enter fullscreen mode Exit fullscreen mode

Calling:

$future = async(fn() => queryDatabase());
Enter fullscreen mode Exit fullscreen mode

creates the coroutine immediately.

So:

$a = async(fn() => operationA());
$b = async(fn() => operationB());

$resultA = await($a);
$resultB = await($b);
Enter fullscreen mode Exit fullscreen mode

does not execute sequentially. Both operations are already running before the first await().


The coroutine boundary

A coroutine needs a scheduler, so we need an entry point:

Async::main(function () {
    // async code
});
Enter fullscreen mode Exit fullscreen mode

Internally it delegates to the library's runtime abstraction:

public static function main(Closure $callback): mixed
{
    return Runtime::run(
        static function () use ($callback): mixed {
            $result = $callback();

            return $result instanceof AwaitableInterface
                ? $result->await()
                : $result;
        }
    );
}
Enter fullscreen mode Exit fullscreen mode

There is still only one scheduler.

The library does not try to replace Swoole.


Promise adoption

If a Future resolves to another awaitable, the outer Future adopts the inner result:

$result = $callback(...$arguments);

if ($result instanceof AwaitableInterface) {
    $result = $result->await();
}

$resolve($result);
Enter fullscreen mode Exit fullscreen mode

That avoids leaking Future<Future<T>> into application code.


Exceptions behave like synchronous PHP

$future = async(function () {
    throw new RuntimeException('Database unavailable');
});
Enter fullscreen mode Exit fullscreen mode

The exception is captured by the Future and rethrown when awaited:

try {
    $result = await($future);
} catch (RuntimeException $exception) {
    echo $exception->getMessage();
}
Enter fullscreen mode Exit fullscreen mode

This keeps async control flow close to normal synchronous PHP.


Waiting for several Futures

Async::all() is similar to JavaScript's Promise.all():

$result = await(
    Async::all([
        'user' => async(fn() => loadUser()),
        'orders' => async(fn() => loadOrders()),
        'stock' => async(fn() => loadStock()),
    ])
);
Enter fullscreen mode Exit fullscreen mode

The result preserves both keys and ordering:

[
    'user' => $user,
    'orders' => $orders,
    'stock' => $stock,
]
Enter fullscreen mode Exit fullscreen mode

Async::all() provides one aggregate Future representing the whole group.


Timeout without cancellation

await() supports a timeout:

try {
    $value = await($future, timeout: 1.5);
} catch (TimeoutException) {
    // operation did not finish in 1.5 seconds
}
Enter fullscreen mode Exit fullscreen mode

A timeout does not cancel the Future.

The underlying coroutine continues running.

try {
    await($future, timeout: 0.1);
} catch (TimeoutException) {
    // continue doing something else
}

$result = await($future);
Enter fullscreen mode Exit fullscreen mode

Timeout and cancellation are intentionally separate concepts.


Why not implement then()?

It would be easy to add a promise-style API such as:

$future
    ->then(...)
    ->then(...)
    ->catch(...);
Enter fullscreen mode Exit fullscreen mode

But PHP already has excellent imperative exception handling:

try {
    $user = await($userFuture);
    $orders = await($orderFuture);

    return buildResponse($user, $orders);
} catch (Throwable $exception) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Once await() exists, callback chains are far less necessary.

More importantly, another promise scheduler would duplicate work already handled by Swoole.


Keeping the runtime lightweight

For:

$result = await(
    async(fn() => operation())
);
Enter fullscreen mode Exit fullscreen mode

the runtime structure is essentially:

1 Future
1 coroutine
1 Channel
1 stored value
1 optional exception
Enter fullscreen mode Exit fullscreen mode

There is no custom event loop, polling scheduler, worker thread, or callback chain.

Swoole handles concurrency.

The Future only models the eventual result.


Supporting both Swoole and OpenSwoole

The project supports both:

Swoole 6.2.x
OpenSwoole 26.2.x
Enter fullscreen mode Exit fullscreen mode

The async layer sits on top of a small runtime compatibility abstraction so application code does not need to know which engine is active.

The final test suite reached:

167 tests
441 assertions

Classes: 100%
Methods: 100%
Lines:   100%
Enter fullscreen mode Exit fullscreen mode

including the native PDO MySQL coroutine integration test.

A coverage issue appeared because the Swoole/OpenSwoole root coroutine implementations require different runtime paths. The selector was kept as a single executable statement so each runtime does not lose line coverage simply because it cannot execute the other runtime's branch:

$run ??= method_exists(Swoole\Coroutine::class, 'run') ? [Swoole\Coroutine::class, 'run'] : 'Swoole\\Coroutine\\run';
Enter fullscreen mode Exit fullscreen mode

The resulting developer experience

$result = Async::main(function () {

    $product = async(fn() => loadProduct());
    $stock = async(fn() => loadStock());
    $prices = async(fn() => loadPrices());

    return [
        'product' => await($product),
        'stock' => await($stock),
        'prices' => await($prices),
    ];
});
Enter fullscreen mode Exit fullscreen mode

Compare it with JavaScript:

const productPromise = loadProduct();
const stockPromise = loadStock();
const pricesPromise = loadPrices();

const result = {
    product: await productPromise,
    stock: await stockPromise,
    prices: await pricesPromise
};
Enter fullscreen mode Exit fullscreen mode

The syntax is different because PHP does not provide language-level await, but the execution model is very similar:

start operation
receive eventual result
continue doing work
await when needed
propagate exception normally
Enter fullscreen mode Exit fullscreen mode

What comes next?

The architecture leaves room for several extensions without changing the fundamental API:

  • race() for the first completed Future
  • cancellation
  • timeout decorators
  • concurrency-limited groups
  • adapting HTTP/database operations directly to AwaitableInterface

But the important foundation is already there.

A Future should remain boring.

It represents one thing:

A value that exists now or will exist later.

Swoole handles concurrency.

The Future handles the result.

await() connects the two.

And with those few primitives, PHP coroutine code starts feeling a lot more like modern asynchronous JavaScript.

Links

Repository : https://git.small-project.dev/lib/small-swoole-patterns
Packagist : https://packagist.org/packages/small/swoole-patterns


Tags: #php #swoole #openswoole #async

Top comments (0)