📜 ⬆️ ⬇️

C #: one use case for any tasks

Hi, Habr! We continue to talk about asynchronous programming in C #. Today we will talk about a single use case or user scenario suitable for any tasks within asynchronous programming. Let's talk about synchronization, deadlocks, operator settings, exception handling and much more. Join now!



Previous related articles
')

Virtually any non-standard behavior of asynchronous methods in C # can be explained on the basis of a single user scenario: the conversion of existing synchronous code to asynchronous should be as simple as possible. You must be able to add the async keyword before the returned method type, add the Async suffix to the name of this method, and add the await keyword here and in the text area of ​​the method to get a fully functional asynchronous method.



The “simple” scenario drastically changes many aspects of the behavior of asynchronous methods: from planning the duration of the task to the exception handling. The script looks convincing and meaningful, but in its context the simplicity of asynchronous methods becomes very deceptive.

Synchronization context


User Interface Development (UI) is one of the areas where the scenario described above is particularly important. Due to the lengthy operations in the user interface flow, the response time of applications increases, and in this case asynchronous programming has always been considered a very effective tool.

private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; // 1 -- UI Thread var result = await _stockPrices.GetStockPricesForAsync("MSFT"); // 2 -- Usually non-UI Thread textBox.Text = "Result is: " + result; //3 -- Should be UI Thread } 

The code looks very simple, but one problem arises. For most user interfaces, there are limitations: UI elements can be modified only by special threads. That is, in line 3, an error occurs if the duration of the task is scheduled in the thread from the thread pool. Fortunately, this issue has been known for a long time, and the concept of synchronization context has appeared in the .NET Framework 2.0 version.

Each UI provides special utilities for marshaling tasks into one or more specialized user interface threads. Windows Forms uses the Control.Invoke method, WPF - Dispatcher.Invoke, other systems can access other methods. The schemes used in all these cases are similar in many respects, but differ in details. The synchronization context allows you to abstract away the differences by providing an API for running code in a “special” context that provides handling of minor details with such derived types as WindowsFormsSynchronizationContext , DispatcherSynchronizationContext , etc.

To solve the problem related to the similarity of threads, C # programmers decided to enter the current synchronization context at the initial stage of the implementation of asynchronous methods and to plan all subsequent operations in such a context. Now each of the blocks between await statements is executed in the user interface thread, which makes it possible to implement the main script. However, this decision gave rise to a number of new problems.

Deadlock


Let's look at a small, relatively simple piece of code. Are there any problems here?

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { await Task.Yield(); return 42; } 

This code causes a deadlock . The user interface thread starts an asynchronous operation and waits synchronously for the result. However, the execution of the asynchronous method cannot be completed, since the second line GetStockPricesForAsync must be executed in the user interface thread that causes the deadlock.

You argue that this problem is fairly easy to solve. Yes indeed. It is necessary to prohibit all calls to the Task.Result or Task.Wait from the user interface code, however, the problem can still occur if the component used by such code synchronously waits for the result of the user operation:

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { // We know that the initialization step is very fast, // and completes synchronously in most cases, // let's wait for the result synchronously for "performance reasons". InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } // StockPrices.dll private async Task InitializeIfNeededAsync() => await Task.Delay(1); 

This code again causes deadlock. How to solve it:


The meaning of the first recommendation is clear, and the second we will explain below.

Configuring await statements


There are two reasons why a deadlock occurs in the last example: Task.Wait() in GetStockPricesForAsync and indirectly using the synchronization context at subsequent stages in InitializeIfNeededAsync. Although C # programmers do not recommend blocking calls to asynchronous methods, it is clear that in most cases this blocking is still used. C # programmers offer the following solution to a deadlock problem: Task.ConfigureAwait(continueOnCapturedContext:false) .

Despite the strange appearance (if the method is called without a named argument, it does not mean anything at all), this function fulfills its function: it ensures that the execution will continue without the synchronization context.

 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false); 

In this case, the continuation of the Task.Delay(1 ) task (here, an empty statement) is scheduled in the stream from the thread pool, and not in the user interface thread, which eliminates the deadlock.

Disable sync context


