ConfigureAwait, who is to blame and what to do?

In my practice, I often see, in a different environment, code like the one below:







[1] var x = FooWithResultAsync(/*...*/).Result; // [2] FooAsync(/*...*/).Wait(); // [3] FooAsync(/*...*/).GetAwaiter().GetResult(); // [4] FooAsync(/*...*/) .ConfigureAwait(false) .GetAwaiter() .GetResult(); // [5] await FooAsync(/*...*/).ConfigureAwait(false) //  [6] await FooAsync(/*...*/)
      
      





From communication with the authors of such lines, it became clear that they are all divided into three groups:









Is the risk possible, and how large is it, when using the code, as in the examples above, depends, as I noted earlier, on the environment .













Risks and their causes



Examples (1-6) are divided into two groups. The first group is code that blocks the calling thread. This group includes (1-4).

Blocking a thread is most often a bad idea. Why? For simplicity, we assume that all threads are allocated from some thread pool. If the program has a lock, then this can lead to the selection of all threads from the pool. In the best case, this will slow down the program and lead to inefficient use of resources. In the worst case, this can lead to deadlock, when an additional thread is needed to complete some task, but the pool cannot allocate it.

Thus, when a developer writes code like (1-4), he should think about how likely the situation described above is.







But things get much worse when we work in an environment in which there is a synchronization context that is different from the standard. If there is a special synchronization context, blocking the calling thread increases the likelihood of a deadlock occurring many times. So, the code from examples (1-3), if it is executed in the WinForms UI thread, is almost guaranteed to create deadlock. I write "practically" because there is an option when this is not so, but more on that later. Adding ConfigureAwait(false)



, as in (4), will not give a 100% guarantee of protection against deadlock. The following is an example to confirm this:







 [7] //   /  . async Task FooAsync() { // Delay   .     . await Task.Delay(5000); //       RestPartOfMethodCode(); } //  ""  ,   ,  WinForms . private void button1_Click(object sender, EventArgs e) { FooAsync() .ConfigureAwait(false) .GetAwaiter() .GetResult(); button1.Text = "new text"; }
      
      





The article "Parallel Computing - It's All About the SynchronizationContext" provides information on various synchronization contexts.







In order to understand the cause of the deadlock, you need to analyze the code of the state machine into which the invocation of the async method is converted, and then the code of the MS classes. An Async Await and the Generated StateMachine article provides an example of such a state machine.

I will not give the full source code generated for example (7), the automaton, I will show only the lines that are important for further analysis:







 //  MoveNext. //... //  taskAwaiter    . taskAwaiter = Task.Delay(5000).GetAwaiter(); if(tasAwaiter.IsCompleted != true) { _awaiter = taskAwaiter; _nextState = ...; _builder.AwaitUnsafeOnCompleted<TaskAwaiter, ThisStateMachine>(ref taskAwaiter, ref this); return; }
      
      





The if



branch is executed if the asynchronous call ( Delay



) has not yet been completed and, therefore, the current thread can be freed.

Please note that in AwaitUnsafeOnCompleted



, taskAwaiter is received from an internal (relative to FooAsync



) asynchronous call ( Delay



).







If we plunge into the jungle of MS sources that are hidden behind the AwaitUnsafeOnCompleted



call, then, in the end, we will come to the SynchronizationContextAwaitTaskContinuation class, and its base class AwaitTaskContinuation , where the answer to the question is located.







