DEV Community

Cover image for A Simple Trick to Boost Performance of Async Code in C#
David Kröll
David Kröll

Posted on • Originally published at davidkroell.com

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.

Discussion (9)

Collapse
sergioluis profile image
Sergio

Why do you need to call await Task.WhenAll(...);? Doesn't a task start running even before the async method returns the Task<TResult> that represents said task?

Considering that to be true, Task.WhenAll is now irrelevant and can be removed.

var profilePictureTask = GetProfilePicture(profile.UserId);
var instagramDetailsTask = GetInstagramDetailsAsync(profile.InstagramId);
var (...);

// No "await Task.WhenAll" here

profile.ProfilePicture = await profilePictureTask;
profile.InstagramDetails = await instagramDetailsTask;
profile.(...);
Enter fullscreen mode Exit fullscreen mode

Now you can start awaiting on the tasks one by one immediately - the time to completion would be the time that takes the slowest of the tasks to complete (plus the irrelevant overhead of the rest of the operations - awaiting the tasks, asigning the results...).

Collapse
andreasjakof profile image
Andreas Jakof

My thoughts as well.
Just start the tasks and store them in a variable. await them later, when you definitly need the results.
Much easier to read, in my opinion.

Collapse
davidkroell profile image
David Kröll Author

Yes, you are absolutely right. Thank you for your Feedback.

In this case there is no difference. The only thing which would make a difference is, that you could ensure that after awaiting the Task.WhenAll, all supplied tasks are completed as well and you can get the results with the property Result instead of awaiting them again.

Thread Thread
andreasjakof profile image
Andreas Jakof

And this is also the Performance gain, you could have, when calling and awaiting them individually. Only the tasks, which give some imideatly needed results, need to be finished, potentially giving longer running tasks more time to finish, while already working with some results, that are needed in an earlier step.

In the making breakfast example this would be boiling an egg starting in cold water (6:30 min) and toasting a bread (2:00 min) butter it (0:30 min) and adding some Jam (0:30 min).

You start the egg and the toasting at the same time. In your case, you await both, which means you start buttering the toast, after the egg is finished. You could use .ContinueWith(…), but that is another approach alltogether.

If you know that (like in our case) the egg will always take longer than the other tasks together, you could

  • start the egg and „keep track“ by storing it in a variable.
  • toast and immediately await it
  • butter + await and Jam + await
  • await the egg. => 6:30 min instead of 7:30 min

You could also (and this case is a perfekt example for it)

  • start the egg and store the task
  • start toast - continuewith butter - continuewith jam and store the task
  • await both (Task.WhenAll or individually) => 6:30 min as well
Thread Thread
davidkroell profile image
David Kröll Author

Yes thats true if you can determine the runtimes that exact this would be a neat option.

Thread Thread
andreasjakof profile image
Andreas Jakof

Therefore the last option with ContinueWith is the better (best?) solution. Even if you don‘t know the runtimes exacty. It makes sure, run the tasks in parallel, that can while also making Visual, that these tasks (toast, butter, jam) are in sequence.

Nevertheless it is usually enough to know that A takes longer than B to start them in the „right“ order.

Collapse
shayanfiroozi profile image
Shayan Firoozi • Edited on

Nice job !

  • If you have thousands of tasks ( Specially CPU bound tasks ) , it's better to use Parallel class ( which gives you a real parallelism behaviour )
Collapse
davidkroell profile image
David Kröll Author

Thank you for the addition. Will the Parallel class also limit the number of current Tasks executed?

Collapse
shayanfiroozi profile image
Shayan Firoozi • Edited on
  • Parallel class is a real parallelism which does your job(s) with some smart threads ! ( specially in .NET 5)
    when a thread finished its job , it will help the other thread(s) !

  • Task.WhenAll is more concurrent not parallel.
    in some cases WhenAll run faster , but in real-time apps with high CPU bound operations we should use Parallel class.
    Also WhenAll creates thousands of tasks which make an high overload on CPU.

  • Here is a Becnhmark :

dev-to-uploads.s3.amazonaws.com/up...

"CreateWords_Parallel/WhenAll" method creates 100,000 random encrypted string.

Please note that how many Tasks been created in "Completed Work Item" column.
28 vs 100,000 !!

Parallel.For done its job with just 28 threads , but Task.WhenAll done it by 100_000 tasks ;)

That's the real difference between them.