LaravelЧого не можна робити всередині корутини

Чого не можна робити всередині корутини

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

Одне правило: не пишіть у static після старту

Кожен приклад нижче окремий випадок однієї й тієї самої помилки: щось записує значення в пам'ять, спільну для всіх корутин процесу, а це значення належить одному конкретному запиту.

Мутабельна статична властивість сервісу.

php
// Небезпечно
class PriceCalculator
{
    private static array $cache = [];

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

Виглядає як безневинна мемоізація. Насправді це масив, спільний для всіх корутин. Якщо computeExpensive() залежить від будь-чого, що змінюється між запитами, валюти користувача, регіональної націнки, перший запит вирішує долю кешу для всіх наступних. Виправлення просте: властивість екземпляра замість static, із сервісом, що резолвиться на кожен запит ("підхід перший" з першого розділу).

А якщо сервіс ваш власний і вам справді потрібен стан на рівні запиту? Не обов'язково чекати, поки 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);
    }
}

Різниця з попереднім прикладом здається невеликою, але вона принципова: масив уже не живе на статичній властивості класу, спільній для всього процесу, він живе в контексті області одного конкретного запиту. Два конкурентних запити викликають той самий екземпляр PriceCalculator, але кожен отримує власний $cache, тому що request_context() резолвиться в різну область для кожного з них. Детальніше про coroutine_context() і request_context() йшлося в другому розділі.

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

php
// Небезпечно
class CurrentUserService  // зареєстрований як синглтон
{
    public function get()
    {
        return once(fn() => Auth::user());  // кешує ПЕРШОГО користувача назавжди
    }
}

once() кешує результат замикання в WeakMap, ключем до якого є об'єкт, якому належить виклик. Для синглтона цей об'єкт один на весь процес, тож і кеш один. Перший запит обчислює користувача, кожен наступний отримує того самого. На об'єктах, що створюються на кожен запит (контролери, моделі 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: повністю прибирає можливість перегонів: нема чого захищати, бо нема чого ділити.

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

Коли static насправді безпечний

Так само важливо не впадати в іншу крайність і не оголошувати кожен static злочином. Ось безпечні випадки:

  • readonly static, якщо значення ніколи не змінюється після ініціалізації, ділити його між корутинами не небезпечніше, ніж ділити константу.
  • Конфігурація часу завантаження, static, встановлений один раз під час старту воркера і надалі лише читаний (маршрути, скомпільовані шаблони, зареєстровані макроси).
  • Детерміновані кеші, якщо результат залежить лише від вхідних аргументів, а не від "поточного" запиту (наприклад, кеш Str::camel() для заданого рядка завжди той самий рядок), перегони за його заповнення не псують дані, у гіршому разі щось обчислюється двічі.
  • Монотонні лічильники без семантики, автоінкрементний псевдонім на кшталт внутрішнього лічильника для унікальних 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 [];
        }

        // ... повідомлення "potential state leak between coroutines"
    }
}

Правило просте, наскільки це можливо: воно знаходить кожну властивість 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, кожен зі своєю історією мутабельного стану. Який із них уже адаптований, а який варто вимкнути в асинхронному режимі, тема наступного розділу.