The code of these, and related classes, is rather confusing, therefore, to facilitate perception, I allow myself to write a very simplified "analog" of what example (7) turns into, but without a state machine, and in terms of TPL:







 [8] Task FooAsync() { //  methodCompleted    ,  , //    ,     " ". //    ,   methodCompleted.WaitOne()  , //   SetResult  AsyncTaskMethodBuilder, //       . var methodCompleted = new AutoResetEvent(false); SynchronizationContext current = SynchronizationContext.Current; return Task.Delay(5000).ContinueWith( t=> { if(current == null) { RestPartOfMethodCode(methodCompleted); } else { current.Post(state=>RestPartOfMethodCode(methodCompleted), null); methodCompleted.WaitOne(); } }, TaskScheduler.Current); } // // void RestPartOfMethodCode(AutoResetEvent methodCompleted) // { //      FooAsync. // methodCompleted.Set(); // }
      
      





In the example (8), it is important to pay attention to the fact that if there is a synchronization context, all the code of the asynchronous method that comes after the completion of the internal asynchronous call is executed through this context (call current.Post(...)



). This fact is the cause of deadlocks. For example, if we are talking about a WinForms application, then the synchronization context in it is associated with the UI stream. If the UI thread is blocked, in example (7) this happens through a call to .GetResult()



, then the rest of the code of the asynchronous method cannot be executed, which means that the asynchronous method cannot end, and cannot release the UI thread, which is deadlock.







In example (7), the call to FooAsync



was configured via ConfigureAwait(false)



, but this did not help. The fact is that you need to configure exactly the wait object that will be passed to AwaitUnsafeOnCompleted



, in our example, this is the wait object from the Delay



call. In other words, in this case, calling ConfigureAwait(false)



in the client code does not make sense. You can solve the problem if the developer of the FooAsync



method changes it as follows:







 [9] async Task FooAsync() { await Task.Delay(5000).ConfigureAwait(false); //       RestPartOfMethodCode(); } private void button1_Click(object sender, EventArgs e) { FooAsync().GetAwaiter().GetResult(); button1.Text = "new text"; }
      
      





Above, we examined the risks that arise with the code of the first group - the code with blocking (examples 1-4). Now about the second group (examples 5 and 6) - a code without locks. In this case, the question arises, when is the call to ConfigureAwait(false)



justified? When parsing example (7), we already found out that we need to configure the waiting object on the basis of which the continuation of execution will be built. Those. configuration is required (if you make this decision) only for internal asynchronous calls.







Who is guilty?



As always, the correct answer is "everything." Let's start with the programmers from MS. On the one hand, Microsoft developers decided that, in the presence of a synchronization context, work should be carried out through it. And this is logical, otherwise why is it still needed. And, as I believe, they expected that the developers of the "client" code would not block the main thread, especially if the synchronization context is tied to it. On the other hand, they gave a very simple tool to "shoot yourself in the foot" - it is too simple and convenient to get the result through blocking .Result/.GetResult



, or block the stream, while waiting for the call to end, through .Wait



. Those. MS developers have made it possible that the “incorrect” (or dangerous) use of their libraries does not cause any difficulties.







But there is also blame on the developers of the "client" code. It consists in the fact that, often, developers do not try to understand their tool and neglect warnings. And this is a direct path to mistakes.







What to do?



Below I give my recommendations.







For client code developers



  1. Do your best to avoid blocking. In other words, do not mix synchronous and asynchronous code without special need.
  2. If you have to do a lock, then determine in which environment the code is executed:

    • Is there a synchronization context? If so, which one? What features does he create in his work?
    • If the synchronization context is “no,” then: What will be the load? What is the likelihood that your block will lead to a "leak" of threads from the pool? Will the number of threads created at the start be enough by default, or should I allocate more?
  3. If the code is asynchronous, do you need to configure the asynchronous call through ConfigureAwait



    ?


Make a decision based on all the information received. You may need to rethink your implementation approach. Perhaps ConfigureAwait



will help you, or maybe you do not need it.







For library developers



  1. If you believe that your code can be called from "synchronous", then be sure to implement a synchronous API. It must be truly synchronous, i.e. You must use the synchronous API of third-party libraries.
  2. ConfigureAwait(true / false)



    .


Here, from my point of view, a more subtle approach is needed than usually recommended. Many articles say that in library code, all asynchronous calls must be configured through ConfigureAwait(false)



. I can not agree with that. Perhaps, from the point of view of the authors, Microsoft colleagues made the wrong decision when choosing the "default" behavior in relation to working with the synchronization context. But they (MS), nevertheless, left the opportunity for the developers of the "client" code to change this behavior. The strategy, when the library code is completely covered by ConfigureAwait(false)



, changes the default behavior, and, more importantly, this approach deprives developers of the "client" code of choice.







My option is to, when implementing the asynchronous API, add two additional input parameters to each API method: CancellationToken token



and bool continueOnCapturedContext



. And implement the code as follows:







 public async Task<string> FooAsync( /*  */, CancellationToken token, bool continueOnCapturedContext) { // ... await Task.Delay(30, token).ConfigureAwait(continueOnCapturedContext); // ... return result; }
      
      





The first parameter, token



, serves, as you know, for the possibility of coordinated cancellation (library developers sometimes neglect this feature). The second, continueOnCapturedContext



- allows you to configure interaction with the synchronization context of internal asynchronous calls.







At the same time, if the asynchronous API method is itself part of another asynchronous method, the "client" code will be able to determine how it should interact with the synchronization context:







 //     : async Task ClientFoo() { // ""  ClientFoo   ,     //   FooAsync   . await FooAsync( /*  */, ancellationToken.None, false); //     . await FooAsync( /*  */, ancellationToken.None, false).ConfigureAwait(false); //... } // ,  . private void button1_Click(object sender, EventArgs e) { FooAsync( /*  */, _source.Token, false).GetAwaiter().GetResult(); button1.Text = "new text"; }
      
      





In conclusion



The main conclusion from the foregoing is the following three thoughts:







  1. Locks are most often the root of all evil. It is the presence of locks that can lead, in the best case, to degradation of performance and inefficient use of resources, in the worst - to deadlock. Before you use locks, think about whether this is necessary? Perhaps there is another way of synchronization acceptable in your case;
  2. Learn the tool you are working with;
  3. If you are designing libraries, then try to make sure that their correct use is easy, almost intuitive, and the wrong one is fraught with complexity.


I tried to explain as simply as possible the risks associated with async / await, and the reasons for their occurrence. And also, presented my vision of solving these problems. I hope that I succeeded, and the material will be useful to the reader. In order to better understand how everything actually works, you must, of course, refer to the source. This can be done through the MS repositories on GitHub or, even more conveniently, through the MS website itself.







PS I would be grateful for constructive criticism.








All Articles