zl程序教程

您现在的位置是:首页 >  前端

当前栏目

Common async / Task mistakes, and how to avoid them

async to and How Task Common avoid them
2023-09-11 14:14:17 时间

Common async / Task mistakes, and how to avoid them

The .Net async / await mechanism is a godsend when it comes to making asynchronous code accessible, but despite being a superb abstraction, there are still a lot of subtle pitfalls a lot of developers fall into.

Therefore, for this post in my series of async-themed stories I really wanted to bring in some fun (and significant) pitfalls a lot of developers (me included) regularily fall into, because they are often just so subtle to introduce, but so hard to find.

UnobservedTaskExceptions from async Task methods

One of the first things developers learn when working with async code is to avoid async void methods due to their potentially catastrophic impact when throwing exceptions. For async Task methods, the exception is placed on the Task instead (as I explained in https://stefansch.medium.com/what-the-async-keyword-actually-does-bb10d54ce31c).

So, that’s safe to use, right? The answer is: It mostly is, but depending on the usage it might also not be.

Imagine this code:

async Task Main()
{
    await DoStuff();
}async Task DoStuff()
{
    await Task.Delay(100);
    throw new Exception();
}

When running the code as-is, everything works fine. The exception is thrown properly, since we await DoStuff(), which in turn means we are consuming the Exception placed on the Task.

Now, look at this code instead

async Task Main()
{
    DoStuff();
}async Task DoStuff()
{
    await Task.Delay(100);
    throw new Exception();
}

It looks very tame, but the problem lies within the missing await. A Task, just like any other reference type, lives on the Heap, and will at some point also be up for Garbage Collection.

When the GC collects a Task, and the Task still has an Exception attached to it, which was never rethrown because it was never awaited, then the CLR will throw an UnobservedTaskException. And believe me, you don’t want to see these in your production log, as debugging them is usually super painful.

We can actually show this behaviour with the following adaptions:

async Task Main()
{
    TaskScheduler.UnobservedTaskException += (sender, args) =>
    {
        Console.WriteLine("Whoopsie");
    };    DoStuff();
    GC.Collect();
}async Task DoStuff()
{
    await Task.Delay(100);
    throw new Exception();
}// Output:
Whoopsie

We manually trigger the GC collection, and attach a listener to the event handler which is raised when an UnobservedTaskException occurs.

So, make sure you properly await your Tasks, or properly catch Exceptions inside so they don’t leak outwards!

Properly awaiting concurrent Tasks

A great example to make use of concurrent asynchronous execution is the following. Imagine this code:

async Task Main()
{
    await DoStuff(1);
    await DoStuff(2);
}async Task DoStuff(int number)
{
    // Imagine a Network call here.
    await Task.Delay(500);
    Console.WriteLine($"{number} done!");
}

This looks fine, but the whole Main() method will take roughly 1 second here, as the execution still happens sequential. We can do better!

async Task Main()
{
    var t1 = DoStuff(1);
    var t2 = DoStuff(2);
    await t1;
    await t2;
}async Task DoStuff(int number)
{
    // Imagine a Network call here.
    await Task.Delay(500);
    Console.WriteLine($"{number} done!");
}

Great! We successfully took advantage of concurrency here. We started both units of work at the same time, and the method executed in only 0,5 seconds, despite doing the same amount of work! We await t1, but by the time t1 is done and we await t2, t2 is also already done!

However, if you paid attention in the previous section, it’s easy to imagine a problem here. What if DoStuff() throws an Exception in both calls? We would properly await t1, t1 would raise its Exception, and it would bubble up as we don’t catch the Exception within Main().

But what about t2? We never got to the point where we await t2! This will eventually unearth as an UnobservedTaskException again.

Instead, the following should be used:

async Task Main()
{
    var t1 = DoStuff(1);
    var t2 = DoStuff(2);
    await Task.WhenAll(t1, t2);
}async Task DoStuff(int number)
{
    // Imagine a Network call here.
    await Task.Delay(500);
    Console.WriteLine($"{number} done!");
}

Task.WhenAll is a utility function of the Task Parallel Library, and essentially bundle a set of awaitables into a single one. That’s all we need to properly fix our example from above. Without modifications, this will only throw the first Exception however, but the other Exceptions are still properly observed.

Async methods run synchronously until the first await

Probably my favourite pitfall of async methods is the behaviour they show with synchronous code at the beginning of the method. See the following example:

async Task Main()
{
    var t1 = DoStuff();
    var t2 = DoStuff();
    await Task.WhenAll(t1, t2);
}async Task DoStuff()
{
    Thread.Sleep(500);
    await Task.Delay(500);
}

Looking at this code, the DoStuff() method waits for 1 second in whole. Since we don’t await it directly but start both Tasks concurrently, one would assume, the total processing takes 1 second, since both methods run independently, right?

Actually, the execution takes 1.5 seconds — How does that happen?

Async methods behave logically similar to non-async methods, at least until the first await, and therefore the first context switch occurs. Up until this point, everything will execute synchronously. So in our case:

  1. The first call to DoStuff() starts, and sleeps for 500ms.
  2. After the sleep, the Task.Delay(500) starts running, the further execution is deferred, and we return to the Main() method.
  3. Now, the second DoStuff() starts, and sleeps for 500ms again.
  4. Now, the second delay is also triggered, and we reach the point in the Main() method where we wait for both Tasks to be done. t1 will already be done at this point, as the delay took place at the same time the second Thread.Sleep() ran, so now we essentially wait for the remaining 500ms of the second delay.
  5. After 1.5 seconds, everything is done.

With just a minor modification, we can bring the execution time down to 1 second:

async Task Main()
{
    var t1 = DoStuff();
    var t2 = DoStuff();
    await Task.WhenAll(t1, t2);
}async Task DoStuff()
{
    await Task.Delay(500);
    Thread.Sleep(500);
}

Now, the DoStuff() calls will delay and return immediately, while the continuation will take place on a random ThreadPool thread, not blocking the main thread this time.

However, a more common way to solve this is to use Task.Run() to (in our case) start the DoStuff() method, as Task.Run() ensures that the delegate we pass to it is started directly on the Threadpool, also keeping the main thread free.

This is something important to keep in mind, as the structure, as well as the respective CPU or IO nature of the unit of work of async methods can mean precious time in overall execution.

That’s it for now! If this post turns out to be well received, I will continue with another set of common pitfalls soon.