Asynchronous rasynchron: antipatterns in work with async / await in .NET

Who among us does not mow? I regularly meet with errors in asynchronous code and make them myself. To stop this wheel of the Samsara, I share with you the most typical shoals of those that are sometimes quite difficult to catch and repair.



This text is inspired by Stephen Clary 's blog , a person who knows everything about competitiveness, asynchrony, multithreading and other scary words. He is the author of the book Concurrency in C # Cookbook , which has collected a huge number of patterns for working with competitiveness.

Classic asynchronous deadlock


To understand the asynchronous deadlock, it is worth understanding which thread the method called using the await keyword is executed on.


First, the method will go deeper into the chain of calls to async methods until it encounters a source of asynchrony. How exactly the asynchronous source is implemented is a topic that goes beyond the scope of this article. Now, for simplicity, we assume that this is an operation that does not require a workflow while waiting for its result, for example, a database request or an HTTP request. The synchronous launch of such an operation means that while waiting for its result in the system there will be at least one falling asleep thread that consumes resources but does not perform any useful work.


In an asynchronous call, we kind of break the command flow to “before” and “after” the asynchronous operation and in .NET there are no guarantees that the code lying after await will be executed in the same thread as the code before await. In most cases, this is not necessary, but what to do when this behavior is vital for the program to work? Need to use SynchronizationContext. This is a mechanism that allows you to impose certain restrictions on the threads in which the code is executed. Next, we will deal with two synchronization contexts ( WindowsFormsSynchronizationContextand AspNetSynchronizationContext), but Alex Davis writes in his book that there are about a dozen in .NET. About SynchronizationContextwell written here , here , and here the author realized his own, for which he had great respect.


So, as soon as the code comes to the asynchrony source, it saves the synchronization context that was in the thread-static property SynchronizationContext.Current, then it starts the asynchronous operation and releases the current thread. In other words, while we wait for the completion of an asynchronous operation, we do not block any thread and this is the main profit from an asynchronous operation compared to a synchronous one. After the completion of the asynchronous operation, we must follow the instructions that are after the asynchronous source and here, in order to decide in which thread we execute the code after the asynchronous operation, we need to consult with the previously saved synchronization context. As he says, so we will do. He will say to execute in the same thread as the code before await - we will execute in the same, it will not say - we will take the first available stream from the pool.


And what to do if in this particular case it is important for us that the code after await is executed in any free stream from the thread pool? Must use mantra ConfigureAwait(false). The false value passed to the parameter continueOnCapturedContext just informs the system that any stream from the pool can be used. And what will happen if at the moment of executing the method with await there was no synchronization context at all ( SynchronizationContext.Current == null), as for example in a console application. In this case, we have no restrictions on the thread in which the code should be executed after await and the system will take the first available thread from the pool, as is the case with ConfigureAwait(false).


So, what is asynchronous deadlock?


Deadlock in WPF and WinForms


The difference between WPF and WinForms applications is the presence of the synchronization context itself. The WPF and WinForms synchronization context has a special stream - the user interface stream. The UI stream is one on SynchronizationContextand only from this stream can interact with the elements of the user interface. By default, the code that started working in the UI thread resumes operation after an asynchronous operation in it.


Now let's look at an example:

privatevoidButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
    StartWork().Wait();
}
privateasync Task StartWork()
{
    await Task.Delay(100);
    var s = "Just to illustrate the instruction following await";
}

What happens when you call StartWork().Wait():

  1. The calling thread (and this is the user interface thread) will enter the method StartWorkand reach the instruction await Task.Delay(100).
  2. The UI thread will start an asynchronous operation Task.Delay(100), and it will return control to the method Button_Click, and there the Wait()class method is waiting for it Task. When the method is called, the Wait()UI thread will be blocked until the end of the asynchronous operation, and we expect that as soon as it is completed, the UI thread will immediately pick up the execution and go further along the code, however, everything will be wrong.
  3. Once Task.Delay(100)completed, the UI thread must first continue to execute the method StartWork()and for this it needs the exact thread in which the execution started. But the UI thread is currently busy waiting for the result of the operation.
  4. Deadlock: StartWork()can not continue execution and return the result, but Button_Clickis waiting for the same result, and due to the fact that execution started in the user interface thread, the application simply hangs without a chance to continue working.

