Skip to content

Asynchrony — never block the event loop

Flyokai is cooperative. Every HTTP handler, CLI command, request handler, and DI lifecycle hook runs inside a fiber managed by Revolt's event loop. Block one fiber and you stall everything.

The two rules

  1. Never use a blocking system call. No sleep(), usleep(), file_get_contents() for remote URLs, mysqli_query() without MYSQLI_ASYNC, curl_exec() without curl-multi, socket_* without non-blocking flags.
  2. Use Amp / Revolt primitives instead. They suspend the current fiber, let the loop run other work, and resume when the I/O is ready.

The conversion table

Blocking Async equivalent
sleep(N) Amp\delay(N)
usleep(N) Amp\delay(N / 1_000_000)
file_get_contents('https://…') (new Amp\Http\Client\HttpClientBuilder())->build()->request(new Request('https://…'))->getBody()->buffer()
file_get_contents('/path') Amp\File\read('/path')
file_put_contents('/path', $data) Amp\File\write('/path', $data)
flock($handle, LOCK_EX) Flyokai\AmpMate\ampFlock($handle, LOCK_EX) (cooperative retry)
mysqli_query($link, $sql) the framework Adapter — uses MYSQLI_ASYNC + EventLoop::onMysqli() under the hood
PDO::query() the framework Adapter — uses worker pools under the hood (AsyncPdoConnectionPool)
curl_exec() (new HttpClientBuilder())->build()->request(...)
socket_read() / socket_write() EventLoop::onReadable($fd, …) / EventLoop::onWritable($fd, …)

Concurrency idioms

Run two operations in parallel

use function Amp\async;
use function Amp\Future\await;

[$a, $b] = await([
    async(fn() => $repoA->load($id)),
    async(fn() => $repoB->load($id)),
]);

Apply backpressure with a semaphore

use Amp\Sync\LocalSemaphore;

$sem = new LocalSemaphore(8);   // max 8 concurrent

foreach ($urls as $url) {
    $lock = $sem->acquire();
    async(function () use ($url, $lock) {
        try { processUrl($url); }
        finally { $lock->release(); }
    });
}

Time out an operation

use Amp\TimeoutCancellation;

$response = $client->request($req, new TimeoutCancellation(5));

Stream-process a large dataset

Use flyokai/amp-data-pipeline:

use Flyokai\AmpDataPipeline\{ArraySource, ProcessorComposition};

$pipeline = new ProcessorComposition([
    new LoadProcessor(),
    new TransformProcessor(),
    new SaveProcessor(),
]);
$pipeline->setSource(new ArraySource($input));
$pipeline->run();

Each processor independently controls its fiber count (setConcurrency) and output buffer (setBufferSize).

Suspension API (low level)

When you need to integrate with a callback-based API:

use Revolt\EventLoop;

$suspension = EventLoop::getSuspension();

someAsyncApi(onComplete: fn ($result) => $suspension->resume($result),
              onError:    fn ($e)      => $suspension->throw($e));

$result = $suspension->suspend();   // current fiber pauses; loop runs other work

The MYSQLI_ASYNC integration in flyokai/laminas-db-driver-async is built on this primitive (EventLoop::onMysqli() + suspension).

Things that look async but aren't

  • sleep() inside a Closure passed to EventLoop::defer() — the closure still runs blockingly when its tick comes; it just runs later. The loop is still stuck while it sleeps.
  • file_get_contents() against a slow disk — magnetic disks under contention can stall fibers for seconds. On hot paths, prefer Amp\File.
  • Tight loops without yielding — even if you call Amp\delay(0) periodically, busy CPU work blocks the loop. Move heavy CPU work to Amp\Parallel\Worker.

Checking your code

To find blocking calls, grep for the usual suspects:

grep -rn -E '\b(sleep|usleep|fread|fgets|file_get_contents|file_put_contents|fopen|fclose|curl_exec|socket_(read|write))\b' --include='*.php' src/

Whitelist legitimate cases (CLI bootstrap before the loop runs, etc.).

Fiber vs {main}

  • EventLoop::run() is callable only from {main} — the top-level script that started the loop.
  • From inside a fiber (any handler / callback), use the Suspension API instead. Calling EventLoop::run() from a fiber re-enters the loop and corrupts state.

Error handling inside a fiber

  • try / catch works as you expect — exceptions propagate up the fiber's call stack.
  • Unhandled exceptions in deferred callbacks go to EventLoop::setErrorHandler(). If that handler throws, the loop stops immediately.
  • Amp\Future::await() re-throws the future's error in the calling fiber.

Diagnostics

  • Profiler: Flyokai\Misc\ProfilerFacade::start('label') / stop('label') — no-op when disabled.
  • Trace driver: set REVOLT_DRIVER_DEBUG_TRACE=1 to wrap the event-loop driver in a tracer that records callback registration sites.
  • Cluster IPC: cluster workers report via WatcherMessageProcessor; pipe the log to the coordinator for correlated tracing.