Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. Dismiss Notice

Cancel async task: best practice?

Discussion in 'Scripting' started by olejuer, Jan 8, 2020.

  1. olejuer

    olejuer

    Joined:
    Dec 1, 2014
    Posts:
    210
    Hi,

    I started working with UniRx.Async replacing coroutines with async/await methods. This is a design pattern question about cancelling async methods.
    When working with coroutines, I usually keep a handle on them and call StopCoroutine() or StopAllCoroutines() or just make use of the fact that a destroyed GameObject will end all coroutines.
    Now, when I use a UniTask instead, I cannot cancel it from the outside. I will either have it end itself or use a cancellation token. The latter seems to come with quite some boilerplate and my code ends up looking worse than with coroutines. What is the best practice to cancel UniTasks?
    Here is an example in both variants that I think are equivalent and you can see how the Rx approach comes with more boilerplate and nesting

    Code (CSharp):
    1. public class CoroutineTest : MonoBehaviour
    2.     {
    3.         private Coroutine _coroutine;
    4.  
    5.         public void Run()
    6.         {
    7.             if(_coroutine != null) StopCoroutine(_coroutine);
    8.             _coroutine = StartCoroutine(RunCoroutine());
    9.         }
    10.  
    11.         private IEnumerator RunCoroutine()
    12.         {
    13.             while (true)
    14.             {
    15.                 // do something
    16.                 yield return new WaitForSeconds(10);
    17.             }
    18.         }
    19.     }
    20.  
    21.     public class UniTaskTest : MonoBehaviour
    22.     {
    23.         private CancellationTokenSource _cancellationTokenSource;
    24.  
    25.         public void Run()
    26.         {
    27.             _cancellationTokenSource?.Cancel();
    28.             _cancellationTokenSource?.Dispose();
    29.             _cancellationTokenSource = new CancellationTokenSource();
    30.             RunTask(_cancellationTokenSource.Token);
    31.         }
    32.  
    33.         private async void RunTask(CancellationToken token)
    34.         {
    35.             while (true)
    36.             {
    37.                 // do something
    38.                 try
    39.                 {
    40.                     await UniTask.Delay(TimeSpan.FromSeconds(10), false, PlayerLoopTiming.Update, token);
    41.                 }
    42.                 catch (OperationCanceledException)
    43.                 {
    44.                     break;
    45.                 }
    46.             }
    47.         }
    48.     }
    Am I missing something that makes this more elegant or am I doing something wrong entirely?
    Thanks!
     
    poiuminaj likes this.
  2. adi7b9

    adi7b9

    Joined:
    Feb 22, 2015
    Posts:
    181
    Code (CSharp):
    1. class AsyncTest
    2. {
    3.     private bool IsCanceled = false;
    4.  
    5.     public async void TaskAsync()
    6.     {
    7.         await Task.Run(() => Runner());
    8.     }
    9.  
    10.     public void Stop()
    11.     {
    12.         IsCanceled = true;
    13.     }
    14.  
    15.     private void Runner()
    16.     {
    17.         while (true)
    18.         {
    19.             Console.WriteLine("TaskAsync");
    20.             if (IsCanceled)
    21.             {
    22.                 break;
    23.             }
    24.         }
    25.     }
    26. }
    Code (CSharp):
    1. AsyncTest at = new AsyncTest();
    2. at.TaskAsync();
    3.  
    4. for (int i = 0; i < 20; i++)
    5. {
    6.     Console.WriteLine("main");
    7.     System.Threading.Thread.Sleep(1);
    8.     if (i == 10)
    9.     {
    10.         at.Stop();
    11.     }
    12. }
     
  3. olejuer

    olejuer

    Joined:
    Dec 1, 2014
    Posts:
    210
    Thanks!
    Problem is, this holds state in the TaskAsync class indicating that a task should terminate. This works fine as long as there is exactly this one task running. I have multiple and I need to cancel one specific task.
    To elaborate: I often have the use case where I have a task running as a response to some event. When that event occurs a second time while the task is still running, the task should be stopped a new task started. So I have to cancel the running task. But I cannot do that immediately, instead I have to let it know that it should terminate at the earliest convenience. This information I have to hold somewhere. And I want to start a new one which, of course, should not cancel immediately. The approach with state in the class will not allow for two Runner tasks where one is to be canceled and the other is to continue.
     
  4. adi7b9

    adi7b9

    Joined:
    Feb 22, 2015
    Posts:
    181
    By using maps.
    Code (CSharp):
    1. var map = new Dictionary<string, AsyncTest>();
    2. map.Add("SomeEvent1", new AsyncTest());
    3. map.Add("SomeEvent2", new AsyncTest());
    4. map["SomeEvent1"].TaskAsync();
    5. map["SomeEvent2"].TaskAsync();
    6.  
    7. for (int i = 0; i < 20; i++)
    8. {
    9.     Console.WriteLine("main");
    10.     System.Threading.Thread.Sleep(1);
    11.     if (i == 10)
    12.     {
    13.         map["SomeEvent1"].Stop();
    14.     }
    15.     if (i == 12)
    16.     {
    17.         map["SomeEvent2"].Stop();
    18.     }
    19. }
    You can declare your map as
    Code (CSharp):
    1. var map = new Dictionary<YourEvent, AsyncTest>();
    2.  
    3. //and use it .. when event arrived
    4. map[EventArrived].DoSomething();
    5.  
     
  5. Baste

    Baste

    Joined:
    Jan 24, 2013
    Posts:
    6,195
    We've done a lot of work to make coroutines less boilerplaty, you could do a similar thing with tasks.

    Code (csharp):
    1. public static class TaskUtil {
    2.     public static CancellationToken RefreshToken(ref CancellationTokenSource tokenSource) {
    3.         tokenSource?.Cancel();
    4.         tokenSource?.Dispose();
    5.         tokenSource = new CancellationTokenSource();
    6.         return tokenSource.Token;
    7.     }
    8. }
    Turns your old:

    Code (csharp):
    1. public void Run() {
    2.     _cancellationTokenSource?.Cancel();
    3.     _cancellationTokenSource?.Dispose();
    4.     _cancellationTokenSource = new CancellationTokenSource();
    5.     RunTask(_cancellationTokenSource.Token);
    6. }
    Into:

    Code (csharp):
    1. public void Run() {
    2.     RunTask(TaskUtil.RefreshToken(ref _cancellationTokenSource));
    3. }

    I've been looking at tasks, and while the whole "can return values" part is great, the cancellationToken bit is a major pain. @adi7b9's suggestion of sticking in an "isCancelled" check is also... not very great, becasue you have to stick it in everywhere.

    I imagine that it's not too bad to write a wrapper that makes it non-painfull. It looks like UniTask is not doing that, so maybe you should write something better than UniTask?
     
  6. olejuer

    olejuer

    Joined:
    Dec 1, 2014
    Posts:
    210
    Ah, that is quite a neat approach. I could write some util, I guess. Writing something entirely new to replace UniTask is very tempting, but I have to keep myself from procrastinating on these things :D
    It is not only returning values that I am interested in, but also running tasks without MonoBehaviour and proper exception handling. Also, coroutines are allegedly expensive and my game is quite performance critical. I was also hoping to do some multithreading for certain things. I have to agree, though, that there are some caveats. It is easy to start a task which does not finish properly or throws unexpected exceptions. I am wondering, if it is worth the effort, or if I'd rather stick with coroutines. Some people say that I should turn to DOTS instead, but I don't think that's there, yet.

    For now, I think I will go with UniTask and a Util like you suggested, thanks again.
     
  7. Straafe

    Straafe

    Joined:
    Oct 15, 2012
    Posts:
    73
    Has anyone found a better solution for stopping async methods/tasks? I ran away from coroutines as fast as I could when async was added to Unity, but I'm also starting to miss StopCoroutine() ...
     
  8. olejuer

    olejuer

    Joined:
    Dec 1, 2014
    Posts:
    210
    I am pretty positive there is none. You will have to pass Cancelation tokens and maybe even create linked cancellation token sources. It is quite ugly and error prone. I am trying to move away from async back to coroutines in many cases. Its a tough choice sometimes. From my experience so far:

    pro async/await:
    - modern programming style (transfers well to coding outside of unity)
    - better exception handling (this is maybe the most important aspect, but it does have it's own caveats)
    - works without GameObject in the scene that runs a Coroutine
    - has return value (personnally, I seldomly have use for this)

    con async/await:
    - Cancellation is a major pain in the ***
    - Possible to have exceptions just vanish and never be logged when you are not careful
    - Need external libraries not maintained by Unity, could break with any Unity update
    - Can be annoying to cleanly setup with respect to Unitys lifecycle

    There are lots of use cases where Coroutines are simply better. And it's not easy to figure out what the best approach is in any particular case, at least for me. I kind of regret bringing in UniTask in the first place, now. It might give some advantage here and there, but honestly, using Coroutines with the necessary workarounds like some artificial runner-GameObject is probably better most of the time.
    The exception handling of Coroutines is horrible, though. So I haven't made up my mind completly. Still happy about any input on best practices here!
     
  9. stevphie123

    stevphie123

    Joined:
    Mar 24, 2021
    Posts:
    74
    We now have MonoBehavior.CancellationToken... Such a bless from heaven
     
  10. koirat

    koirat

    Joined:
    Jul 7, 2012
    Posts:
    2,009
    Where is documentation for this ?
     
  11. VolodymyrBS

    VolodymyrBS

    Joined:
    May 15, 2019
    Posts:
    150
    abegue and koirat like this.
  12. passerbycmc

    passerbycmc

    Joined:
    Feb 12, 2015
    Posts:
    1,739
    oh that is pretty nice, though feels like something that is simple enough to just implement yourself and is more flexible that way. Since this is tied to only running on onDestroy vs if i did it myself i could do it for other signals like OnDisable if needed
     
  13. stevphie123

    stevphie123

    Joined:
    Mar 24, 2021
    Posts:
    74
    sure you can do your own implementation, but the new cancellationtoken API that was added will be very useful for failsafe use cases
     
  14. MadboyJames

    MadboyJames

    Joined:
    Oct 28, 2017
    Posts:
    262
    Something mostly related, is there a way to pause tasks? I'm using Addressables and if multiple things request an object before the AssetReference is loaded, I want to get the AsyncOperationHandle from assetReference.InstantiateAsync() to pass back to the requester, but I don't want the task to actually try and finish until LoadAssetAsync() completes. I'm following (and heavily modifying) Jason Weimann's tutorial from 2019, so my issue may be a non-problem by now, but I don't know.
     
  15. MadboyJames

    MadboyJames

    Joined:
    Oct 28, 2017
    Posts:
    262
    I think my use case may be a bit different (or I am struggling to see how I can refactor the code to work with that pattern).
    This method is called when something requests an object and the AssetReference is still loading.

    Code (CSharp):
    1. private static AsyncOperationHandle EnqueueSpawnForAfterInitialization(AssetReference assetReference)
    2.         {
    3.             if (QueuedSpawnRequests.ContainsKey(assetReference) == false)
    4.                 QueuedSpawnRequests[assetReference] = new Queue<AsyncOperationHandle>();
    5.  
    6.             AsyncOperationHandle handle = assetReference.InstantiateAsync();
    7.             QueuedSpawnRequests[assetReference].Enqueue(handle);
    8.  
    9.             //handle.Task.Wait();   //freezes main thread, need something that just pauses...
    10.             //handle.Task.Delay(1000);   //Delay is a static method, cannot call from instance
    11.             //Task.Delay(100,handle.Task);  //Handle.Task is not a cancellation token.
    12.          
    13.             return handle;
    14.         }
     
    Last edited: Jun 30, 2022
  16. gwelkind

    gwelkind

    Joined:
    Sep 16, 2015
    Posts:
    65
    IMO, at least as of Unity 2021 LTS, the best way to handle the strengths and weaknesses of Coroutines and Tasks/Async is to use both of them where applicable, but mostly Async is better even where it's superficially more verbose.


    I think if you have something you want to run perpetually/until cancelled just do

    Code (CSharp):
    1. public async UniTask SomethingToDoEverySecond(){
    2. // Probably wrap in a try{}catch{}
    3. }
    4.  
    5. public IEnumerator PerpetuallyRun() {
    6.     while (true) {
    7.        yield return SomethingToDoEverySecond().ToCoroutine();
    8.        yield return new WaitForSeconds(1);
    9.    }
    10. }
    11.  
    12. private Coroutine perpetuallyRunningCo;
    13.  
    14. public void OnEnable(){
    15.    perpetuallyRunningCo = StartCoroutine(PerpetuallyRun());
    16.  
    17. }
    18.  
    19. public void OnDisable() {
    20.   StopCoroutine(perpetuallyRunningCo);
    21. }

    That said, it's easy enough to just pass the cancellation token through or put in a bunch of
    Code (CSharp):
    1.  
    2. public async UniTask CancellableSh*t(CancellationToken ct){
    3.     while(moreWorkToDo) {
    4.         await DoWork();
    5.         if(ct.IsCancellationRequested) break;
    6.     }
    7. }
    8.  
    or make your while loop
    Code (CSharp):
    1. while (!ct.IsCancellationRequested) {
    I prefer this because, in a more complicated function, it forces you to think about cleaning up in different ways depending on at what step in your function it's cancelled from the outside. This is actually really, really important for preventing timing bugs and edge cases which are easy to miss with coroutines.

    It's a different (I think better) way of thinking about cancellation; cancellation is something that is requested externally, but fulfilled internally depending on the instantaneous state of the method.

    However, coroutines are still cleaner imo if you specifically have a procedure with many asynchronous steps and you definitely don't care about doing something different depending on when it's cancelled.

    TL;DR if something makes more intuitive sense to you to do as a coroutine, use UniTask and they're interchangeable. Doesn't have to be one or the other. However UniTask/Async, though more verbose, is often more robust.
     
    Last edited: Nov 15, 2022
    JonBFS and olejuer like this.
  17. JonBFS

    JonBFS

    Joined:
    Feb 25, 2019
    Posts:
    39
    I think this is the take away knowledge I needed at the moment. Coroutines are great if you don't care what happens if it shuts off or cancelled suddenly, while Tasks are great if you need control and need to gracefully cleanup in the case of cancellation.
     
    HarvesteR and Straafe like this.
  18. HarvesteR

    HarvesteR

    Joined:
    May 22, 2009
    Posts:
    525
    Very good info on this thread. I'm in the same boat. Asyncs are awesome and the way of the future, but they don't have the conveniences of being able to rely on the async operation being hosted by a gameobject (and sharing its lifecycle)...

    Maybe a convenience set of methods to start an async task 'hosted' on a component/monobehaviour/GO could be the way to go... something to just wrap away the unsightly business with cancellationTokens and token sources and stuff.

    This is the exact sort of rabbit hole I'd gladly spend the rest of the week on... but it's already 5pm on a Friday, and I'm running late enough as it is :rolleyes:
     
  19. dlorre

    dlorre

    Joined:
    Apr 12, 2020
    Posts:
    700
    With Awaitable you can do whatever the coroutines were doing but with async/await.

    https://docs.unity3d.com/2023.1/Documentation/ScriptReference/Awaitable.html
     
    Last edited: Apr 29, 2023
  20. HarvesteR

    HarvesteR

    Joined:
    May 22, 2009
    Posts:
    525
    DungDajHjep likes this.