A Simple Trick to Boost Performance of Async Code in C#

davidkroell

David Kröll

Posted on February 8, 2022

A Simple Trick to Boost Performance of Async Code in C#

Performance is something every developer cares about. Even if most of us care about it too early, sooner or later it will impact the user experience if we do not improve performance. A very crucial part of performance is the latency a user is exposed to.

TL; DR:

Use Task.WhenAll() to execute tasks in parallel, if the following task does not depend on a return value of the previous one. This will reduce latency dramatically - without having to optimize every single code-path (if even possible).

Example

Imagine a platform which provides profile pages for users. This platform is able to connect to other social media platforms and load data from there. An example profile page would look like this:

An example profile page from a squirrel

An example profile page from a squirrel

There are different user profile infos printed. To have the latest data, every time a user accesses this profile page, the data is retrieved directly from the third party service.

To retrieve the data, a ProfileLoader class is used. This class returns some dummy data after a specific, artificial delay. I'll omit the implementation of the details-methods in this post. The full source code is available at GitHub (see Conclusion below).

public class ProfileLoader
{
    public async Task<Profile> GetProfileDetailsAsync(string userName)
    {
        var profile = await GetProfileMetaData(userName);

        profile.ProfilePicture = await GetProfilePicture(profile.UserId);
        profile.InstagramDetails = await GetInstagramDetailsAsync(profile.InstagramId);
        profile.GithubDetails = await GetGithubDetailsAsync(profile.GithubId);
        profile.LinkedInDetails = await GetLinkedInDetailsAsync(profile.LinkedInId);

        return profile;
    }
}
Enter fullscreen mode Exit fullscreen mode

For this example, I've created a console application which prints the profile details as JSON to the console, along with how long it took to gather the data. The elapsed time is measured with the Stopwatch.

public static async Task Main()
{
    var profileLoader = new ProfileLoader();

    var sw = Stopwatch.StartNew();

    var profile = await profileLoader.GetProfileDetailsAsync("TheAwesomeSquirrel");

    Console.WriteLine($"Profile details retrieved, took {sw.ElapsedMilliseconds}ms");

    Console.WriteLine(JsonSerializer.Serialize(profile, new JsonSerializerOptions
    {
        WriteIndented = true
    }));

    // Output: 
    // Profile details retrieved, took 1528ms
    // ...
}
Enter fullscreen mode Exit fullscreen mode

According to the code above, the tasks are executed in an asynchronous manner, but still one after another. The thread does not block (because it is async) but the execution is not parallel either.

Flowchart when using sequential execution

These are the steps executed one after another. The numbers on the x-axis are the artificial delays.

Every await schedules a thread on the thread pool and waits for the result. After the method returns, the thread continues in the original method. In the diagram above, you can see how the execution is done. Every subsequent task does not start before the current one has completed.

This is the part which can be optimized easily with Task.WhenAll(). Task.WhenAll() allows us to start all tasks at the same time and wait until every single task has completed it's operation.

No task therefore delays the execution of another one.

Flowchart when using parallel execution with Task.WhenAll

These are the steps executed in parallel. The numbers on the x-axis are the artificial delays.

Below is the new implementation of the ProfileLoader. All the methods which are called in here to get the details were not touched.

public class ProfileLoader
{   
    public async Task<Profile> GetProfileDetailsFastAsync(string userName)
    {
        var profile = await GetProfileMetaData(userName);

        var profilePictureTask = GetProfilePicture(profile.UserId);
        var instagramDetailsTask = GetInstagramDetailsAsync(profile.InstagramId);
        var githubDetailsTask = GetGithubDetailsAsync(profile.GithubId);
        var linkedInDetailsTask = GetLinkedInDetailsAsync(profile.LinkedInId);

        // start all tasks in parallel and wait until all are completed
        await Task.WhenAll(profilePictureTask, instagramDetailsTask, githubDetailsTask, linkedInDetailsTask);

        // await does not wait here, because tasks are already completed
        profile.ProfilePicture = await profilePictureTask;
        profile.InstagramDetails = await instagramDetailsTask;
        profile.GithubDetails = await githubDetailsTask;
        profile.LinkedInDetails = await linkedInDetailsTask;

        return profile;
    }
}

public static async Task Main()
{
    var profileLoader = new ProfileLoader();

    var sw = Stopwatch.StartNew();

    var profileFast = await profileLoader.GetProfileDetailsFastAsync("TheAwesomeSquirrel");

    System.Console.WriteLine($"Profile details retrieved, took {sw.ElapsedMilliseconds}ms");

    System.Console.WriteLine(JsonSerializer.Serialize(profileFast, new JsonSerializerOptions
    {
        WriteIndented = true
    }));

    // Output: 
    // Profile details retrieved, took 833ms
    // ...
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the total execution is much faster now. Still every method has it's artificial delay, but since it's now running in parallel, the total delay is much lower. Also the overhead compared to when awaiting every task one after another is lower. Before, the overhead (in addition to the artifical delays) was 78ms, now it is reduced to 33ms.

Conclusion

Only by optimizing how tasks are awaited, a lot of performance can be gained. This is possible when method return values do not depend upon another one. Also the overhead in awaiting the tasks is reduced.

Note: The WhenAll() also has an overload which returns the result:
WhenAll<TResult>(IEnumerable<Task<TResult>>).
When all of your provided tasks have the same result type, an
array of results is returned, which you can then use to combine
all of them together.

Have you ever used Task.WhenAll() to improve the performance of your app?

You can view the whole source code on my GitHub.

💖 💪 🙅 🚩
davidkroell
David Kröll

Posted on February 8, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related