Привет, меня зовут 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

Бенчмарки Thrun и Horizon в разных сценариях
Бенчмарки Thrun и Horizon в разных сценариях
График потребления памяти Horizon при CPU bound
График потребления памяти Horizon при CPU bound
График потребления памяти Thrun при CPU bound
График потребления памяти Thrun при CPU bound

На 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

Job

0x01

client → server

Поместить задачу в локальную очередь в памяти

Event

0x02

client → server

Отправить событие всем подписчикам

Subscribe

0x03

client → server

Зарегистрировать подписку на имя события или шаблон

RpcRequest

0x04

client → server

Синхронный RPC-вызов, ожидающий ответ RpcReply

RpcReply

0x05

server → client

Ответ наRpcRequest

Error

0x06

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: