Concurrency¶
A request often needs several independent answers — a row, a count, a
cached value, an upstream call. concurrently() starts them together and
returns every result:
composer require kinetis/database-bridge kinetis/query-builder kinetis/cache-redis
use Kinetis\Http\Attributes\Get;
use Kinetis\Persistence\Contract\MysqlLink;
use Kinetis\QueryBuilder\Query;
use Psr\SimpleCache\CacheInterface;
use function Kinetis\Async\concurrently;
final readonly class OrderController
{
public function __construct(
private MysqlLink $db,
private CacheInterface $cache,
) {}
#[Get('/orders/{id}/summary')]
public function summary(int $id): array
{
/** @var array{object|array|null, int, mixed} $result */
$result = 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->cache->get("order.views.{$id}"),
]);
[$order, $itemCount, $views] = $result;
return ['order' => $order, 'itemCount' => $itemCount, 'views' => (int) $views];
}
}
concurrently() is declared @template T / @param list<callable(): T> $tasks / @return list<T>: one shared type parameter across every task.
Given heterogeneous closures, PHPStan resolves T to the union of all three
return types and reports every destructured element as that same union,
not as its own position’s type. The array{...} shape above states the
tuple PHPStan can’t infer on its own, so $order, $itemCount and
$views keep their real per-position types after destructuring.
Each closure is a task. Results come back in task order, whatever order
the tasks finish in. MysqlLink is bound by kinetis/database-bridge
from the DB_* keys (Database), and CacheInterface is bound
to Redis by kinetis/cache-redis from REDIS_* configuration
(Redis).
Under FrankenPHP worker mode or RoadRunner, the two queries and the Redis read overlap, and the call takes roughly as long as the slowest of them.
When the overlap is real¶
Tasks overlap only while each one waits through a client that yields to the event loop. A blocking call inside a task runs to completion before any other task continues.
A task waiting on |
FrankenPHP worker, RoadRunner |
PHP-FPM, AWS Lambda, CLI, queue workers, PHPUnit |
|---|---|---|
An injected |
Native driver: queries overlap on pooled connections |
One blocking PDO connection: queries run one after another |
|
Overlaps |
Overlaps |
|
Overlaps |
Overlaps |
|
Overlaps |
Overlaps |
A hand-built |
Blocks every task |
Blocks every task |
Warning
The example above returns the same result under PHP-FPM, but not in the
same time. DB_DRIVER=auto gives PHP-FPM one blocking PDO connection;
with the tasks ordered as shown, both queries finish before the Redis
read starts. Code that needs query overlap runs under a persistent worker.
Forcing DB_DRIVER=native under PHP-FPM builds and discards a connection
pool on every request — Performance tuning’s “What not to tune”
gives the cost.
concurrently() overlaps one request’s own work. It does not let a
worker take a second request while the first waits: a FrankenPHP thread
or a RoadRunner process serves one request at a time, and cross-request
capacity comes from the worker count — see Runtime Adapters’s
“Sizing FrankenPHP’s worker threads”.
When a task fails¶
Every task runs to completion, successfully or not, before an exception surfaces. A failing task does not abort the others; the first failure in task order is rethrown once all of them have finished:
try {
concurrently([
fn () => $slowButSuccessful(),
fn () => throw new RuntimeException('this one fails'),
]);
} catch (RuntimeException $e) {
// $slowButSuccessful() ran to completion before this was thrown.
}
A task may itself call concurrently(). The inner call suspends only its
own task while the inner tasks run. Prefer one flat call when the work is
naturally one batch; nesting that falls out of composition, such as a
helper that fans out internally, is correct.
Important
Tasks run on reused Fibers, and a Fiber that ran one task may run a task from a later request. Do not keep state keyed on the current Fiber, or attach Fiber-local state that outlives the task — see Fiber scheduling.
Timers and sockets¶
Timer::delay() and Kinetis\Async\Socket suspend the current Fiber
without blocking the process. Call them inside a Fiber, such as a
concurrently() task: outside one there is nothing to resume, and PHP
throws FiberError.
Timer::delay() is a deterministic way to show overlap in a test: three
50 ms delays through concurrently() finish in well under 100 ms.
use Kinetis\Async\Timer;
use function Kinetis\Async\concurrently;
concurrently([
fn () => Timer::delay(0.05),
fn () => Timer::delay(0.05),
fn () => Timer::delay(0.05),
]);
Socket is a non-blocking TCP client. connect(), read() and
write() suspend the current task while the stream is not ready.
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);
},
]);
concurrently() itself needs no surrounding Fiber, so a controller, a
console command or a test calls it directly.
Keeping application I/O non-blocking¶
Wrapping a blocking call in a Fiber or a concurrently() task does not
make it non-blocking. A blocking call has no point where it can hand
control back to the event loop, so it holds the worker, and every other
Fiber and watcher on its loop, until it returns: the other tasks of a
concurrently() call wait, and so does every deadline and timer the loop
enforces meanwhile. Only calls that wait on the loop, such as Socket,
Timer, and the clients in the table above, let the rest make progress.
Flagging blocking calls with PHPStan¶
Kinetis\Linting\NoBlockingIoRule is a PHPStan rule for application code.
It ships under the framework’s main autoload for the same reason
NoStaticPropertiesRule does (see Appendix: Container Lifecycle) and needs nothing
beyond PHPStan. kinetis/skeleton’s phpstan.neon registers both rules;
an existing project adds it to its own:
rules:
- Kinetis\Linting\NoBlockingIoRule
Every report carries the identifier kinetis.blockingCall and a message
naming the replacement for its category:
Category |
Reported |
Use instead |
|---|---|---|
Sleep |
|
|
Sockets |
|
|
curl waits |
|
|
Database connections |
|
an injected |
Child processes |
|
a separate short-lived or external process, or an audited short-lived console command — never a persistent worker, |
HTTP transport selection |
|
an explicitly injected Revolt-backed client, below |
curl_multi_exec() is not reported: it advances transfers without
waiting, and curl_multi_select() is the wait.
Names resolve the way PHP resolves them. An imported or aliased function
or class is reported under its real name, and an unqualified sleep()
inside a namespace that declares its own sleep() calls that function and
is not reported.
Injecting the HTTP transport¶
php-http/discovery returns the first supported client it finds
installed — Guzzle, a curl or socket adapter, Symfony’s. Symfony’s
HttpClient::create() prefers CurlHttpClient over AmpHttpClient
when ext-curl supports HTTP/2, even with amphp/http-client installed.
Guzzle’s default handler is curl, or PHP streams without it. None of
these is a Revolt-backed transport by guarantee, and which one a
deployment gets can change with an unrelated composer require or PHP
extension.
For calls the application makes itself, inject
Kinetis\RevoltHttpClient\Http (see HTTP Client). For a
library that takes a PSR-18 client, construct one around the
Revolt-backed transport and bind it:
use Kinetis\RevoltHttpClient\AmpHttpClientFactory;
use Psr\Http\Client\ClientInterface;
use Symfony\Component\HttpClient\Psr18Client;
$app->instance(ClientInterface::class, new Psr18Client(AmpHttpClientFactory::create()));
A Psr18Client from either package, or Symfony’s HttplugClient, given
a known non-null transport like the one above uses it and is not reported.
Audited exceptions¶
Code that never runs inside a persistent worker — an audited short-lived console command, a build script — can block without stalling other work. Exempt it with PHPStan’s standard ignores, by identifier and path, with the reason beside it:
parameters:
ignoreErrors:
# A short-lived CLI command; never runs inside a persistent worker.
-
identifier: kinetis.blockingCall
path: src/Console/ImportCommand.php
For a single line, put
/** @phpstan-ignore-next-line kinetis.blockingCall */ on the line before
it instead.
What the rule does not see¶
Dynamic calls. A function or class named by an expression —
$function(),new $class(),call_user_func('sleep', 1)— is not reported.Nullable client arguments. The rule treats the client argument of these adapters as intentional injection unless it is a literal
null. An expression that evaluates tonullat runtime still makes the client pick its own transport, so guard a nullable transport before passing it.Filesystem and DNS.
file_get_contents()andfopen()on a local path or a URL,gethostbyname(), and the name lookup inside connecting to a hostname all block, and none of them is reported: there is no universal non-blocking replacement for either. Storage serves local files through a Fiber-suspending adapter.Dependencies. PHPStan analyses the project’s own
paths, so a blocking call inside a vendor package is not reported.CPU-bound work. A long computation holds the loop exactly as a blocking call does.
A clean analysis is therefore not proof that a path keeps the loop responsive. Proving a path keeps the loop responsive observes that directly in a test.
See also¶
Database — driver selection, pools, and
TransactionGuard.Redis and HTTP Client — the non-blocking Redis and HTTP clients.
Performance tuning — the worker × connection budget and tuning by workload shape.
Telemetry — overlapping tasks as overlapping spans.
Appendix: Runtime Reference — Fiber scheduling, resident Fibers, and what each client waits on.