Привет, меня зовут YanGusik. Я работал во многих PHP-проектах и в большинстве случаев в них использовался Horizon, Messenger, изредка RoadRunner.
В одной компании был высоконагруженный сервис с большим количеством внешних API-запросов. Сотни тысяч задач в очереди, каждая из которых делала несколько HTTP-запросов к внешним сервисам, ждала ответа, принимала решение, снова делала запрос. Типичная I/O-нагрузка.
Поначалу всё крутилось на Laravel Horizon — стандартный выбор для Laravel-проектов. Но чем больше рос проект, тем больше накапливались «мелкие» трения.
Что раздражало в Horizon
Horizon это зрелый, продуманный инструмент, но у него есть нюансы, которые в высоконагруженном проекте становятся настоящей болью:
Мелкие, но утомляющие
timeout — параметр-иллюзия.
Если внутри job есть Http::timeout(60) или $client->request() с явным таймаутом, то реальное время выполнения job определяет HTTP-клиент, а не Horizon. Формально timeout есть, фактически — job работает столько, сколько решит Guzzle. Это не баг, это особенность работы pcntl, но она неочевидна и ведёт к тому, что параметр timeout в конфиге создаёт ложное ощущение контроля.
ShouldBeUnique без uniqueFor — тихая мина. Если job зависнет и процесс упадёт, Redis-ключ уникальности останется вечно. Следующий dispatch молча проигнорируется — задача просто не встанет в очередь. Обнаруживается это обычно уже в продакшене, когда начинаешь разбираться почему данные не обновляются, а это очень критично для бизнеса.
retry_after меньше timeout — двойная обработка. Если retry_after меньше фактического времени выполнения job, задача будет взята в работу повторно, пока первая ещё не завершилась. Получаем одну задачу, обрабатываемую дважды.
Всё это решаемо: абстрактный базовый класс, документация, внимательность. Но в реальном проекте ты держишь это в голове постоянно, и это... приводит к ошибкам.
Фундаментальные ограничения
Масштабирование только через процессы. I/O-bound задача занимает воркер на всё время ожидания HTTP-ответа. Единственный способ поднять throughput — добавить воркеры. Каждый воркер — отдельный PHP-процесс, отдельный контейнер Laravel, отдельные мегабайты RAM. При большом количестве параллельных I/O-задач это становится расточительным: 12 воркеров × ~85–125 MB = больше гигабайта только на инфраструктуру очередей.
Horizon — это Laravel-first, и не более того. Основным producer задач является сам Laravel. Если у вас микросервисная архитектура и один из сервисов написан на Go или Python — отправить задачу в Horizon-очередь превращается в отдельный квест. Формат сериализации жёстко завязан на PHP: в Redis лежит сериализованный PHP-объект, который другой язык не прочитает. Новички по привычке кидают в payload жирные модели вместо ID сущности, и в Redis начинают накапливаться килобайтные записи. Всё это — следствие архитектуры, где очередь проектировалась как внутренний механизм Laravel, а не как язык-нейтральный транспорт.
Здесь уже не вопрос внимательности — это архитектурные ограничения, которые не решаются аккуратной настройкой.
Подход Thrun: потоки и сопрограммы
Thrun Laravel использует принципиально иную модель выполнения. Вместо использования множества рабочих процессов, он построен на основе TrueAsync (ядро PHP, реализующего истинную асинхронность на уровне движка, но теперь с потоками).
Thrun использует:
Один основной процесс.
Несколько реальных потоков операционной системы.
Планирование на основе сопрограмм внутри каждого потока.
Модель выполнения:
Один процесс ├── OS-поток 1 │ ├── корутина: job A → HTTP-запрос → ждём... │ ├── корутина: job B → HTTP-запрос → ждём... │ └── корутина: job C → обработка ответа → готово └── OS-поток 2 ├── корутина: job D → DB-запрос → ждём... └── ...
Это позволяет одному рабочему процессу обрабатывать сотни задач одновременно, не блокируя операции ввода-вывода. Цель проста: максимальная пропускная способность при минимальном потреблении памяти в условиях высокопараллельных рабочих нагрузок.
Проблема timeout в Thrun решается иначе, чем в Horizon. Если задача ожидает HTTP-ответ, выполнение запроса к БД, файловую операцию, находится в
sleep()или иным образом передала управление планировщику, она может быть остановлена по истечении timeout. На практике это означает, что timeout действительно ограничивает время жизни I/O-задач, а не создаёт лишь иллюзию контроля.Исключение составляют CPU-bound задачи. Если внутри job выполняется бесконечный цикл или длительные вычисления без точек переключения контекста, принудительно остановить такую задачу невозможно до тех пор, пока она сама не вернёт управление планировщику.
Бенчмарки
Scenario | Config | Jobs | Time | Throughput | RSS |
|---|---|---|---|---|---|
Horizon IO | 12 workers | 1,000 | 12.1s | 83/s | 872 MB |
Thrun IO | 1 thread, 100 coroutines | 1,000 | 2.3s | 434/s | 80 MB |
Horizon IO | 12 workers | 10,000 | 55.0s | 182/s | 1019 MB |
Thrun IO | 1 thread, 100 coroutines | 10,000 | 6.3s | 1580/s | 84 MB |
Horizon CPU | 12 workers | 100 | 18.4s | 5.4/s | 1022 MB |
Thrun CPU | 12 threads | 100 | 16.3s | 6.1/s | 100 MB |
Horizon CPU | 12 workers | 1,000 | 162.6s | 6.2/s | 1023 MB |
Thrun CPU | 12 threads | 1,000 | 139.5s | 7.2/s | 101 MB |
Horizon NOOP | 12 workers | 1,000 | 5.0s | 198/s | 656 MB |
Thrun NOOP | 12 threads | 1,000 | 2.3s | 434/s | 103 MB |



