КомпонентиZombie-корутини

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

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

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

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

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

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

Zombie-корутина -- це корутина, яка:

  • Продовжує виконуватися як зазвичай
  • Залишається прив'язаною до свого Scope
  • Не вважається активною -- Scope може формально завершитися, не чекаючи на неї
  • Не блокує awaitCompletion(), але блокує awaitAfterCancellation()
php
$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.

php
$scope->dispose();
// Всі корутини скасовані. Scope закрито.

disposeSafely() -- Без скасування, корутини стають Zombie ​

Корутини не отримують Cancellation. Вони позначаються як zombie і продовжують працювати. Scope вважається закритим -- нові корутини не можуть бути створені.

Використовуйте, коли Scope містить "сторонній" код і ви не впевнені в коректності скасування.

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

disposeAfterTimeout(int $timeout) -- Скасування з таймаутом ​

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

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

Очікування Zombie-корутин ​

awaitCompletion() чекає тільки активні корутини. Коли всі корутини стають zombie, awaitCompletion() вважає Scope завершеним і повертає управління.

Але іноді потрібно дочекатися завершення всіх корутин, включаючи zombie. Для цього існує awaitAfterCancellation():

php
$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:

  • Глобальний лічильник активних корутин зменшується -- Scheduler вважає, що активної роботи менше
  • Корутина залишається в реєстрі -- Scheduler продовжує керувати її виконанням

Застосунок продовжує працювати, поки лічильник активних корутин більше нуля. З цього випливає важливий наслідок: Zombie-корутини не перешкоджають завершенню роботи застосунку, оскільки вони не вважаються активними. Якщо активних корутин більше немає, застосунок завершується і навіть zombie-корутини будуть скасовані.

Успадкування прапорця Safely ​

Прапорець DISPOSE_SAFELY за замовчуванням є лише у глобального scope. new Scope() створюється без нього: коли такий Scope знищується (наприклад, у деструкторі об'єкта), його корутини скасовуються. allowZombies() вмикає прапорець, і тоді корутини стають zombie, а не скасовуються.

Дочірній Scope успадковує прапорець від батьківського:

php
$parent = (new Async\Scope())->allowZombies();
// parent має прапорець DISPOSE_SAFELY

$child = Async\Scope::inherit($parent);
// child також має прапорець DISPOSE_SAFELY

$top = Async\Scope::inherit();
// на верхньому рівні батько -- глобальний scope, тому $top теж має прапорець

Щоб успадкований Scope при знищенні скасовував корутини, використовуйте asNotSafely():

php
$scope = Async\Scope::inherit()->asNotSafely();
// Тепер при знищенні об'єкта Scope
// корутини будуть скасовані, а не позначені як zombie

Приклад: HTTP-сервер з Middleware ​

php
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(), а не dispose().
        // Zombie-корутини завершаться самостійно.
        $this->scope->disposeSafely();
    }
}

Приклад: Обробник з обмеженням за часом ​

php
$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-корутин, що дозволить розробникам діагностувати проблеми, пов'язані з зависанням задач.

Що далі? ​