LaravelЧто нельзя делать в корутине

Что нельзя делать в корутине

Первые главы чинили конкретные утечки состояния: auth/session контекстом, счётчик транзакций трейтом. laravel-spawn закрыл эти дыры за вас. Но фреймворк большой, сторонних пакетов ещё больше, а завтра кто-то в команде допишет свой сервис. Нужно правило, по которому можно на глаз отличить код, безопасный для конкурентных корутин, от кода, который однажды подсунет одному пользователю данные другого.

Правило одно: не пишите в static после старта

Все примеры ниже — частные случаи одной и той же ошибки: где-то записывается значение в память, разделяемую всеми корутинами процесса, и это значение относится к одному конкретному запросу.

Мутабельное static-свойство сервиса.

php
// ❌ Опасно
class PriceCalculator
{
    private static array $cache = [];

    public function forProduct(int $id): float
    {
        return self::$cache[$id] ??= $this->computeExpensive($id);
    }
}

Выглядит как безобидное мемо. На деле — это общий на все корутины массив. Если computeExpensive() зависит хоть от чего-то, что меняется между запросами (валюта пользователя, региональная наценка), первый запрос решает судьбу кэша для всех последующих. Лечится просто: свойство экземпляра вместо static, а сервис резолвится на запрос (тот самый «подход первый» из первой главы).

А если сервис — ваш собственный, и per-request состояние нужно осознанно? Тогда необязательно ждать, пока laravel-spawn заведёт для него ScopedService и прокси, — это тот же приём, что во второй главе решал проблему счётчика транзакций, только уровнем выше: не coroutine_context() одной корутины, а request_context(), общий для всего дерева корутин запроса.

php
class PriceCalculator  // остаётся синглтоном
{
    private const CTX_CACHE = 'price.cache';

    public function forProduct(int $id): float
    {
        $cache = request_context()->find(self::CTX_CACHE) ?? [];

        return $cache[$id] ??= $this->computeExpensive($id, $cache);
    }
}

Разница с прошлым примером на первый взгляд незаметна, а на деле принципиальна: массив живёт не в static-свойстве класса, общем на весь процесс, а в контексте scope конкретного запроса. Два конкурентных запроса вызовут один и тот же экземпляр PriceCalculator, но каждый получит свой собственный $cache, потому что request_context() для них — разные scope. Подробный разбор coroutine_context() и request_context() — во второй главе.

once() на синглтоне.

php
// ❌ Опасно
class CurrentUserService  // зарегистрирован как singleton
{
    public function get()
    {
        return once(fn() => Auth::user());  // закэширует ПЕРВОГО же пользователя
    }
}

once() кэширует результат замыкания в WeakMap, где ключ — объект, которому принадлежит вызов. Для синглтона этот объект один на весь процесс, значит и кэш один. Первый запрос вычислил пользователя, все следующие получают его же. На per-request объектах (контроллеры, Eloquent-модели) once() совершенно безопасен, потому что там и объект новый на каждый запрос.

Глобальная мутация вроде Number::useLocale().

php
// ❌ Опасно
Number::useLocale('de');
$price = Number::format(1234.5);       // а если корутина уснёт до этой строки?

// ✅ Безопасно
$price = Number::format(1234.5, locale: 'de');

useLocale() меняет статическую переменную класса Number. Между вызовом useLocale() и format() может случиться await, delay(), любой поход в базу — то есть точка, где планировщик передаст управление другой корутине. Если та корутина тоже вызовет Number::format() без явной локали, она получит locale, которую только что выставил кто-то другой. Явный параметр locale: убирает саму возможность гонки: не нужно ничего защищать, потому что нечего делить.

Суперглобальные переменные. $_GET, $_POST, $_SERVER, $_SESSION в PHP-FPM были безопасны просто потому, что жили один запрос. В корутинном воркере это переменные, общие для всего процесса, и обновляет их сервер, а не PHP при каждом новом соединении так, как вы привыкли. Используйте объект Request, который laravel-spawn и так изолирует через current_context(), — суперглобальные оставьте в покое.

Когда static на самом деле безопасен

Важно не удариться в другую крайность и не объявить любой static преступлением. Безопасны:

  • readonly static — если значение не меняется после инициализации, делить его между корутинами не опаснее, чем делить константу.
  • Boot-time конфигурацияstatic, который выставляется один раз при старте воркера и после этого только читается (маршруты, скомпилированные шаблоны, зарегистрированные макросы).
  • Детерминированные кэши — если результат зависит только от входных аргументов и не зависит от «текущего» запроса (например, кэш Str::camel() для одной и той же строки всегда один и тот же), гонка за право его заполнить не портит данные — в худшем случае кто-то вычислит значение дважды.
  • Монотонные счётчики без семантики — auto-increment alias вроде внутреннего счётчика уникальных SQL-алиасов: даже если два запроса выхватят одно и то же число, коллизия не производит неверные данные, максимум менее красивый алиас.

Разница везде одна: делится ли результат вычисления, не зависящий от запроса, или делится состояние, принадлежащее конкретному запросу. Первое — оптимизация. Второе — утечка.

Статический анализ вместо тщательного чтения

Вручную пролистывать каждый сторонний пакет в поисках private static без readonly — работа, которую с удовольствием отдают линтеру. В пакете есть PHPStan-правило именно под эту задачу:

php
final class MutableStaticPropertyRule implements Rule
{
    public function getNodeType(): string
    {
        return Property::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        if (! $node->isStatic() || $node->isReadonly()) {
            return [];
        }

        // ... сообщение "потенциальная утечка состояния между корутинами"
    }
}

Правило простое до примитивности: находит все static-свойства без модификатора readonly и подсвечивает их. Ложных срабатываний будет много — как раз те самые «безопасные» случаи из списка выше, — но это осознанный компромисс: пропустить реальную утечку дороже, чем один раз вручную разобрать список кандидатов.

bash
phpstan analyse app/ --configuration=phpstan.neon
phpstan analyse vendor/some/package/src --configuration=phpstan.neon

Прогон правила по самому фреймворку Laravel даёт больше трёх сотен находок. Подавляющее большинство — как раз безопасный static из предыдущего раздела: скомпилированные кэши BladeCompiler, флаги конфигурации, задаваемые один раз при boot(), резолверы, которые внутри себя обращаются к уже отизолированному $app['request']. Разобрать триста строк один раз — четверть часа работы. Не разобрать ни одной и наткнуться на утечку в проде — часы отладки чужого отчёта «я вижу профиль другого пользователя».

Итоговое правило

Перед тем как оставить static (или синглтон с мутабельным свойством) в коде, который выполняется в обработчике запроса, задайте один вопрос: переживёт ли это значение конец текущего запроса, оставаясь корректным для следующего? Если да — оно безопасно. Если оно должно устареть вместе с запросом, а физически остаётся жить в общей памяти процесса — это кандидат на ScopedService, request_context()/coroutine_context() (см. вторую главу) или обычное свойство экземпляра, пересоздаваемого на запрос.

Свой код и стандартный Laravel разобрали. Но в реальном проекте рядом обычно живут spatie/laravel-permission, Telescope, Inertia, Debugbar — у каждого своя история мутабельного состояния. Кто из них уже адаптирован, а кого стоит выключить в async-режиме — тема следующей главы.