
Всё началось с отклонённого RFC.
В 2025 году в PHP промелькнул RFC про нативные корутины в PHP. Сообщество контрибьютеров-консерваторов посмотрело, покивало, и благополучно его завернуло — как это обычно и происходит со всем интересным в PHP. Я тогда подумал «жаль, а было бы интересно увидеть это в PHP…» и забыл.
Спустя полгода я случайно наткнулся на репозиторий — оказалось, автор не забросил идею и всё это время продолжал над ней работать.
TrueAsync — это альтернативное ядро PHP, которое реализует настоящую асинхронность путём модификации Zend-ядра, библиотек I/O, работы с БД и сокетами.
И когда вышла первая более-менее рабочая версия, у меня возникла очевидная мысль: а что если попробовать запустить на этом Laravel?
Спойлер: Laravel к такому не был готов…
Что такое TrueAsync и при чём тут корутины
Но прежде чем переходить к тому как я ломал Laravel — пара слов про то, что вообще такое корутины и зачем они нужны. (Если вы это знаете — листайте дальше)
Корутина — контекст, который может быть приостановлен буквально в любой точке.
В том числе внутри С-функции. Просто кусок кода который говорит планировщику «я жду ответа от IO, займись пока чем-нибудь другим». Планировщик переключается на другую корутину, та делает своё, потом возвращается к первой когда ответ пришёл.
Важная особенность TrueAsync: прозрачная асинхронность без цветных функций.
Если вы работали с Node.js или Python asyncio, вы знаете эту боль стоит одной функции стать async, и весь стек вызовов выше неё тоже обязан стать async.
Это называют проблемой цвета функций.
В TrueAsync этого нет: обычные функции PHP работают асинхронно без каких-либо изменений в их коде. PDO::query(), файловые операции, сокеты — всё прозрачно уступает управление планировщику в момент ожидания.
Вот как это выглядит:
use function Async\spawn;use function Async\await;use function Async\delay;$a = spawn(function() { // имитируем запрос в БД 100мс +- delay(100); return 'результат A';});$b = spawn(function() { delay(100); return 'результат B';});// Важно: в отличие от Go тут нет паралельности. // Паралельность требует несколько потоков.// А у нас код всегда в 1 потоке. // Поэтому код переключается, но за 1 раз работает только 1 корутина.// общее время — ~100мс, не 200мсecho await($a); // 'результат A'echo await($b); // 'результат B'

Важный момент который часто путают — это не многопоточность!
Один поток, один процесс. Корутины не работают буквально одновременно — они переключаются в моменты ожидания I/O.
Поэтому:
Хорошо работает там где есть ожидание: запросы в БД, HTTP-запросы к внешним API, чтение файлов — всё что связано с ожиданием ответа от внешней системы. Пока одна корутина ждёт — другая работает.
Не поможет с: вычисления на CPU, в простонародье (CPU-bound).
Если у тебя тяжёлый алгоритм или обработка больших файлов. Потоки в TrueAsync в разработке 0.7.0, но пока их нет.
Для типичного веб-приложения — API с запросами в БД этого должно быть достаточно.
FrankenPHP и модель «один воркер — много запросов»
TrueAsync интегрируется с FrankenPHP в async-режиме. Воркер стартует, загружает приложение один раз, и дальше обрабатывает входящие запросы как корутины:
[воркер] ├── корутина: GET /api/orders → запрос в БД → ждём... ├── корутина: GET /api/users → запрос в БД → ждём... ├── корутина: POST /api/payments → запрос в БД → ждём... └── ...ещё 50 корутин в процессе
Пока все ждут ответа от PostgreSQL, планировщик гоняет их по кругу. PDO Pool прозрачно даёт каждой корутине своё физическое соединение с БД.
Звучит здорово. На практике оказалось, что сначала нужно объяснить это Laravel…
Проблема: Laravel не готов к такой жизни
Laravel проектировался под FPM-модель: один процесс = один запрос = умер. Stateful-сервисы живут на весь запрос и дальше выбрасываются вместе с процессом.
В async-модели процесс не умирает. Один воркер обрабатывает тысячи запросов подряд. И если Auth::user() где-то внутри хранит залогиненного пользователя в свойстве объекта — этот объект переживёт запрос и попадёт к следующему. Хорошо для синхронного кода, но плохо для нас.
Вот простой пример того как это ломается:
// Корутина A: запрос от пользователя user_1Auth::loginUsingId(1);delay(200); // ждём чего-нибудь// Корутина B (запускается пока A ждёт): запрос от пользователя user_2 Auth::loginUsingId(2);// Возвращаемся в корутину Aecho Auth::id(); // 2. Привет, user_1. Ты теперь user_2.
AuthManager хранит guard в свойстве, guard хранит пользователя. Один объект на всех.
То же самое с сессиями, с View::share(), с локалью переводчика, с роутером который запоминает текущий маршрут, с конфигом если кто-то его перезаписывает в runtime.
Изначально я хотел взять за основу Octane, т.к он поддерживает swoole,roadrunner и т.д
Но Octane решает это клонированием контейнера на каждый запрос: аля clone $app.
Работает, но:
-
Клонировать контейнер с сотнями байндингов на каждый запрос — это накладные расходы
-
Статические свойства PHP-классов (
Model::$dispatcher,Facade::$resolvedInstance) вообще не в контейнере — clone их не трогает, Octane вынужден вести ручной список того что сбрасывать (очень много listeners). -
PDO всё равно синхронный — настоящей конкурентности нет
Нам нужен другой подход.
Решение:
TrueAsync даёт current_context() — хранилище данных привязанное к текущему Scope (жизненному циклу запроса). Когда Scope запроса завершается — контекст автоматически уничтожается. Никакого ручного сброса.
По сути Laravel делится на две части:
-
Статическое ядро — роутер, конфиг, IoC-контейнер, сервис-провайдеры. Загружается один раз при старте воркера, живёт вечно, общее для всех запросов.
-
Per-request объекты — AuthManager, сессия, cookie. Создаются для каждого запроса в своём Scope, уничтожаются когда запрос завершён.
Идея: вместо того чтобы клонировать весь контейнер, перехватываем резолвинг конкретных сервисов и смотрим в контекст текущей корутины:
// Упростил Внутри AsyncApplication::resolve()protected function resolve($abstract, ...){ // 'auth', 'session', 'cookie', 'auth.driver' - scoped сервисы if ($this->asyncMode && $this->isScoped($abstract)) { $ctx = current_context(); // есть в контексте этой корутины - возвращаем if ($instance = $ctx->find($abstract)) { return $instance; } // нет - создаём, кладём в контекст $instance = $this->buildFresh($abstract); $ctx->set($abstract, $instance); return $instance; } return parent::resolve($abstract, ...);}
Каждая корутина получает свой экземпляр AuthManager, свою сессию, свой cookie-jar. Контейнер один, клонирования нет.
Ключи контекста — не строки а enum
enum ScopedService: string { case REQUEST = 'request'; case SESSION = 'session'; case AUTH = 'auth'; case AUTH_DRIVER = 'auth.driver'; case COOKIE = 'cookie'; }
Это даёт три вещи сразу.
-
Изоляция по доступу.
Данные в контексте фактически приватны для того кода, который владеет enum-ом. Сторонний пакет не может случайно прочитать объект сессии или подменить request, у него нет ScopedService::SESSION. -
Никаких коллизий.
Два разных enum-а с одинаковым строковым значением — разные ключи.ScopedService::REQUESTиSomeOtherEnum::REQUESTникогда не пересекутся, даже если оба backed-значения = ‘request’. -
Статический анализ.
IDE и PHPStan видит все точки использования каждого enum-case .
Поиск ScopedService::AUTH мгновенно показывает все места, где читается или пишется auth-состояние. Со строками такой гарантии нет.
Проблема с Facades
Facades кешируют resolved instance в статическом массиве:
// Упрощённо, Illuminate\Support\Facades\Facadeprotected static function resolveFacadeInstance($name){ if (isset(static::$resolvedInstance[$name])) { return static::$resolvedInstance[$name]; // ← вот он, кэш } // ...}
Даже если контейнер правильно изолирует, Facade закеширует первый instance и будет отдавать его всем корутинам навсегда.
Решение — ScopedServiceProxy. Facade кеширует прокси один раз, а прокси при каждом вызове идёт в контекст текущей корутины:
// Facade кеширует этоclass ScopedServiceProxy{ public function __call($method, $args) { // каждый раз берём из контекста ТЕКУЩЕЙ корутины return ($this->resolver)()->$method(...$args); }}
Auth::user() → Facade достаёт прокси из кеша → прокси идёт в current_context() → достаёт AuthManager этой корутины → возвращает правильного пользователя.
Что пришлось адаптировать
После того как базовая изоляция заработала, выяснилось что отдельные сервисы Laravel хранят per-request состояние у себя внутри. Пришлось делать async-safe версии (узнавали по ходу тестирования):
-
AsyncRouter —
$currentи$currentRequestв свойствах объекта, течёт между корутинами -
AsyncDispatcher — состояние отложенных событий (defer) per-coroutine
-
AsyncTranslator — локаль (
setLocale) per-coroutine -
AsyncViewFactory —
View::share()per-coroutine -
AsyncConfig — runtime-изменения конфига per-coroutine
-
AsyncDatabaseSessionHandler — upsert вместо INSERT + catch + UPDATE (атомарность при concurrent записи одной сессии)
-
Async*Connection — счётчик транзакций
$transactionsper-coroutine (иначе concurrent beginTransaction() создаёт SAVEPOINT вместо BEGIN)
Ещё адаптеры для Spatie Permissions, Inertia, Socialite, Telescope — но это уже детали.
PDO Pool — и почему без него никуда
Корутины разделяют один процесс — а значит и все объекты внутри него. Если дать двум корутинам одно и то же соединение с БД, они начнут одновременно писать и читать из одного сокета.
На сетевом уровне пакеты перемешиваются, протокол ломается.
PostgreSQL в таких случаях честно ругается: “запрос ещё выполняется, а вы уже шлёте следующий”.
Решение очевидное — каждой корутине своё соединение. Но создавать новое TCP-соединение на каждый запрос дорого и бессмысленно. Нужен пул.
Раньше это было проблемой: Swoole, AMPHP, ReactPHP — каждый требовал использовать свой async-драйвер для БД. Стандартный PDO не работал, нужно было переписывать код под специфичные клиенты.
В TrueAsync PDO Pool встроен в ядро. Включается одной строкой, весь остальной код не меняется:
$pdo = new PDO($dsn, $user, $password, [ PDO::ATTR_POOL_ENABLED => true, // Включить пул PDO::ATTR_POOL_MIN => 0, // Минимум соединений (по умолчанию 0) PDO::ATTR_POOL_MAX => 10, // Максимум соединений (по умолчанию 10) PDO::ATTR_POOL_HEALTHCHECK_INTERVAL => 30, // HealthCheck (сек, 0 = выключено)]);
Пул работает умнее чем кажется.
Соединение возвращается не когда корутина завершилась, а как только оно больше не нужно — например, после SELECT и получения результата соединение сразу уходит обратно в пул.
Поэтому пул из 10 соединений может обслуживать больше чем 10 корутин одновременно.
Исключение — транзакции. Пока транзакция активна, соединение закреплено за корутиной до commit() или rollback()
Пара слов о производительности
asyncMode — это не просто флаг, а двухфазная инициализация.
Когда же Ларавел уже инициализирован и сервер стартует, активируется asyncMode, и тогда все данные уже не ложатся в общую память. Это касается например Ларавел конфига.
Таким образом мы снова экономим память где можно, а где нет — память честно делиться между запросами
ScopedServiceProxy добавляет лишний уровень indirection на каждый вызов через Facade.
Да, это чуть медленнее. Замеры показали что overhead незначительный, но он есть.
Зато такой подход позволяет быстро адаптировать большой фреймворк не переписывая его целиком. А проблемы с производительностью — решать точечно, если они вообще появятся на реальной нагрузке.
А что по цифрам?
После тысячи правок и исправлений багов, решил сравнить производительность с octane.
Нагрузка: 1200 req/s (константная, constant-arrival-rate), два эндпоинта с реальными запросами в PostgreSQL, 30 секунд, 12 воркеров у каждого, тестировал через k6.
|
|
PHP-FPM |
Octane Swoole |
TrueAsync |
|---|---|---|---|
|
Реальный throughput |
~200 req/s |
~752 req/s |
~1118 req/s |
|
Пропущено итераций |
~28 000 |
~5 000 |
20 |
|
Средняя latency |
~4 000ms |
~880ms |
13ms |
|
p95 latency |
~5 000ms |
2 320ms |
21ms |
|
p95 < 200ms |
✗ |
✗ |
✓ |
|
Пиковых DB-соединений |
12 |
12 |
120 |
FPM упирается в количество процессов.
Swoole держит приложение в памяти, это помогает, но PDO блокирует воркер (12 соединений). TrueAsync при 1200 target реально обрабатывает 1118 и p95 = 21ms.
Важная оговорка: бенчмарки на WSL2, на bare metal числа будут выше. И это I/O-bound нагрузка — если у вас тяжёлые вычисления на CPU, прироста не будет.
Проверить тесты можно тут в ta_benchmark и проверить самому
Кстати, сам Swoole прогнал сравнение на чистом PHP без фреймворка и без I/O — на облачном сервере с 4 ядрами и 4GB RAM:
|
Метрика |
TrueAsync |
Swoole |
|---|---|---|
|
Throughput |
9 607 req/s |
9 918 req/s |
|
Avg latency |
13.28ms |
4.23ms |
|
Median latency |
8.4ms |
1.11ms |
|
P95 latency |
43.06ms |
19.01ms |
|
Max latency |
170.75ms |
69.92ms |
|
Max concurrent VUs |
535 |
315 |
На CPU-bound нагрузке Swoole быстрее по latency — сказывается накладной расход на Go и PHP границе в FrankenPHP. Разница в throughput около 3%.
На I/O-bound нагрузке этот overhead незаметен — корутина всё равно ждёт ответа от БД, и эти миллисекунды растворяются в задержке сети.
https://github.com/php/frankenphp/issues/1754#issuecomment-4215141347
Что получилось
AsyncServiceProvider делает всё автоматически — ставишь пакет, он регистрирует адаптеры, дальше пишешь Laravel как обычно. Auth::user(), session(), DB::transaction() — работают корректно в concurrent-среде без изменений в коде приложения.
Так же, можно зарегистрировать чужие библиотеки как scoped.
/* |-------------------------------------------------------------------------- | Scoped Services |-------------------------------------------------------------------------- | | Services listed here will be resolved per-coroutine instead of shared | as singletons. Use this for third-party packages that hold request state. | | Example: | \SomePackage\Manager::class, | */ 'scoped_services' => [],
Репозиторий: yangusik/laravel-spawn
Если используете Spatie Permissions, Inertia, Socialite, Debugbar или Telescope — адаптеры для них тоже есть из коробки.
Потоки в PHP TrueAsync пока в разработке — когда появятся, CPU-bound задачи тоже получат прирост. Пока это история про I/O.
Вы же, в свою очередь, можете помочь с тестированием и фидбеком, это очень сильно помогает выйти true-async в стабильный релиз.
Ссылки:
ссылка на оригинал статьи https://habr.com/ru/articles/1024762/