Such a situation can be quite easily cured by changing the call Task.Delay(100)to Task.Delay(100).ConfigureAwait(false):

privatevoidButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
    StartWork().Wait();
}
privateasync Task StartWork()
{
    await Task.Delay(100).ConfigureAwait(false);
    var s = "Just to illustrate the instruction following await";
}

This code will work without deadlocks, since now the StartWork()thread from the pool can be used to complete the method , and not the blocked UI thread. In his blog, Stephen Clary recommends using it ConfigureAwait(false)in all “library methods,” but he specifically emphasizes that it ConfigureAwait(false)is not a good practice to use deadlock to treat. Instead, he advises not to use blocking techniques such as Wait(), Result, GetAwaiter().GetResult()and translate all the techniques to use the async / await, if possible (so-called principle Async all the way).


Deadlock in ASP.NET


ASP.NET also has a synchronization context, but it has some other limitations. It permits the use of only one thread per request at the same time and also requires that the code after await be executed in the same thread as the code before await.


Example:

publicclassHomeController : Controller
{
    public ActionResult Deadlock()
    {
        StartWork().Wait();
        return View();
    }
    privateasync Task StartWork()
    {
        await Task.Delay(100);
        var s = "Just to illustrate the code following await";
    }
}

This code will also call deadlock, because at the time of the call the StartWork().Wait()only allowed thread will be blocked and wait for the operation to end StartWork(), but it will never end, since the thread in which the execution should continue is busy waiting.


It fixes all the same ConfigureAwait(false).


Deadlock in ASP.NET Core (actually not)


Now let's try to run the code from the example for ASP.NET in the project for ASP.NET Core. If we do this, we will see that there will be no deadlock. This is due to the fact that there is no synchronization context in ASP.NET Core . Fine! And what, now you can cover the code with blocking calls and not be afraid of deadlocks? Strictly speaking, yes, but remember that this causes the flow to fall asleep while waiting, that is, the flow consumes resources, but does not perform any useful work.




Remember that using blocking calls eliminates all the benefits of asynchronous programming, making it synchronous . Yes, sometimes without use Wait()it will not be possible to write a program, but the reason must be serious.

Erroneous use of Task.Run ()


The method Task.Run()was created to start operations in a new thread. As it should be for a method written using a TAP pattern, it returns Taskor Task<T>even people who first encounter async / await have a great desire to wrap the synchronous code in Task.Run()and output the result of this method. The code seemed to become asynchronous, but in fact nothing has changed. Let's see what happens with this use Task.Run().


Example:

privatestaticasync Task ExecuteOperation()
{
    Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}");
    await Task.Run(() => 
    {
        Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}");
        Thread.Sleep(1000);
        Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}");
    });
    Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}");
}

The result of this code will be:

Before: 1
Inside before sleep: 3
Inside after sleep: 3
After: 3

Here Thread.Sleep(1000)is any synchronous operation that requires a thread to perform. Suppose we want to make our solution asynchronous, and so that this operation can be evait, we wrapped it in Task.Run().


As soon as the code reaches the method Task.Run(), another thread gets from the thread pool and executes the code that we passed to Task.Run(). The old stream, as it should be for a decent stream, returns to the pool and waits for him to be called to do the work again. The new thread executes the transferred code, reaches the synchronous operation, synchronously executes it (waits until the operation is completed) and proceeds along the code. In other words, the operation remained synchronous: we, as before, use the stream during the execution of a synchronous operation. The only difference is that we spent time switching the context when calling Task.Run()and returning to ExecuteOperation(). Things got a little worse.


