How YOU can make your .NET programs more responsive with async/await in .NET Core, C# and VS Code

Chris Noring - Nov 12 '19 - - Dev Community

Follow me on Twitter, happy to take your suggestions on topics or improvements /Chris

When we run synchronous code we block the main Thread from doing anything else than just what's it's doing currently. This makes your software and user experience slower than it needs to be.

TLDR; we have the concept of Threads in .NET/.NET Core and they are an excellent way to schedule work to be carried out in parallel. However, they might be cumbersome to use. There is, however, a library called TPL, Task Parallel Library that lives on top of the Thread model and makes it really easy to schedule and manage work.

References

WHAT

So we mentioned TPL as a library. What do we need to know? TPL is such a central and important concept that it lives in the core APIs. It's part of the System.Threading and System.Threading.Tasks namespaces. It does a lot for us like:

  • Partitioning of the work
  • Scheduling of threads on the ThreadPool
  • Cancellation support
  • State management

and other low-level details.

There are some basic concepts that we need to understand.

  • Task, a task represent an asynchronous operation, like fetching content from a file or doing a calculation that takes time. There are some interesting properties on a Task that allows us to communicate to a UI, for example, how the asynchronous work is doing, like:
    • Status, this can tell us if it's currently working on something, is done, errored out or it was canceled
    • IsCanceled, if canceled this would be set to true
    • IsFaulted, if something went wrong, like an exception, this would be set to true
    • IsCompleted, once it has finished its operation it would be set to true
  • Async/Await. The await keyword means that we wait for the asynchronous operation to end and by the end of the operation we are given the result, e.g var fileContent = await GetFileAsync(). Any method that uses the await concept would need to have async keyword as part of the method header.
  • Blocking/Non-blocking. When we use Tasks we are not blocking and other Threads can carry out work. There are exceptions though when we use the method Wait() on a Task. Then we are forcing the code to run synchronously. We will show that in our demo in the next section.

WHY

A lot of things like opening up large files or carrying out a Web Request or maybe searching through your computer - are things that can be done in parallel. This means you can return back to the user much faster with a result and your app will be perceived as faster and more responsive. Web Development already uses the concept of Tasks heavily, which is a central concept in TPL. Learning how to use TPL can really make your applications more responsive. My hope is that you with this article feel more empowered to use TPL and Tasks.

DEMO

In our Demo we will demonstrate the following:

  • Authoring methods, How to author methods using async/await and how to return different types
  • Control flow, we will show how to wait for all as well as specific Tasks
  • Blocking code, we will show how the usage of Result as well as Wait() affects your code

Scaffold a project

Let's start by creating a solution like so:

mkdir tasks
cd tasks

dotnet new sln

This should create a solution file.

Next, we will create a console project like so:

dotnet new solution -o task-demo

and now add it to the solution like so:

dotnet sln add task-demo/task-demo.csproj

Ok, we are ready to start coding. Open up an IDE, I'm gonna go with VS Code.

Authoring methods

Let's open up the file Program.cs and add the following method inside of the class Program:

static async Task<int> Sum(int a, int b) 
{
    var result = await Task.FromResult(a + b);
    return result;
}

There are some interesting things that go on above:

  • Return type, Task<int>. This tells us that it will be a Task that once resolved will return something of type int.
  • Task.FromResult(), This creates a Task given a value. We give it the calculation to perform, e.g a+b.
  • Async/Await, We can see how we use the async keyword inside of the method to wait for the result to arrive back to us. This needs to be followed by the async keyword to ensure the compiler is happy.

It's easy to think that the above method above doesn't need to be asynchronous but imagine instead that this is a calculation that takes time, then it would make more sense.

One other thing, Task.FromResult is used when the answer is immediately known so it's status is RanToCompletion and the answer is available right away so you can argue that await is unnecessary, it's already available on the Task.Result property. Another way to do the above is:

static async Task<int> Sum2(int a, int ab) 
{
  var result = await Task.Run(() => {
    // do some time-consuming work
    return a + b;
  })
  return result;
}

await Sum2(1,2)

Control flow

