myesn

myEsn2E9

hi
github

C#: Asynchronous programming scenarios

If there are I/O-bound requirements (such as requesting data from the network, accessing databases, reading and writing to the file system), asynchronous programming is needed. Alternatively, if there is CPU-bound code (such as performing expensive calculations), it is also a good scenario for writing asynchronous code.

C# has a language-level asynchronous programming model that allows us to easily write asynchronous code without dealing with callbacks or conforming to libraries that support asynchronous operations. It follows the so-called Task-based Asynchronous Pattern (TAP).

Overview of Asynchronous Models#

The core of asynchronous programming is the Task and Task<T> objects, which model asynchronous operations. They are supported by the async and await keywords, and in most cases, this model is quite simple:

  • For I/O-bound code, you can await operations that return Task or Task<T> in asynchronous methods.
  • For CPU-bound code, you can use the Task.Run method to start an operation on a background thread and await its completion.

The await keyword is key to asynchronous programming. It hands control back to the caller of the method that executed await, ultimately achieving responsiveness of the interface or resilience of the service. While there are other ways to handle asynchronous code, this article focuses on language-level constructs.

I/O-bound Example: Downloading Data from a Web Service#

When a button is pressed, it may be necessary to download some data from a web service without blocking the UI thread. You can use the System.Net.Http.HttpClient class to implement this as follows:

private readonly HttpClient _httpClient = new HttpClient();

downloadButton.Clicked += async (o, e) =>
{
    // This line of code will return control to the UI thread while sending a request to the web service.
    //
    // At this point, the UI thread can freely perform other work.
    var stringData = await _httpClient.GetStringAsync(URL);
    DoSomethingWithData(stringData);
};

This code expresses the intent to asynchronously download data without getting bogged down in the details of interacting with Task objects.

CPU-bound Example: Performing Expensive Calculations for a Game#

Suppose we are writing a mobile game where pressing a button can deal damage to multiple enemies on the screen. Performing damage calculations can be very time-consuming, and if executed on the UI thread, the game will stutter due to the calculations!

