How in the world does Awaitable.Cancel() actually work?

Behind the scenes, I mean.
I’m quite familiar with the CancellationToken paradigm, but Awaitable.Cancel() is a black box to me…

I understand that I can catch the cancellation in the calling code like this:

    Awaitable myAnim = CoolAnimation();
    try
    {
        await myAnim;
    }
    catch (OperationCanceledException oce)
    {
        Debug.Log("You didn't want to watch my animation? 😢", this);
    }

But internally, how does CoolAnimation() get stopped?

With a cancellation token, we can simply do:

private async Awaitable CoolAnimation(CancellationToken ct)
{
    while (condition)
    {
        if (ct.IsCancellationRequested) break;
        // or
        ct.ThrowIfCancellationRequested();

        Awaitable.NextFrameAsync();
    }
}

We have control here. Happy :blush:

If, then, however, the Awaitable has .Cancel() called on it from the outside, does Unity get to decide when the logic stops? Is there an API for detecting this, outside of a CancellationToken? Is there an equivalent to a finally block that we could throw in our Awaitable task?

Even more, is myAwaitable.IsCompleted true after myAwaitable.Cancel() is called? Assuming I have a reference, but can’t await (and subsequently wrap in try-catch) the awaitable, how would I know that it’s been cancelled?

From the decompiled code, it looks like it’s simply stopping iteration and throwing the [sometimes-catchable] exception:

    //
    // Summary:
    //     Cancel the awaitable. If the awaitable is being awaited, the awaiter will get
    //     a System.OperationCanceledException.
    public void Cancel()
    {
        AwaitableHandle awaitableHandle = CheckPointerValidity();
        if (awaitableHandle.IsManaged)
        {
            _managedCompletionQueue?.Remove(this);
            RaiseManagedCompletion(new OperationCanceledException());
        }
        else
        {
            CancelNativeAwaitable(awaitableHandle);
        }
    }

The docs say “Note: some methods returning an awaitable also accept a CancellationToken. Both cancelation models are equivalent.”
But this seems very far from the truth, as far as my understanding goes.

Can anyone with deeper insight than the docs give me some clarity here? It’s been driving me nuts all day :sweat_smile:

I think Awaitable.Cancel() is much more similar to StopCoroutine() than to CancellationToken. Like with coroutines, Unity will just not call the continuation of the async state machine. The async method that’s being canceled won’t have any way to detect this.

Only the method awaiting the canceled Awaitable will get an exception and is able to catch it.

Awaitables are pooled and recycled, you shouldn’t keep a reference beyond it being completed or canceled. So there’s really no way to check, calling IsCompleted after Cancel() will throw a InvalidOperationException.

If you need the control, use CancellationToken. I suspect Cancel() was added for upgrading coroutines to async/await without having to change the API to add the cancellation tokens everywhere.

1 Like