We must understand that in spite of the fact that the lines Inside after sleep: 3and After: 3we see the same flow Id, in these places are very different execution context. Just ASP.NET smarter than us and tries to save resources when switching context from code inside Task.Run()to external code. Here he decided not to change at least the flow of execution.


In such cases, there is no point in using Task.Run(). Instead, Clary advises making all operations asynchronous, that is, in our case, replace Thread.Sleep(1000)with Task.Delay(1000), but this, of course, is not always possible. What to do in cases when we use third-party libraries that we cannot or do not want to rewrite and make asynchronous until the end, but for one reason or another we need the async method? It is better to use Task.FromResult()for wrapping the result of the work of vendor methods in Task. This, of course, does not make the code asynchronous, but at least we will save on context switching.


Why then use Task.Run ()? The answer is simple: for CPU-bound operations, when you need to maintain responsiveness of the UI or parallelize the calculations. Here it must be said that CPU-bound operations are synchronous in nature. It was for the start of synchronous operations in asynchronous style that was invented Task.Run().

Using async void is not as intended


The ability to write asynchronous return methods voidwas added in order to write asynchronous event handlers. Let's see why they can make confusion, if they are used for other purposes:

  1. You can not wait for the result.
  2. Exception handling via try-catch is not supported.
  3. It is impossible to combine calls through Task.WhenAll(), Task.WhenAny()and other similar methods.

Of all the reasons listed, the most interesting point is the exception handling. The fact is that in async methods that return Taskor Task<T>, exceptions are caught and wrapped in an object Task, which will then be passed to the caller. In his MSDN article, Clary writes that since there are no return values ​​in the async-void methods, there’s no way to wrap exceptions in the context of synchronization. The result is an unhandled exception due to which the process crashes, succeeding, except to write an error to the console. You can get and deposit such exceptions by subscribing to an event.AppDomain.UnhandledException, but it will not be possible to stop the crash of the process even in the handler of this event. This behavior is typical just for the event handler, but not for the usual method, from which we expect the possibility of standard exception handling via try-catch.


For example, if you write this in an ASP.NET Core application, the process is guaranteed to fall:

public IActionResult ThrowInAsyncVoid()
{
    ThrowAsynchronously();
    return View();
}
privateasyncvoidThrowAsynchronously()
{
    thrownew Exception("Obviously, something happened");
}

But it is worth changing the type of the return value of the method ThrowAsynchronouslyto Task(even without adding the await keyword) and the exception will be caught by the standard error handler ASP.NET Core, and the process will continue to live despite the exception.


Be careful with async-void methods - they can put you in the process.

await in one line method


The latest anti-pattern is not as terrible as the previous ones. The bottom line is that it makes no sense to use async / await in methods that, for example, simply forward the result of another async method further, with the possible exception of using await in using .


Instead of this code:

publicasync Task MyMethodAsync()
{
    await Task.Delay(1000);
}

it is quite possible (and preferable) to write:
public Task MyMethodAsync()
{
    return Task.Delay(1000);
}

Why does this work? Because the await keyword can be applied to Task-like objects, and not to methods marked with the async keyword. In turn, the async keyword just tells the compiler that this method needs to be expanded into a state machine, and all the returned values ​​are wrapped into Task(or into another Task-like object).


In other words, the result of the first version of the method is Task, which becomes Completedas soon as the wait ends Task.Delay(1000), and the result of the second version of the method is Taskreturned by itself Task.Delay(1000), which becomes Completedas soon as 1000 milliseconds pass.


As you can see, both versions are equivalent, but at the same time, the first requires much more resources to create an asynchronous "body kit."


Alex Davis writes that costs directly to calling an asynchronous method can be ten times more than the cost of calling a synchronous method , so there is something to try for.


UPD:
As rightly observed in the comments, cutting out async / await from single-line methods leads to negative side effects. For example, when throwing an exception, the method that forwards Task to the top will not be visible in the stack. Therefore, removing default bytes is not recommended . Post Clary with analysis.

Also popular now: