Отмена (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, поэтому корутина завершается по тем же правилам.

Проверка состояния

Корутина предоставляет два метода для проверки отмены:

$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();
    }
}

Дальше что?