The best approach is to use [Task.Run](http://Task.Run) to start a background thread to perform the calculations and use await to wait for the results. This way, the game's user interface remains smooth because the calculations are done in the background.

private DamageResult CalculateDamageDone()
{
    // Code omitted:
    //
    // Perform expensive calculations and return the result.
}

calculateButton.Clicked += async (o, e) =>
{
    // This line of code returns control to the UI while CalculateDamageDone() is executing.
    // The UI thread can freely perform other work.
    var damageResult = await Task.Run(() => CalculateDamageDone());
    DisplayDamage(damageResult);
};

This code clearly expresses the intent of the button click event, without needing to manually manage the background thread, and achieves a non-blocking implementation.

Internal Principles#

In C#, the compiler converts the code into a state machine to track the execution pause when encountering await and to resume execution after the background task is completed.

For those studying theory, this is the Promise Model of Asynchrony implementation.

Key Points to Understand#

  • Asynchronous code can be used for both I/O-bound and CPU-bound code, but the way it is used differs in each case.
  • Asynchronous code uses the Task<T> and Task structures to simulate the execution of background work.
  • The async keyword converts a method into an asynchronous method, allowing the use of the await keyword within the method body.
  • When applying the await keyword, it pauses the calling method and hands control back to the caller until the awaited task is complete.
  • await can only be used within asynchronous methods.

Identifying CPU-bound and I/O-bound Work#

The first two examples demonstrate how to use async and await in I/O-bound and CPU-bound work. It is important to be able to identify whether the task to be executed is I/O-bound or CPU-bound, as this greatly affects the performance of the code and may lead to misuse of certain structures.

Before writing any code, two questions should be considered:

  1. Will your code wait for something, such as data from a database?
    If "yes," then the task is I/O-bound.
  2. Will your code perform an expensive computation?
    If "yes," then the task is CPU-bound.

If the work is I/O-bound, then use async and await in the code without using Task.Run. It is even less appropriate to use the Task Parallel Library.

If the work is CPU-bound and responsiveness of the code is a concern, then you can use async and await, but you need to execute the work on another thread using Task.Run. If the work is suitable for concurrent and parallel processing, you can also consider using the Task Parallel Library.

Additionally, you should always measure the execution of the code. For example, it may be found that in a multithreaded scenario, CPU-bound work is not very expensive compared to the overhead of context switching. Each choice has its pros and cons, and the correct choice should be made based on the actual situation.

More Examples#

The following examples demonstrate various ways to write asynchronous code in C#. They cover several different scenarios you may encounter.

Fetching Data from the Web#

This code snippet downloads HTML from the https://dotnetfoundation.org homepage and counts the occurrences of the string ".NET" in the HTML. It uses ASP.NET to define a Web API controller method that will perform this task and return the count.

private readonly HttpClient _httpClient = new HttpClient();

[HttpGet, Route("DotNetCount")]
public async Task<int> GetDotNetCount()
{
    // Suspend GetDotNetCount() to allow the caller (web server) to receive another request instead of blocking on this request.
    var html = await _httpClient.GetStringAsync("https://dotnetfoundation.org");

    return Regex.Matches(html, @"\.NET").Count;
}

Here is the same scenario written for a Universal Windows app that will perform the same task when a button is pressed:

private readonly HttpClient _httpClient = new HttpClient();

private async void OnSeeTheDotNetsButtonClick(object sender, RoutedEventArgs e)
{  
    // Capture the task handle here to await the background task later.
    var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("https://dotnetfoundation.org");

    // Other UI-related work can be done here, such as enabling a progress bar.
    // This is important to complete before the "await" call so that the user can see the progress bar before the execution of this method pauses.
    NetworkProgressBar.IsEnabled = true;
    NetworkProgressBar.Visibility = Visibility.Visible;

    // The "await" operator will suspend the OnSeeTheDotNetsButtonClick() method, returning control to the caller. This is key to making the application responsive and not blocking the UI thread.
    var html = await getDotNetFoundationHtmlTask;
    int count = Regex.Matches(html, @"\.NET").Count;

    DotNetCountLabel.Text = $"Number of .NETs on dotnetfoundation.org: {count}";

    NetworkProgressBar.IsEnabled = false;
    NetworkProgressBar.Visibility = Visibility.Collapsed;
}

Waiting for Multiple Tasks to Complete#

In some cases, you may need to fetch multiple pieces of data in parallel. The Task API provides two methods, Task.WhenAll and Task.WhenAny, that allow us to write asynchronous code that non-blockingly waits for multiple background tasks.

The following example shows how to fetch user data for a set of user IDs:

public async Task<User> GetUserAsync(int userId)
{
    // Code omitted:
    //
    // Given a user ID {userId}, retrieve the user object corresponding to {userId} in the database.
}

public static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
{
    var getUserTasks = new List<Task<User>>();
    foreach (int userId in userIds)
    {
        getUserTasks.Add(GetUserAsync(userId));
    }

    return await Task.WhenAll(getUserTasks);
}

Here is another way to write this more concisely using LINQ:

public async Task<User> GetUserAsync(int userId)
{
    // Code omitted:
    //
    // Given a user ID {userId}, retrieve the user object corresponding to {userId} in the database.
}

public static async Task<User[]> GetUsersAsync(IEnumerable<int> userIds)
{
    var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
    return await Task.WhenAll(getUserTasks);
}

While using LINQ can reduce the amount of code, caution is needed when mixing LINQ with asynchronous code. Because LINQ uses deferred execution, asynchronous calls will not occur immediately unless the generated sequence is iterated by calling .ToList() or .ToArray(), as in a foreach loop. The above example uses Enumerable.ToArray to force the query to execute immediately and store the results in an array. This ensures that the code id => GetUserAsync(id) runs and starts the task.

Important Information and Recommendations#

In asynchronous programming, there are some details to keep in mind that can prevent unexpected behavior.

  • Asynchronous methods (async methods) need to use the await keyword in their body; otherwise, they will never produce a result!

    This is very important. If await is not used in the body of an asynchronous method, the C# compiler will generate a warning, but the code will compile and run just like a regular method. This is very inefficient because the state machine generated by the C# compiler for the asynchronous method does not implement any functionality.

  • Add "Async" as a suffix to the name of every asynchronous method you write.
    This is a convention used in .NET that makes it easier to distinguish between synchronous and asynchronous methods. Some methods (such as event handlers or web controller methods) may not necessarily apply to this convention. Since they are not explicitly called by your code, it is less important to name them explicitly.

  • async void should only be used for event handlers.

    async void is the only way to allow asynchronous event handlers to work because events do not have a return type (thus cannot use Task and Task<T>). Any other use of async void does not conform to the TAP model and can be difficult to work with, for example:

    • Exceptions thrown in an async void method cannot be caught outside that method.
    • It is difficult to test async void methods.
    • If the caller does not expect them to be asynchronous, async void methods can have undesirable side effects.
  • Be cautious when using asynchronous lambda expressions in LINQ expressions.

    Lambda expressions in LINQ use deferred execution, meaning the code may execute at times we do not want it to. If not written correctly, introducing blocking tasks can easily lead to deadlocks. Additionally, nesting asynchronous code like this can make understanding the execution of the code more difficult. Async and LINQ are both powerful tools, but care should be taken when using both together.

  • Write code that waits for tasks to complete in a non-blocking way.

    Waiting for a task to complete by blocking the current thread can lead to deadlocks and block context threads, and may require more complex error handling. The following table provides guidelines on how to handle waiting for tasks in a non-blocking way:

    To do this...Use this...Instead of…
    Get the result of a background taskawaitTask.Wait or Task.Result
    Wait for any task to completeawait Task.WhenAnyTask.WaitAny
    Wait for all tasks to completeawait Task.WhenAllTask.WaitAll
    Wait for a period of timeawait Task.DelayThread.Sleep
  • Consider using ValueTask where possible.

    Returning a Task object from an asynchronous method may introduce performance bottlenecks on certain paths. Task is a reference type, so using it means allocating an object. In methods marked with the async modifier that return cached results or complete synchronously, the extra allocation can become a significant time overhead in performance-critical parts of the code. If these allocations occur in tight loops, the cost can become even higher. For more information, see Generalized async return types.

    The ValueTask struct is an alternative provided by .NET for optimizing performance. ValueTask can avoid allocations of Task in certain cases and can improve performance when asynchronous operations return results directly. In performance-critical scenarios, consider using ValueTask to reduce unnecessary memory allocations and improve performance.

  • Consider using ConfigureAwait(false).

    A common question is, “When should I use the Task.ConfigureAwait(Boolean) method?” This method allows a Task instance to configure its awaiter. This is an important consideration, as incorrect settings can lead to performance issues or even deadlocks. For more information on ConfigureAwait, see the ConfigureAwait FAQ.

    In asynchronous programming, using ConfigureAwait(false) can inform the runtime not to return the waiting operation to the original context, thus improving performance and reducing the overhead of context switching. However, in certain cases, such as operations that need to access the UI thread or need to ensure that code executes in a specific context, ConfigureAwait(false) should be used with caution. Weigh and decide based on the specific situation to ensure the correctness and performance optimization of the code.

  • Write stateless code.

    Do not rely on the state of global objects or the execution of specific methods. Instead, rely solely on the return values of methods. Why?

    • The code is easier to understand.
    • The code is easier to test.
    • Mixing asynchronous and synchronous code is simpler.
    • Race conditions can often be completely avoided.
    • Relying on return values makes coordinating asynchronous code simple.
    • (Bonus benefit) Works very well with dependency injection.

    By reducing reliance on state, we can improve the maintainability and testability of the code. Additionally, avoiding the use of global state can reduce the occurrence of race conditions and concurrency issues. Relying on method return values allows for a clearer understanding of code behavior and simplifies writing and debugging code. In summary, adopting a stateless programming style can enhance code quality and development efficiency.

The goal is to achieve complete or near-complete referential transparency in the code. Doing so will produce a predictable, testable, and maintainable codebase.

References#

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.