Отмена (Cancellation)
Необходимость отмены асинхронных операций — частая задача. Допустим, пользователь закрыл страницу, а сервер продолжает составлять ответ. Логично было бы прервать операцию.
В True Async для этого существует метод cancel().
Cancellable by design
В True Async действует принцип: корутина по умолчанию может быть отменена в любой момент, и это не должно нарушать целостность данных.
Причина такого решения проста: большую часть времени приложение занято чтением данных — из базы, из файлов, по сети. Прервать чтение можно безболезненно, никаких дополнительных усилий для этого не требуется.
Если же корутина выполняет критическую операцию — например, запись — это указывается явно. Об этом чуть позже.
Как работает отмена
Для отмены корутины используется специальное исключение — Cancellation.
Корутина отменяется в момент ожидания — будь то вызов suspend(), await(), delay(),
операция ввода-вывода или любая другая блокирующая операция.
Корутине посылается Cancellation. Когда выполнение корутины возобновляется,
она получает это исключение и выбрасывает его в точке остановки.
$coroutine = spawn(function() {
echo "Начинаю работу\n";
suspend();
echo "Этого не будет\n";
});
$coroutine->cancel();
try {
await($coroutine);
} catch (Async\Cancellation $e) {
echo "Корутина отменена\n";
throw $e;
}
Cancellation нельзя подавлять
Cancellation — это исключение базового уровня, наравне с Error и Exception.
Конструкция catch (Exception $e) его не перехватит.
Перехватывать Cancellation и продолжать работу — ошибка.
Вы можете использовать конструкцию catch Async\Cancellation с целью обрабатывания особенных ситуаций,
но обязаны следить за тем, чтобы корректно выбросить исключение дальше.
В общем случае рекомендуется использовать finally для гарантированного освобождения ресурсов:
spawn(function() {
$connection = connectToDatabase();
try {
processData($connection);
} finally {
$connection->close();
}
});
Три сценария отмены
Поведение cancel() зависит от состояния корутины:
Корутина ещё не начала работу — она никогда не запустится.
$coroutine = spawn(function() {
echo "Не выполнится\n";
});
$coroutine->cancel();
Корутина находится в ожидании — она проснётся с исключением Cancellation.
$coroutine = spawn(function() {
echo "Начала работу\n";
suspend(); // Здесь получит Cancellation
echo "Не выполнится\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 отменяются все его корутины и все дочерние scope.
Каскад идёт только сверху вниз — отмена дочернего 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 — соседний scope не затронут
Каскад вниз: отмена родителя отменяет всех потомков
$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
Корутина может инициировать отмену scope, в котором работает. Код до ближайшей точки приостановки продолжит выполнение:
$scope = new Async\Scope();
$scope->spawn(function() use ($scope) {
echo "Начинаю\n";
$scope->cancel();
echo "Это ещё выполнится\n";
suspend();
echo "А это уже нет\n";
});
После отмены scope закрывается — запустить в нём новую корутину уже нельзя.
Таймауты
Частный случай отмены — тайм-аут. Функция timeout() создаёт ограничение по времени:
$coroutine = spawn(function() {
return file_get_contents('https://slow-api.example.com/data');
});
try {
$result = await($coroutine, timeout(5000));
} catch (Async\TimeoutException $e) {
echo "API не ответил за 5 секунд\n";
}
TimeoutException является подтипом Cancellation,
поэтому корутина завершается по тем же правилам.
Проверка состояния
Корутина предоставляет два метода для проверки отмены:
isCancellationRequested()— отмена запрошена, но ещё не примененаisCancelled()— корутина фактически остановлена
$coroutine = spawn(function() {
suspend();
});
$coroutine->cancel();
$coroutine->isCancellationRequested(); // true
$coroutine->isCancelled(); // false — ещё не обработана
suspend();
$coroutine->isCancelled(); // true
Пример: обработчик очереди с graceful shutdown
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();
}
}