Zombie-корутини: Відмовостійкість

Проблема: Код, який не можна скасувати

Скасування корутини – кооперативний процес. Корутина отримує виключення Cancellation у точці призупинення і повинна коректно завершитися. Але що, якщо хтось допустив помилку і створив корутину в неправильному Scope? Хоча TrueAsync дотримується принципу Cancellation by design, можуть виникнути ситуації, коли хтось написав код, скасування якого може призвести до неприємних наслідків. Наприклад, хтось створив фонову задачу для відправки email. Корутину скасовано, email так і не відправлено.

Висока відмовостійкість дозволяє значно заощадити час розробки і мінімізувати наслідки помилок, якщо програмісти використовують аналіз логів для покращення якості застосунку.

Рішення: Zombie-корутини

Для згладжування таких ситуацій TrueAsync надає спеціальний підхід: толерантна обробка “зависших” корутин – zombie-корутин.

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 перевіряє, чи може повністю звільнити ресурси.

Як планувальник обробляє Zombie

Scheduler підтримує два незалежних лічильники корутин:

  1. Глобальний лічильник активних корутин (active_coroutine_count) – використовується для швидкої перевірки, чи потрібно щось планувати
  2. Реєстр корутин (хеш-таблиця coroutines) – містить всі корутини, що ще працюють, включаючи 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) Після таймауту До таймауту Так

Логування Zombie-корутин

У майбутніх версіях TrueAsync планує надати механізм логування zombie-корутин, що дозволить розробникам діагностувати проблеми, пов’язані з зависанням задач.

Що далі?