Search Unity

Discussion Cancelling an Awaitable without throwing an exception

Discussion in 'Scripting Dev Blitz Day 2023 - Q&A' started by rawna, Feb 23, 2023.

  1. rawna

    rawna

    Joined:
    Aug 13, 2015
    Posts:
    35
    I have mentioned this in the "future dotnet development" thread, but I did not push on it, because I didn't want to derail the thread.

    From what I can tell, cancelling an Awaitable will cause allocations by creating an Exception and an ExceptionDispatchInfo. And then throw the exception, which will be caught by the "catch" block in the generated state machine. Then it will repeat the whole process for each parent/calling async Awaitable method.

    Would it be possible to instead, just reset and release the Awaitable object and its resources (and all its Awaitable parents)?

    Would love to hear the scripting team opinion on the matter. Thanks.
     
  2. tvardero

    tvardero

    Joined:
    Apr 2, 2022
    Posts:
    5
    You can wrap your async method call in try-catch block, that will catch OperationCancelledException and do nothing.

    P.S. I'm not a developer from Unity team, I'm just pathing through...
     
  3. rawna

    rawna

    Joined:
    Aug 13, 2015
    Posts:
    35
    My problem with the exception, is the performance implications.

    It will cause allocations for the exception and the dispatch info, and it'll multiply based on how deep the Awaitable method is.

    Plus, catching an exception is known to be very slow in dotnet.
     
  4. tvardero

    tvardero

    Joined:
    Apr 2, 2022
    Posts:
    5
    Don't get scared by exceptions in C#.
    Even if they are slightly slow compared to returning a some result object with "failure" status, they have no big impact on your app, even if scaled to 10000 or higher.
    Nick Chapsas has benchmark video comparing exception throwing vs returning failed status object. We are talking about 150 nanoseconds here, will someone notice?

    Exception object, yes, is allocated on heap. As any other object in your code. But it is passed by reference from once call frame to another (in your call stack), so it won't allocate again when comes in outer function.

    P.S. Even after seing Nick Chapsas video on the topic, I still prefer to throw exceptions. Not only because it feels natural and reduces nestings, but also for stack trace that helps find the issue root A LOT. I develop web applications (backend mostly), and when you have a bug occuring somewhere without stack trace - it could take ages to debug.
     
    Last edited: Feb 23, 2023
  5. rawna

    rawna

    Joined:
    Aug 13, 2015
    Posts:
    35
    While the exception object won't allocate multiple times, the ExceptionDispatchInfo will. And the exception will be caught by every parent Awaitable.

    The way it works AFAIK is, when you cancel, it'll create the exception object in the "Cancel" method(*1), then it'll create the dispatch info object in the "RaiseManagedCompletion" method(*2).

    After that it'll continue the async state machine, and call "GetReult" for the Awaitable, which will call "PropagateExceptionAndRelease"(*3).

    This will throw the created exception, and be caught by the async state machine catch block. which will call "SetException" in the async method builder(*4).

    Which in turn would call the "RaiseManagedCompletion" of the outer Awaitable method, and repeat the cycle for the parent Awaitables (hence the PropagateException).


    Now I wouldn't care about this, if cancelling is an occasional thing, but most of the use cases that I can imagine for Awaitables, will need to cancel at some point.

    To list a few:-
    * State machine, where each state is an Awaitable, that cancels when it transitions to another state
    * Tweening UI. When a use selects an option in menu, it starts an Awaitable, then cancels it when the user moves out of the option
    * Player skill, which gets canceled, when the player gets hit


    *1: https://github.com/Unity-Technologi...13/Runtime/Export/Scripting/Awaitable.cs#L177

    *2: https://github.com/Unity-Technologi...13/Runtime/Export/Scripting/Awaitable.cs#L111

    *3: https://github.com/Unity-Technologi...13/Runtime/Export/Scripting/Awaitable.cs#L142

    *4: https://github.com/Unity-Technologi...cripting/Awaitable.AsyncMethodBuilder.cs#L115
     
    SisusCo and TeodorVecerdi like this.
  6. simon-ferquel-unity

    simon-ferquel-unity

    Unity Technologies

    Joined:
    Apr 1, 2021
    Posts:
    68
    The problem with not raising the exception, is that it is totally unsafe, as the continuation will never run.
    Imagine something like the following:

    Code (CSharp):
    1. using(var someNativeResource = SomeApi.ReturningSomethingThatNeedsDispose()){
    2.    await SomeAwaitableThatCanBeJustInterupted();
    3. }
    4.  
    5. // or even worse with standard-ish use of SemaphoreSlim:
    6.  
    7. await semaphoreSlim.WaitAsync();
    8. try{
    9.    await SomeNonExceptionCancellableAsync();
    10. }
    11. finally{
    12.   semaphoreSlim.Release();
    13. }
    14.  
    In the first case, Dispose won't ever be called, which can be problematic in some scenarios.
    Second case, you will end up with a deadlock as the semaphore won't be released.
     
    SisusCo and rawna like this.
  7. rawna

    rawna

    Joined:
    Aug 13, 2015
    Posts:
    35
    That's a good point. That hasn't occurred to me.

    It that case, what about providing another method, called "StopExecuting" or "UnsafeStop"?

    I would argue that performance benefits in 90% of the Awaitable use cases, outweighs the mentioned risks in those cases.

    Plus the method name would make it clear, that you are responsible not to cause scenarios like these.
     
    NikolaNikolov likes this.
  8. Baste

    Baste

    Joined:
    Jan 24, 2013
    Posts:
    6,338
    This is also a problem with coroutines, which we are allowed to stop externally.

    That design allows us to make mistakes and require us to keep track of proper cleanup. As a result, it's much cleaner, much more comfortable, and generally better.
     
    NikolaNikolov, LeeYoungBum and rawna like this.
  9. kdserra

    kdserra

    Joined:
    May 16, 2018
    Posts:
    10
    What about inverting it?

    await MyAwaitable.ThrowWhenCancelled()

    This way we don't have to catch throws which have a performance drawback, but for the edge cases that need throws on cancellation they can still have it aswell.

    Tasks do it this way aswell
    CancellationToken.ThrowIfCancellationRequested

    I have made a post dedicated to this here:
    https://forum.unity.com/threads/awa...w-exceptions-by-default.1512038/#post-9444425
     
    Last edited: Nov 1, 2023
    NikolaNikolov likes this.