На I/O-bound нагрузке Thrun в ~8.6× быстрее Horizon и потребляет в ~10× меньше памяти при 10 000 задач.
На CPU-bound задачах прирост скромнее (~15% по скорости), но память всё равно в 10× меньше — там TrueAsync задействует реальные OS-потоки — ОС сама распределяет их по ядрам.
Архитектура Thrun
Главное решение при проектировании: никакой магии.
В Horizon много неявного конфигурация через свойства класса, поиск параметров через Reflection, разное поведение одних и тех же настроек в разных местах. Я хотел, чтобы в Thrun каждый параметр был явным и понятным из самого кода.
Два стиля написания задач
Стиль 1: Message + Handler (рекомендуется)
Чистое разделение: Message — это DTO с данными, Handler — инвокабельный класс с логикой. Всё как в Symfony Messenger.
// Message — только данные #[Queue('emails')] #[Retry(backoff: [1000, 2000, 4000], maxAttempts: 3)] #[Delay(5000)] final readonly class SendEmailMessage { public function __construct( public string $to, public string $subject, ) {} } // Handler — только логика #[AsThrunHandler] // автоматически связывается с SendEmailMessage final class SendEmailHandler { public function __construct(private MailerInterface $mailer) {} public function __invoke(SendEmailMessage $message, Acknowledger $ack): void { $this->mailer->send($message->to, $message->subject); $ack->ack(); } }
Dispatch:
use Thrun\Laravel\Bus\ThrunMessageBus; use Thrun\Laravel\Bus\DispatchOptions; // Базовый вариант $bus->dispatch(new SendEmailMessage('user@test.com', 'Hello')); // С переопределением параметров $bus->dispatch( new SendEmailMessage('user@test.com', 'Hello'), 'emails', new DispatchOptions(delayMs: 10_000, messageId: 'email-42'), ); // Через fluent builder $bus->builder() ->id('email-42') ->retry([1000, 2000], 3) ->delay(5000) ->timeout(30000) ->send(new SendEmailMessage('user@test.com', 'Hello'), 'emails');
Стиль 2: Self-handling Job
Один класс — и данные, и логика. Для простых задач, когда не хочется создавать лишние файлы.
#[ThrunJob] // Self-handling job #[Queue('emails')] #[Retry(backoff: [1000, 2000, 4000], maxAttempts: 3)] final readonly class SendEmailJob { public function __construct( public string $to, public string $subject, ) {} public function __invoke(MailerInterface $mailer, Acknowledger $ack): void { $mailer->send($this->to, $this->subject); $ack->ack(); } }
Сервисы и зависимости инжектируются через DI в
__invoke().Важное правило: конструктор принимает только скалярные данные или простые readonly-DTO.
Явные атрибуты вместо свойств
Весь контракт задачи виден прямо в объявлении класса:
#[Queue('emails')] // очередь #[Retry(backoff: [1000, 2000, 4000], maxAttempts: 3)] // политика повторов #[Delay(5000)] // задержка в мс #[Timeout(30000)] // жёсткий таймаут в мс
Никакого $tries = 3, $timeout = 30, backoff() в отдельном методе, и никаких расхождений между конфигами.
Acknowledger — явное подтверждение
public function __invoke(MyMessage $message, Acknowledger $ack): void { // ... логика ... $ack->ack(); // подтвердить успех // $ack->fail(new \Exception()); // отклонить // $ack->retry($delayMs); // отклонить и повторить }
Явное ack() вместо неявного «если не упало — значит успешно». Полный контроль над жизненным циклом сообщения.
Cross-language интеграция
Это одна из вещей, которую я хотел сделать правильно с самого начала.
Thrun использует простой JSON-конверт в Redis — никакого сериализованного PHP. Любой сервис на любом языке может пушить задачи напрямую:
Из Yii3:
$envelope = new Envelope( message: ['message' => 'hello from Yii3'], routeKey: 'yii3_hello' ); $serializer = new JsonSerializer(new ClassMapMessageTypeResolver()); $payload = $serializer->serialize($envelope); $redis->rPush('thrun:queue:ready', $payload);
Из Go:
message := map[string]any{ "body": map[string]any{ "message": "hello from Go", }, "headers": map[string]any{ "type": "array", "route_key": "go_handler", }, } data, _ := json.Marshal(message) rdb.RPush(ctx, "thrun:queue:ready", data)
Вот как выглядит payload в Redis — чисто и без PHP-специфики:
{ "body": { "message": "hello from Yii3" }, "headers": { "type": "array", "route_key": "yii3_hello", "message_id": null, "stamps": [] } }
Регистрация handler на стороне Laravel — через атрибут или через конфиг:
// Через атрибут #[AsThrunHandler('yii3_hello')] final class HelloYiiHandler { public function __construct(private TestService $testService) {} public function __invoke(array $payload, Acknowledger $ack): void { $this->testService->process($payload['message']); $ack->ack(); } }
// Или через config/thrun.php 'handlers' => [ 'yii3_hello' => App\Handlers\HelloYiiHandler::class, 'go_handler' => App\Handlers\GoOrderHandler::class, ],
Thrun автоматически смапит входящий JSON-payload на аргументы handler'а. Никаких дополнительных адаптеров, никаких общих схем.
RPC-сервер и события
В Thrun встроен RPC-сервер, который запускается вместе с воркером. Он предоставляет лёгкую pub/sub систему поверх RPC-сокета.
Emit события из handler'а:
$this->rpc->emit('order.completed', ['order_id' => $message->orderId]);
Listener:
#[ThrunEventListener('order.completed')] final class OrderCompletedListener { public function __invoke(array $payload): void { // ... } }
Поддерживаются wildcard-подписки: payment.*, * для всех событий. События ephemeral — fire-and-forget, без персистентности.
RPC server служит не только для событий
Также RPC сервер позволяет пушить задачи в memory очереди, отправлять и регистрировать подписку на имя события не только php сервисам.
Протокол обмена (Wire Protocol)
Все RPC-взаимодействие использует простой бинарный формат с префиксом длины:
[4 bytes BE uint32 — payload length][1 byte — frame type][N bytes — JSON payload]
Type | Byte | Direction | Purpose |
|---|---|---|---|
|
| client → server | Поместить задачу в локальную очередь в памяти |
|
| client → server | Отправить событие всем подписчикам |
|
| client → server | Зарегистрировать подписку на имя события или шаблон |
|
| client → server | Синхронный RPC-вызов, ожидающий ответ |
|
| server → client | Ответ на |
|
| server → client | Сообщение об ошибке |
Конфигурация
Количество потоков стоит держать в пределах логических ядер сервера — ОС умеет переключать потоки, но при превышении начинается лишняя конкуренция. Точное распределение зависит от вашей нагрузки: HTTP-сервер редко утилизирует 100% CPU постоянно, поэтому Thrun можно не ограничивать жёстко. Для высоконагруженных систем оптимальный вариант — разнести HTTP и воркеры на разные ноды.
Пример конфигурации:
'supervisors' => [ 'default' => [ 'queues' => ['emails', 'notifications'], 'worker' => [ 'threads' => 6, // 6 потоков 'concurrency' => 100, // 100 корутин ], ... ], 'heavy_cpu' => [ 'queues' => ['video_processing'], 'worker' => [ 'threads' => 6, // 6 потоков 'concurrency' => 0, // корутины в cpu bound не нужны ], ... ], ... ],
Как попробовать
composer require yangusik/thrun-laravel php artisan vendor:publish --tag=thrun-config php artisan thrun:work
Либо скачать demo_project
git clone https://github.com/YanGusik/thrun_laravel_example.git cd thrun_laravel_example docker compose up -d docker compose exec app php artisan thrun:benchmark:dispatch io 10 docker compose exec app php artisan thrun:email:dispatch 1 docker compose exec app php artisan thrun:order:dispatch 1
Требования:
PHP (TrueAsync Core) ^8.6
ext-async (TrueAsync extension)
ext-phpredis (TrueAsync fork) (если используете redis-transport)
Итог
Thrun показывает чего может достичь PHP с многопоточностью и конкуретностью. Без необходимости переходить на Go или Питон, вы можете использовать хорошо знакомый код. Потоки в PHP позволяют серьёзно экономить память для таких больших фреймворков как Laravel, и игнорировать это не стоит.
Главное, чем я хотел отличиться — предсказуемость. Каждый параметр в атрибутах, явный acknowledger, строгие скалярные payload, открытая конфигурация supervisors, JSON-конверт понятный любому языку. Чтобы поведение системы читалось из кода, а не вычислялось из комбинации трёх конфигов и двух версий документации.
Я рассказал небольшую часть из того, что может thrun, поэтому если интересно посмотрите примеры здесь: https://github.com/yangusik/thrun
Буду рад фидбеку и PR:
thrun_laravel — Laravel-адаптер
thrun — Thrun Core
TrueAsync — PHP- ядро
