Zombie-корутини: Відмовостійкість
Проблема: Код, який не можна скасувати
Скасування корутини – кооперативний процес. Корутина отримує виключення Cancellation
у точці призупинення і повинна коректно завершитися. Але що, якщо хтось допустив помилку і створив корутину в неправильному Scope?
Хоча TrueAsync дотримується принципу Cancellation by design, можуть виникнути ситуації, коли хтось написав код,
скасування якого може призвести до неприємних наслідків.
Наприклад, хтось створив фонову задачу для відправки email. Корутину скасовано, email так і не відправлено.
Висока відмовостійкість дозволяє значно заощадити час розробки і мінімізувати наслідки помилок, якщо програмісти використовують аналіз логів для покращення якості застосунку.
Рішення: Zombie-корутини
Для згладжування таких ситуацій TrueAsync надає спеціальний підхід:
толерантна обробка “зависших” корутин – zombie-корутин.
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 перевіряє, чи може повністю звільнити ресурси.
Як планувальник обробляє Zombie
Scheduler підтримує два незалежних лічильники корутин:
- Глобальний лічильник активних корутин (
active_coroutine_count) – використовується для швидкої перевірки, чи потрібно щось планувати - Реєстр корутин (хеш-таблиця
coroutines) – містить всі корутини, що ще працюють, включаючи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) |
Після таймауту | До таймауту | Так |
Логування Zombie-корутин
У майбутніх версіях TrueAsync планує надати механізм логування zombie-корутин, що дозволить
розробникам діагностувати проблеми, пов’язані з зависанням задач.
Що далі?
- Scope – управління групами корутин
- Скасування – патерни скасування
- Корутини – життєвий цикл корутин