How YOU can make your .NET programs more responsive using Tasks and async/await in .NET Core, C# and VS Code
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
Async return types It gives you a good intro to Tasks.
Task Control Flows This talks about Control flows, how to ensure that the code happens in the right order
Task Based Asynchronous programming This is more of an overview of Task-based programming
How to run Tasks sequentially This talks about how to run Tasks in order, one after another.
Task Cancellation This teaches you how to cancel and listen for cancellation messages for your tasks
Durable Functions in Azure This shows how to do Tasks in Serverless programming and specifically Durable Functions
A Recipe converting sync code to async This takes you all the way from synchronous code to gradually convert it to asynchronous code.
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 finish for the asynchronous operation to end and by the end of the operation we are given the result, e.gvar fileContent = await GetFileAsync()
. Any method that uses theawait
concept would need to haveasync
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 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 asWait()
affects your code
Scaffold a project
Let's start by creating a solution like so:
mkdir tasks
cd tasks
dotnet new sln
2
3
4
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;
}
2
3
4
5
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 typeint
. Task.FromResult()
, This creates a Task given a value. We give it the calculation to perform, e.ga+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 theasync
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.
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 returningvoid
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 togetherTask.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 how finished first, as long as we get a response. This is also blocking and waiting for one of the Tasks to finishTask.WhenAll()
, this gives you aTask
back that you can interact with. When all of the tasks have finished it will resolve.Task.WhenAny()
, this gives you aTask
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);
}
2
3
4
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);
2
3
4
5
6
7
8
9
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);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Let' compile:
dotnet build
and run it:
dotnet run
We should get the following response:
4
Time taken! 00:00:02.0026920
2
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
2
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);
}
2
3
4
5
6
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";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
2
3
4
5
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();
}
}
2
3
4
5
6
7
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();
}
}
2
3
4
5
6
7
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
andWaitAny
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-blocktask.Result
, this also blocks and waits for the result to be availableWait()
, this method on a Task will block and cause you to wait here until the code has finished, for exampleTask.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);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
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.