惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
人人都是产品经理
人人都是产品经理
大猫的无限游戏
大猫的无限游戏
博客园 - 三生石上(FineUI控件)
量子位
腾讯CDC
The Cloudflare Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
云风的 BLOG
云风的 BLOG
Vercel News
Vercel News
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
L
LangChain Blog
aimingoo的专栏
aimingoo的专栏
The Hacker News
The Hacker News
T
The Exploit Database - CXSecurity.com
B
Blog
S
SegmentFault 最新的问题
P
Privacy & Cybersecurity Law Blog
T
Threatpost
博客园 - 聂微东
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
C
Check Point Blog
N
Netflix TechBlog - Medium
D
DataBreaches.Net
爱范儿
爱范儿
IT之家
IT之家
S
Secure Thoughts
M
MIT News - Artificial intelligence
NISL@THU
NISL@THU
C
Cisco Blogs
TaoSecurity Blog
TaoSecurity Blog
有赞技术团队
有赞技术团队
A
Arctic Wolf
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Proofpoint News Feed
Spread Privacy
Spread Privacy
Schneier on Security
Schneier on Security
Simon Willison's Weblog
Simon Willison's Weblog
G
GRAHAM CLULEY
雷峰网
雷峰网
Project Zero
Project Zero
博客园 - Franky
H
Heimdal Security Blog
A
About on SuperTechFans
Security Latest
Security Latest
Webroot Blog
Webroot Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Hugging Face - Blog
Hugging Face - Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Thrun: как я устал от Horizon и написал свой многопоточный воркер с корутинами
YanGusik · 2026-06-25 · via Все публикации подряд на Хабре

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