Скасування
Браузер відправив запит, але потім користувач закрив сторінку. Сервер продовжує обробляти запит, який вже нікому не потрібний. Було б добре перервати операцію, щоб уникнути зайвих витрат. Або уявіть, що є тривалий процес копіювання даних, який потрібно раптово скасувати. Сценаріїв, коли потрібно зупинити операції, безліч. Зазвичай ця проблема вирішується через змінні-прапорці або токени скасування, що є досить трудомістким. Код повинен знати, що його можуть скасувати, повинен планувати контрольні точки скасування та коректно обробляти ці ситуації.
Скасування за замовчуванням
Більшу частину часу застосунок зайнятий читанням даних
з баз даних, файлів або мережі. Перервати читання – безпечно.
Тому в TrueAsync діє наступний принцип: корутину можна скасувати в будь-який момент зі стану очікування.
Цей підхід зменшує обсяг коду, оскільки в більшості випадків програмісту не потрібно турбуватися
про скасування.
Як працює скасування
Для скасування корутини використовується спеціальний виняток – Cancellation.
Виняток Cancellation або похідний від нього кидається в точці призупинення (suspend(), await(), delay()).
Виконання також може бути перерване під час операцій вводу/виводу або будь-якої іншої блокуючої операції.
$coroutine = spawn(function() {
echo "Starting work\n";
suspend(); // Тут корутина отримає Cancellation
echo "This won't happen\n";
});
$coroutine->cancel();
try {
await($coroutine);
} catch (\Cancellation $e) {
echo "Coroutine cancelled\n";
throw $e;
}
Скасування не можна придушити
Cancellation – це виняток базового рівня, нарівні з Error та Exception.
Конструкція catch (Exception $e) його не перехопить.
Перехоплювати Cancellation і продовжувати роботу – це помилка.
Ви можете використовувати catch Async\AsyncCancellation для обробки спеціальних ситуацій,
але маєте переконатися, що коректно повторно кидаєте виняток.
Загалом рекомендується використовувати finally для гарантованого очищення ресурсів:
spawn(function() {
$connection = connectToDatabase();
try {
processData($connection);
} finally {
$connection->close();
}
});
Три сценарії скасування
Поведінка cancel() залежить від стану корутини:
Корутина ще не почала виконання – вона ніколи не запуститься.
$coroutine = spawn(function() {
echo "Won't execute\n";
});
$coroutine->cancel();
Корутина перебуває в стані очікування – вона прокинеться з винятком Cancellation.
$coroutine = spawn(function() {
echo "Started work\n";
suspend(); // Тут вона отримає Cancellation
echo "Won't execute\n";
});
suspend();
$coroutine->cancel();
Корутина вже завершилася – нічого не відбудеться.
$coroutine = spawn(function() {
return 42;
});
await($coroutine);
$coroutine->cancel(); // Не помилка, але не має ефекту
Критичні секції: protect()
Не кожну операцію можна безпечно перервати. Якщо корутина списала гроші з одного рахунку, але ще не зарахувала на інший – скасування в цей момент призведе до втрати даних.
Функція protect() відкладає скасування до завершення критичної секції:
use Async\protect;
use Async\spawn;
$coroutine = spawn(function() {
protect(function() {
$db->query("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
suspend();
$db->query("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
});
// Скасування набуде чинності тут -- після виходу з protect()
});
suspend();
$coroutine->cancel();
Всередині protect() корутина позначається як захищена.
Якщо cancel() надійде в цей момент, скасування зберігається,
але не застосовується. Щойно protect() завершиться –
відкладене скасування набуде чинності негайно.
Каскадне скасування через Scope
Коли Scope скасовується, усі його корутини та всі дочірні області видимості скасовуються.
Каскад іде тільки зверху вниз – скасування дочірньої області не впливає на батьківську або сусідні.
Ізоляція: скасування дочірнього не впливає на інших
$parent = new Async\Scope();
$child1 = Async\Scope::inherit($parent);
$child2 = Async\Scope::inherit($parent);
// Скасовуємо тільки child1
$child1->cancel();
$parent->isCancelled(); // false -- батьківський не порушений
$child1->isCancelled(); // true
$child2->isCancelled(); // false -- сусідня область не порушена
Каскад вниз: скасування батьківського скасовує всіх нащадків
$parent = new Async\Scope();
$child1 = Async\Scope::inherit($parent);
$child2 = Async\Scope::inherit($parent);
$parent->cancel(); // Каскад: скасовує і child1, і child2
$parent->isCancelled(); // true
$child1->isCancelled(); // true
$child2->isCancelled(); // true
Корутина може скасувати власну область видимості
Корутина може ініціювати скасування області видимості, в якій вона виконується. Код до найближчої точки призупинення продовжить виконуватися:
$scope = new Async\Scope();
$scope->spawn(function() use ($scope) {
echo "Starting\n";
$scope->cancel();
echo "This will still execute\n";
suspend();
echo "But this won't\n";
});
Після скасування область видимості закривається – запустити нову корутину в ній вже неможливо.
Тайм-аути
Особливий випадок скасування – тайм-аут. Функція timeout() створює обмеження за часом:
$coroutine = spawn(function() {
return file_get_contents('https://slow-api.example.com/data');
});
try {
$result = await($coroutine, timeout(5000));
} catch (Async\OperationCanceledException $e) {
// $e->getPrevious() містить TimeoutException
echo "API didn't respond within 5 seconds\n";
}
При спрацюванні токена скасування (включно з таймаутом) викидається OperationCanceledException. Оригінальний виняток із токена доступний через $e->getPrevious(). Це дозволяє відрізнити спрацювання токена від помилки самого awaitable-об’єкта.
Перевірка стану
Корутина надає два методи для перевірки скасування:
isCancellationRequested()– скасування було запрошено, але ще не застосованоisCancelled()– корутина фактично зупинилася
$coroutine = spawn(function() {
suspend();
});
$coroutine->cancel();
$coroutine->isCancellationRequested(); // true
$coroutine->isCancelled(); // false -- ще не оброблено
suspend();
$coroutine->isCancelled(); // true
Приклад: обробник черги з коректним завершенням
class QueueWorker {
private Async\Scope $scope;
public function __construct() {
$this->scope = new Async\Scope();
$this->queue = new Async\Channel();
}
public function start(): void {
$this->scope->spawn(function() {
while (true) {
$job = $this->queue->receive();
try {
$job->process();
} finally {
$job->markDone();
}
}
});
}
public function stop(): void
{
// Усі корутини будуть зупинені тут
$this->scope->cancel();
}
}