Zombie-корутины: толерантность к ошибкам
Проблема: код, который нельзя отменить
Отмена корутины — кооперативный процесс. Корутина получает исключение Cancellation
в точке ожидания и должна корректно завершиться. Но, что, если кто-то ошибся и создал корутину не в том Scope?
Хотя TrueAsync следует принципу Cancellation by design, могут возникать ситуации, когда кто-то написал код,
отмена которого может привести к неприятной ситуации.
Например, кто-то создал фоновую задачу для отправки email. Корутина отменилась, email не был послан.
Высокая толерантность к ошибкам позволяют значительно экономить время разработки и минимизировать последствия ошибки, если программисты используют анализ логов с целью повышения качества приложения.
Решение: zombie-корутины
Чтобы сгладить подобные ситуации TrueAsync предоставляет особый подход:
толерантной обработки “зависших” корутин: зомби корутины.
Zombie-корутина — это корутина, которая:
- Продолжает выполнение как обычно
- Остаётся привязанной к своему Scope
- Не считается активной — Scope может формально завершиться, не дожидаясь её
- Не блокирует
awaitCompletion(), но блокируетawaitAfterCancellation()
$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, происходит следующее:
- Корутина получает флаг
ZOMBIE - Счётчик активных корутин в
Scopeуменьшается на 1 - Счётчик
zombie-корутин увеличивается на 1 Scopeпроверяет, остались ли активные корутины, и может уведомить ожидающих о завершении
Scope
├── active_coroutines_count: 0 ← уменьшается
├── zombie_coroutines_count: 2 ← увеличивается
├── coroutine A (zombie) ← продолжает работать
└── coroutine B (zombie) ← продолжает работать
Zombie-корутина не отвязывается от Scope. Она остаётся в его списке корутин,
но не считается активной. Когда zombie-корутина наконец завершается,
она удаляется из Scope, и Scope проверяет, можно ли полностью освободить ресурсы.
Как Scheduler обрабатывает zombie
Scheduler ведёт два независимых учёта корутин:
- Глобальный счётчик активных корутин (
active_coroutine_count) — используется для быстрой проверки, нужно ли что-то планировать - Реестр корутин (
coroutineshash table) — содержит все корутины, которые ещё выполняются, включаяzombie
Когда корутина помечается как zombie:
- Глобальный счётчик активных корутин уменьшается — Scheduler считает, что активной работы стало меньше
- Корутина остаётся в реестре —
Schedulerпродолжает управлять её выполнением
Приложение продолжает работу, пока счётчик активных корутин больше нуля. Отсюда следует важное следствие:
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 намерен предоставить механизм для логирования зомби-корутин, что позволит
разработчикам устранять проблемы, связанные с зависшими задачами.