There's more to Tasks than just marking them async. We can ensure to wait for all or some of the tasks to finish before carrying on with our code. We have some constructs that help us control this flow:

  • Task.WaitAll(), this one takes a list of Tasks in. What you are essentially saying is that all tasks need to finish before we can carry on, it's blocking. You can see that by it returning void A typical use-case is to wait for all Web Requests to finish cause we want to return a result that consists of us stitching all their data together
  • Task.WaitAny(), we give it a list of Tasks here as well but the meaning is different. We say that as long as any of the Task has finished we are good. This usually a race for data towards an endpoint or search for a file/file content on a disk. We don't care who finished first, as long as we get a response. This is also blocking and waiting for one of the Tasks to finish
  • Task.WhenAll(), this gives you a Task back that you can interact with. When all of the tasks have finished it will resolve.
  • Task.WhenAny(), this gives you a Task back that you can interact with. When one of the Tasks has finished then it will resolve.

Let's create a demo of a Control flow. We will fake carrying out time-consuming work by adding an additional method to our class, like so:

static async Task DoSomething()
{
  await Task.Delay(2000);
}

Demo - Control flow

Now we can add some control flow code in our Main() method like so:

var start  = DateTime.Now;

var taskSum = Sum(2,2);
var taskDelay = DoSomething();
Task.WaitAll(taskSum, taskDelay);

end = DateTime.Now;

Console.WriteLine("Time taken {0}",end - start);

Our full code in Program.cs should now look like this:

using System;
using System.Threading.Tasks;
using System.IO;

namespace task_demo
{
    class Program
    {
        static async Task DoSomething()
        {
            await Task.Delay(2000);
        }
        static async Task<int> Sum(int a, int b) 
        {
            var result = await Task.FromResult(a + b);
            return result;
        }

        static void Main(string[] args)
        {
            var start  = DateTime.Now;

            var taskSum = Sum(2,2);
            var taskDelay = DoSomething();

            Task.WaitAll(taskSum, taskDelay);

            end = DateTime.Now;

            Console.WriteLine("Time taken! {0}", end-start);
        }
    }
}

Let' compile:

dotnet build

and run it:

dotnet run

We should get the following response:

4
Time taken! 00:00:02.0026920

Even though the calculation from calling Sum() took a few milliseconds, we don't get any response until 2 seconds later, when DoSomething() has finished.

If we shift our code now from WaitAll to WhenAll we would get very different behavior. The code would have kept going and reported this instead:

4
Time taken! 00:00:00.0235860

So the lesson here is that if we want the code to wait at a specific point, using WaitAny is a good idea but if you want to start up a lot of asynchronous work then use When....

We can still make the code behave correctly with WhenAll but we would need to investigate the status like so:

var twoTasks = Task.WhenAll(taskSum, taskDelay);
if(twoTasks.IsCompleted) 
{
    var end = DateTime.Now;
    Console.WriteLine("{0}", taskSum.Result);
}

DEMO - Wait any

To test this one out we create three new methods that mock opening up files. Each of the three methods has a delay built in that differs:

static async Task<string> ReadFile1() 
{
    await Task.Delay(3000);
    return "file1";
}

static async Task<string> ReadFile2()
{
    await Task.Delay(4000);
    return "file2";
}

static async Task<string> ReadFile3()
{
    await Task.Delay(2000);
    return "file3";
}

Let's update our Program() method with some code as well:

var task1 = ReadFile1();
var task2 = ReadFile2();
var task3 = ReadFile3();

start = DateTime.Now;
Task.WaitAny(task1, task2, task3);


Console.WriteLine("Task1, completed: {0}", task1.IsCompleted);

Console.WriteLine("Task2, completed: {0}", task2.IsCompleted);

Console.WriteLine("Task3, completed: {0}", task3.IsCompleted);
Console.WriteLine("Task3, completed: {0}", task3.Result);

end = DateTime.Now;
Console.WriteLine("Time taken! {0}", end - start);

As you can see above, we are waiting for one of the three tasks to finish, with this construct:

Task.WaitAny(task1, task2, task3);

Given what we know of the methods being called, ReadFile3() should finish first, after 2 seconds, but let's test that by running our program:

Task1, completed: False
Task2, completed: False
Task3, completed: True
Task3, completed: file3
Time taken! 00:00:02.0031370

