Zombie-корутины: толерантность к ошибкам

Проблема: код, который нельзя отменить

Отмена корутины — кооперативный процесс. Корутина получает исключение Cancellation в точке ожидания и должна корректно завершиться. Но, что, если кто-то ошибся и создал корутину не в том Scope? Хотя TrueAsync следует принципу Cancellation by design, могут возникать ситуации, когда кто-то написал код, отмена которого может привести к неприятной ситуации. Например, кто-то создал фоновую задачу для отправки email. Корутина отменилась, email не был послан.

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

Решение: zombie-корутины

Чтобы сгладить подобные ситуации TrueAsync предоставляет особый подход: толерантной обработки “зависших” корутин: зомби корутины.

Zombie-корутина — это корутина, которая:

$scope = new Async\Scope();

$scope->spawn(function() {
    thirdPartySync(); // Чужой код — не знаем, как он реагирует на отмену
});

$scope->spawn(function() {
    return myOwnCode(); // Наш код — корректно обрабатывает отмену
});

// disposeSafely() НЕ отменяет корутины, а помечает их как zombie
$scope->disposeSafely();
// Scope закрыт для новых корутин.
// Существующие корутины продолжают работать как zombie.

Три стратегии завершения Scope

TrueAsync предоставляет три способа закрытия Scope, рассчитанных на разную степень доверия к коду:

dispose() — принудительная отмена

Все корутины получают Cancellation. Scope закрывается немедленно. Используйте, когда вы контролируете весь код внутри Scope.

$scope->dispose();
// Все корутины отменены. Scope закрыт.

disposeSafely() — без отмены, корутины становятся zombie

Корутины не получают Cancellation. Они помечаются как zombie и продолжают работать. Scope считается закрытым — новые корутины создать нельзя.

Используйте, когда Scope содержит “чужой” код и вы не уверены в корректности отмены.

$scope->disposeSafely();
// Корутины продолжают работу как zombie.
// Scope закрыт для новых задач.

disposeAfterTimeout(int $timeout) — отмена с тайм-аутом

Комбинация двух подходов: сначала корутины получают время на завершение, затем Scope отменяется принудительно.

$scope->disposeAfterTimeout(5000);
// Через 5 секунд Scope отправит Cancellation всем оставшимся корутинам.

Ожидание zombie-корутин

awaitCompletion() ждёт только активные корутины. Как только все корутины стали zombie, awaitCompletion() считает Scope завершённым и возвращает управление.

Но иногда нужно дождаться завершения всех корутин, включая zombie. Для этого существует awaitAfterCancellation():

$scope = new Async\Scope();
$scope->spawn(fn() => longRunningTask());
$scope->spawn(fn() => anotherTask());

// Отменяем — корутины, которые не могут быть отменены, станут zombie
$scope->cancel();

// awaitCompletion() вернётся сразу, если остались только zombie
$scope->awaitCompletion($cancellation);

// awaitAfterCancellation() дождётся ВСЕХ, включая zombie
$scope->awaitAfterCancellation(function (\Throwable $error, Async\Scope $scope) {
    // Обработчик ошибок от zombie-корутин
    echo "Zombie error: " . $error->getMessage() . "\n";
});
Метод Ждёт активные Ждёт zombie Требует cancel()
awaitCompletion() Да Нет Нет
awaitAfterCancellation() Да Да Да

awaitAfterCancellation() можно вызвать только после cancel() — иначе будет ошибка. Это логично: zombie-корутины появляются именно в результате отмены с флагом DISPOSE_SAFELY.

Как zombie работают внутри

Когда корутина помечается как zombie, происходит следующее:

  1. Корутина получает флаг ZOMBIE
  2. Счётчик активных корутин в Scope уменьшается на 1
  3. Счётчик zombie-корутин увеличивается на 1
  4. Scope проверяет, остались ли активные корутины, и может уведомить ожидающих о завершении
Scope
├── active_coroutines_count: 0    ← уменьшается
├── zombie_coroutines_count: 2    ← увеличивается
├── coroutine A (zombie)          ← продолжает работать
└── coroutine B (zombie)          ← продолжает работать

Zombie-корутина не отвязывается от Scope. Она остаётся в его списке корутин, но не считается активной. Когда zombie-корутина наконец завершается, она удаляется из Scope, и Scope проверяет, можно ли полностью освободить ресурсы.

Как Scheduler обрабатывает zombie

Scheduler ведёт два независимых учёта корутин:

  1. Глобальный счётчик активных корутин (active_coroutine_count) — используется для быстрой проверки, нужно ли что-то планировать
  2. Реестр корутин (coroutines hash table) — содержит все корутины, которые ещё выполняются, включая zombie

Когда корутина помечается как zombie:

Приложение продолжает работу, пока счётчик активных корутин больше нуля. Отсюда следует важное следствие: Zombie-корутины не препятствуют завершению приложения, так как они не считаются активными. Если активных корутин больше нет, приложение завершается и теперь даже zombie-корутины будут отменены.

Наследование флага safely

По умолчанию Scope создаётся с флагом DISPOSE_SAFELY. Это означает: если Scope уничтожается (например, в деструкторе объекта), корутины становятся zombie, а не отменяются.

Дочерний Scope наследует этот флаг от родителя:

$parent = new Async\Scope();
// parent имеет флаг DISPOSE_SAFELY по умолчанию

$child = Async\Scope::inherit($parent);
// child тоже имеет флаг DISPOSE_SAFELY

Если вы хотите принудительную отмену при уничтожении, используйте asNotSafely():

$scope = (new Async\Scope())->asNotSafely();
// Теперь при уничтожении объекта Scope
// корутины будут отменены принудительно, а не помечены как zombie

Пример: HTTP-сервер с middleware

class RequestHandler
{
    private Async\Scope $scope;

    public function __construct() {
        $this->scope = new Async\Scope();
    }

    public function handle(Request $request): Response {
        // Запускаем middleware — это может быть чужой код
        $this->scope->spawn(function() use ($request) {
            $this->runMiddleware($request);
        });

        // Основная обработка — наш код
        $response = $this->scope->spawn(function() use ($request) {
            return $this->processRequest($request);
        });

        return await($response);
    }

    public function __destruct() {
        // При уничтожении: middleware может быть не готов к отмене,
        // поэтому используем disposeSafely() (поведение по умолчанию).
        // Zombie-корутины доработают сами.
        $this->scope->disposeSafely();
    }
}

Пример: обработчик с ограничением по времени

$scope = new Async\Scope();

// Запускаем задачи с чужим кодом
$scope->spawn(fn() => thirdPartyAnalytics($data));
$scope->spawn(fn() => thirdPartyNotification($userId));

// Даём 10 секунд на завершение, затем принудительная отмена
$scope->disposeAfterTimeout(10000);

Когда zombie становятся проблемой

Zombie-корутины — это компромисс. Они решают проблему чужого кода, но могут привести к утечке ресурсов.

Поэтому disposeAfterTimeout() или Scope с явной отменой корутин — лучший выбор для production: даёт время чужому коду завершиться, но гарантирует отмену в случае зависания.

Итого

Метод Отменяет корутины Корутины доработают Scope закрыт
dispose() Да Нет Да
disposeSafely() Нет Да (как zombie) Да
disposeAfterTimeout(ms) После тайм-аута До тайм-аута Да

Логирование зомби-корутин

В следующих версиях TrueAsync намерен предоставить механизм для логирования зомби-корутин, что позволит разработчикам устранять проблемы, связанные с зависшими задачами.

Дальше что?