I know that ConfigureAwait actually solves this problem, but it itself generates much more. Here is a small example:

 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() { // Initialize the cache field first await _cache.InitializeAsync().ConfigureAwait(false); // Do some work await Task.Delay(1); } 

Do you see the problem? We used ConfigureAwait(false) , so everything should be fine. But not a fact.

ConfigureAwait(false) returns a custom awaiter ConfiguredTaskAwaitable object, and we know that it is used only if the task does not complete synchronously. That is, if _cache.InitializeAsync() completes synchronously, a deadlock is still possible.

To eliminate a deadlock, all tasks awaiting completion must be decorated with a call to the ConfigureAwait(false) method ConfigureAwait(false) . All this is annoying and causes mistakes.

Alternatively, you can use a custom awaiter object in all public methods to disable the synchronization context in the asynchronous method:

 private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public async Task<decimal> GetStockPricesForAsync(string symbol) { // The rest of the method is guarantee won't have a current sync context. await Awaiters.DetachCurrentSyncContext(); // We can wait synchronously here and we won't have a deadlock. InitializeIfNeededAsync().Wait(); return 42; } 

Awaiters.DetachCurrentSyncContext returns the following custom awaiter object:

 public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion { /// <summary> /// Returns true if a current synchronization context is null. /// It means that the continuation is called only when a current context /// is presented. /// </summary> public bool IsCompleted => SynchronizationContext.Current == null; public void OnCompleted(Action continuation) { ThreadPool.QueueUserWorkItem(state => continuation()); } public void UnsafeOnCompleted(Action continuation) { ThreadPool.UnsafeQueueUserWorkItem(state => continuation(), null); } public void GetResult() { } public DetachSynchronizationContextAwaiter GetAwaiter() => this; } public static class Awaiters { public static DetachSynchronizationContextAwaiter DetachCurrentSyncContext() { return new DetachSynchronizationContextAwaiter(); } } 

DetachSynchronizationContextAwaiter does the following: the async method works with a non-zero synchronization context. But if the async method works without a synchronization context, the IsCompleted property returns true, and the method continues to run synchronously.

This means the presence of service data close to zero, when the asynchronous method is executed from a thread in the thread pool, and the payment is made once for transferring the execution from the user interface thread to the thread from the thread pool.

The following are other benefits of this approach.


Exception Handling


What is the difference between these two options:

Task mayFail = Task.FromException (new ArgumentNullException ());

 // Case 1 try { await mayFail; } catch (ArgumentException e) { // Handle the error } // Case 2 try { mayFail.Wait(); } catch (ArgumentException e) { // Handle the error } 

In the first case, everything meets expectations - error handling is being performed, but in the second case this does not happen. The TPL parallel task library is created for asynchronous and parallel programming, and Task / Task can represent the result of several operations. That is why Task.Result and Task.Wait() always throw an AggregateException exception, which may contain several errors.

However, our main script changes everything: the user should be able to add an async / await operator, without touching the logic of error handling. That is, the await operator must be different from Task.Result / Task.Wait() : it must remove the wrapper from one exception in an AggregateException instance. Today we will choose the first exception.

Everything is fine if all Task-based methods are asynchronous and parallel computations are not used to perform tasks. But in some cases, everything is different:

 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException()); // await will rethrow the first exception await Task.WhenAll(task1, task2); } catch (Exception e) { // ArgumentNullException. The second error is lost! Console.WriteLine(e.GetType()); } 

Task.WhenAll returns a task with two errors, but the await operator retrieves and fills only the first one.

There are two ways to solve this problem:

  1. manually view tasks if they are accessible, or
  2. Configure the TPL library to force the exception to be thrown to another AggregateException exception.

 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException()); // t.Result forces TPL to wrap the exception into AggregateException await Task.WhenAll(task1, task2).ContinueWith(t => t.Result); } catch(Exception e) { // AggregateException Console.WriteLine(e.GetType()); } 

Async void method


The Task based method returns a token that can be used to process results in the future. If the task is lost, the token becomes inaccessible for reading by the user code. An asynchronous operation that returns a void method produces an error that cannot be processed in user code. In this sense, tokens are useless and even dangerous - we will see it now. However, our main scenario assumes their mandatory use:

 private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; var result = await _stockPrices.GetStockPricesForAsync("MSFT"); textBox.Text = "Result is: " + result; } 

But what if GetStockPricesForAsync gives an error? An unhandled async void method exception is marshaled to the current synchronization context, triggering the same behavior as for synchronous code (for more information, see the ThrowAsync Method on the AsyncMethodBuilder.cs web page). In Windows Forms, an unhandled exception in the event handler triggers the Application.ThreadException event, for the WPF, the Application.DispatcherUnhandledException event is fired, and so on.

And if the async void method does not get a sync context? In this case, an unhandled exception causes a fatal application crash. It will not trigger the [ TaskScheduler.UnobservedTaskException ] event being restored, but will trigger the unrecoverable AppDomain.UnhandledException event and then close the application. This is intentional, and this is the result we need.

Now let's consider another well-known way: using asynchronous void methods only for UI event handlers.

Unfortunately, the asynch void method is easy to call by accident.

 public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError) { // Calls 'provider' N times and calls 'onError' in case of an error. } public async Task<string> AccidentalAsyncVoid(string fileName) { return await ActionWithRetry( provider: () => { return File.ReadAllTextAsync(fileName); }, // Can you spot the issue? onError: async e => { await File.WriteAllTextAsync(errorLogFile, e.ToString()); }); } 

At first glance, it is difficult to say if a function is a Task-based method or an async void method, and therefore an error may creep into your code base, despite the most thorough check.

Conclusion


Many aspects of asynchronous programming in C # have been influenced by one user script — the simple conversion of the synchronous code of an existing user interface application to asynchronous:


Free cheese is only in a mousetrap. Ease of use can sometimes result in great difficulty in other areas. If you are familiar with the history of asynchronous programming in C #, the strangest behavior no longer seems so strange, and the likelihood of errors in asynchronous code is significantly reduced.

Source: https://habr.com/ru/post/421245/


All Articles