We can see above that Task3 is completed and the other tasks haven't completed yet.

Using Async APIs

Ok, we now understand more about async and is able to leverage that on existing APIs. Let's look at reading the content of a file. Normally you would create a method like so:

 static async string ReadTxtFile() 
{
    using(var sr = new StreamReader(File.Open("test.txt", FileMode.Open)))
    {
        return sr.ReadToEnd();
    }
}

The above would block though and you wouldn't be able to do much else while this finishes. Imagine this is a really large file then it would be really noticeable. If we rewrite the method to use an async version we would instead get code looking like this:

 static async Task<string> ReadTxtFile() 
{
    using(var sr = new StreamReader(File.Open("test.txt", FileMode.Open)))
    {
        return await sr.ReadToEndAsync();
    }
}

This doesn't block and everyone is happy.

 Blocking code

One of the tricky parts of using TPL is knowing what calls block. You are all happy that your code is now asynchronous but suddenly you end up blocking anyway. So what shall we look out for? Well, we touched upon this subject already:

  • WaitAll and WaitAny blocks, the rule of thumb here seems to be that they return void and use the word Wait.... Sometimes you want it to wait though, so learn to be intentional with block/non-block
  • task.Result, this also blocks and waits for the result to be available
  • Wait(), this method on a Task will block and cause you to wait here until the code has finished, for example Task.Delay(2000).Wait()

Full code

This is the full code I was playing around with if you want to explore for yourself:

using System;
using System.Threading.Tasks;
using System.IO;

namespace task_demo
{
  class Program
  {
      static async Task<string> ReadTxtFile() 
      {
          using(var sr = new StreamReader(File.Open("test.txt", FileMode.Open)))
          {
              return await sr.ReadToEndAsync();
          }
      }

      static string ReadFileSync1() 
      {
          Task.Delay(2000).Wait();
          return "content1";
      }

      static string ReadFileSync2()
      {
          Task.Delay(2000).Wait();
          return "content2";
      }

      static string ReadFileSync3()
          {
          Task.Delay(2000).Wait();
          return "content3";
      }

      static async Task DoSomething()
      {
          await Task.Delay(2000);
      }
      static async Task<int> Sum(int a, int b) 
      {
          var result = await Task.FromResult(a + b);
          return result;
      }

      static async Task<string> ReadFile1() 
      {
          await Task.Delay(3000);
          return "file1";
      }

      static async Task<string> ReadFile2()
      {
          await Task.Delay(4000);
          return "file2";
      }

      static async Task<string> ReadFile3()
      {
          await Task.Delay(2000);
          return "file3";
      }

      static void Main(string[] args)
      {
          var start = DateTime.Now;
          var c1 = ReadFileSync1();
          var c2 = ReadFileSync2();
          var c3 = ReadFileSync3();
          var end = DateTime.Now;
          Console.WriteLine("Time taken {0}", end-start);

          start  = DateTime.Now;

          var taskSum = Sum(2,2);
          var taskDelay = DoSomething();

          Task.WaitAll(taskSum, taskDelay);

          end = DateTime.Now;

          Console.WriteLine("{0}",taskSum.Result);

          Console.WriteLine("Time taken! {0}", end-start);

          var task1 = ReadFile1();
          var task2 = ReadFile2();
          var task3 = ReadFile3();

          start = DateTime.Now;
          Task.WaitAny(task1, task2, task3);


          Console.WriteLine("Task1, completed: {0}", task1.IsCompleted);

          // this forces everyone to wait for this Task1
          // Console.WriteLine("Task1, completed: {0}", task1.Result);

          Console.WriteLine("Task2, completed: {0}", task2.IsCompleted);

          Console.WriteLine("Task3, completed: {0}", task3.IsCompleted);
          Console.WriteLine("Task3, completed: {0}", task3.Result);

          end = DateTime.Now;
          Console.WriteLine("Time taken! {0}", end - start);

      }
  }
}

Summary

In summary, we learned about the concept of Tasks and their anatomy. Additionally, we learned about Control Flows and we also discussed blocking/non-blocking code. There is more to learn though like how to cancel Tasks. Im gonna save that one for a separate article. I will add a link to Cancellation in the References section of this article.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .