Concurrency¶
Kinetis\Async is a thin wrapper over Revolt — the
AMPHP v3 event loop — not a hand-rolled reactor. PHP Fibers alone are just
cooperative coroutines; they need an event loop scheduling I/O around them,
and Revolt is the PHP ecosystem’s de facto standard for that. Nothing in
this page is Kinetis reinventing an event loop — it’s a small, deliberately
thin layer of primitives built on Revolt’s suspend/resume pattern.
The suspend/resume pattern¶
Every non-blocking primitive in Kinetis\Async is built the same way:
capture the currently-running Fiber, register a Revolt watcher for
whatever condition you’re waiting on, then suspend — and let the watcher’s
callback resume the fiber once that condition is met.
use Revolt\EventLoop;
$fiber = Fiber::getCurrent();
EventLoop::onReadable($stream, static function (string $watcherId) use ($fiber): void {
EventLoop::cancel($watcherId);
$fiber?->resume();
});
Fiber::suspend();
Timer::delay() is the simplest concrete example — a Fiber-suspending
delay with nothing to read or write, useful mainly as a deterministic way
to prove concurrency actually overlaps, without depending on real network
timing in a test:
use Kinetis\Async\Timer;
Timer::delay(0.05); // suspends this Fiber for 50ms, without blocking the process
Important
What this buys you, precisely. Every primitive on this page provides
concurrency within the scope of what’s currently executing — most
concretely, several independent pieces of one request’s own work (see
concurrently() below). It does not, by itself, let one worker thread
serve a second, unrelated incoming HTTP request while the first is
suspended waiting on I/O — under FrankenPHP worker mode specifically,
each worker thread processes one request fully before picking up the
next; cross-request concurrency there comes from the number of worker
threads, not from this suspend/resume mechanism. See Runtime Adapters’s
“Sizing FrankenPHP’s worker threads” section for what that means in
practice and how to size for it.
Socket — non-blocking TCP¶
connect()/read()/write() suspend the calling Fiber while the
underlying stream isn’t ready, rather than blocking on it — the worker is
free to run other Fibers, or process other watchers, for however long that
takes. Calling any of these methods outside a Fiber is a programming
error — there’s nothing to resume — and surfaces as PHP’s own
FiberError, not a silent hang. In practice that means calling Socket
from inside a concurrently() task (below), which is what actually
supplies the Fiber — a bare top-level Socket::connect(...) call with no
surrounding Fiber will throw:
use Kinetis\Async\Socket;
use function Kinetis\Async\concurrently;
[$response] = concurrently([
function (): string {
$socket = Socket::connect('example.com', 80);
$socket->write("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n");
return $socket->read(4096);
},
]);
Warning
This is why Kinetis’s database clients (see Persistence) aren’t
built on PDO, ext-mysqli, or ext-pgsql. A blocking call has no point
where it can hand control back to other work, so wrapping one in a Fiber
doesn’t make it non-blocking — it blocks the entire worker process just
as hard, only less visibly, defeating concurrently()’s whole purpose.
concurrently() — running tasks side by side¶
use Amp\Redis\RedisClient;
use Kinetis\Http\Attributes\Get;
use Kinetis\Persistence\Contract\MysqlLink;
use Kinetis\QueryBuilder\Query;
use function Kinetis\Async\concurrently;
final readonly class OrderController
{
public function __construct(
private MysqlLink $db,
private RedisClient $redis,
) {}
#[Get('/orders/{id}/summary')]
public function summary(int $id): array
{
[$order, $itemCount, $views] = concurrently([
fn () => new Query($this->db)->table('orders')->where('id', '=', $id)->first(),
fn () => new Query($this->db)->table('order_items')->where('order_id', '=', $id)->count(),
fn () => $this->redis->get("order:{$id}:views"),
]);
return ['order' => $order, 'itemCount' => $itemCount, 'views' => (int) $views];
}
}
A database row, a database count, and a Redis read — three independent round trips that would otherwise run one after another — complete together, in roughly the time the slowest one alone takes.
Each task runs in its own Fiber, drawn from a pool of resident
workers (Kinetis\Async\FiberPool) that park between tasks instead of
terminating. That reuse isn’t a micro-optimization: constructing a
Fiber allocates a whole C stack and destroying it frees one, and under
FrankenPHP’s threaded worker mode those mmap/munmap cycles serialize
every worker thread in the process against the kernel’s address-space
lock — on an 8-vCPU host, resident reuse measures roughly 3× the
throughput of per-task construction on a 20-query fan-out route. The
pool is per PHP thread, holds only idle Fibers (a task suspended on I/O
keeps its Fiber to itself until it finishes), and none of it is visible
in the API: you write plain closures, exactly as above.
While tasks are in flight, the caller waits on a Revolt suspension that the last task to finish resumes — the event loop drives every suspended task no matter how many times each one suspends internally, or in what order they finish.
Every task runs to completion — successfully or not — before any
exception is allowed to surface. A failing task doesn’t abort the others
still in flight; if one or more failed, the first failure (in $tasks
order) is rethrown only once everything has finished:
try {
concurrently([
fn () => $slowButSuccessful(),
fn () => throw new RuntimeException('this one fails'),
]);
} catch (RuntimeException $e) {
// $slowButSuccessful() still ran to completion — it just wasn't
// allowed to abort the other task's own error from surfacing.
}
Three 50ms Timer::delay() calls run through concurrently() complete in
well under 100ms total, not the ~150ms+ a sequential fallback would take —
because they genuinely overlap rather than merely appearing to.
Note
Nesting is supported. A task may itself call concurrently() — the
inner call suspends only its own task’s Fiber while the inner tasks run,
and everything else keeps making progress. Prefer a single flat
concurrently() call when the work is naturally one batch — a flat
list is easier to reason about and slightly cheaper — but nesting that
falls out of composition, such as a helper that fans out internally
being called from a task, is correct and safe.
Composing across clients¶
Kinetis’s database drivers (see Persistence) and the Redis client
(amphp/redis, chosen because it’s Revolt-native) all wait by suspending
the calling Fiber on the same underlying Revolt loop — the native
Postgres driver through a real socket watcher, the native MySQL driver
through its poll bridge, Redis through Amp\Future internally. Different
API shapes, one loop: a concurrently() call can freely mix tasks built
on any of them and still run every one genuinely in parallel — a MySQL
query, a Postgres query, and a Redis command issued together complete in
roughly the time the slowest one alone takes, not the sum of all three.
See also¶
Persistence — the database drivers and Redis client described above, and
TransactionGuard, the request-lifecycle safety net built specifically because connection pools have no concept of Kinetis’sRequestScope.Performance tuning — the worker-threads x connections budget, what to observe under load, and tuning by workload shape.