Tasks are a key part of asynchronous programming in the .NET world -- this includes .NET Core 2.0 which was released last month. Today we'll take a look at consuming an asynchronous method (one that returns a Task) and see how to perform additional work after it has completed (a continuation), how to deal with exceptions, and also how to handle cancellation.
Note: .NET Core 2.0 Articles are collected here: Getting Started with .NET Core 2.0.
Initial Setup
In the previous 2 articles, we've seen how to
create a WebAPI service and also
a console application that consumes that service. Both of these are built with .NET Core 2.0. If you'd like to follow along, you can download the projects on GitHub:
WebAPI Service:
https://github.com/jeremybytes/person-api-core
Console Application:
https://github.com/jeremybytes/task-app-core
These projects have the completed code.
I'm using the command-line interface (CLI) in a bash terminal along with Visual Studio Code on macOS. But this will also work on Windows 10 using PowerShell and Visual Studio Code. My environment includes (1) the .NET Core 2.0 SDK, (2) Visual Studio Code, and (3) the C# Extension for Visual Studio Code. You can check the
Microsoft .NET Core 2.0 announcement for resources on getting the environment set up.
We'll be expanding on the console application that was described in
the previous article. We won't be so focused on the .NET Core environment as much as we'll be looking at how to use Task.
The Asynchronous Method
The asynchronous method that we're using is in the
task-app-core project (a console application with a couple of class files). We'll start by opening the project folder in Visual Studio Code. This is the "TaskApp" folder that we worked with
last time.
Here's the method signature of the asynchronous method (from the
PersonRepository.cs file):
The important bit here is that "GetAsync()" returns a Task. A Task represents a concurrent operation. It may or may not happen on a separate thread, but we don't normally need to worry about those details.
In this case, we can treat "Task<List<Person>>" as a promise: at some point in the future, we will have a list of Person objects (List<Person>) that we can do something with.
The parameter is a CancellationToken. This allows us to notify the process that we would like to cancel.
We won't look at the insides of this method here. The only thing that we need to know is that this method has an artificial 3 second delay built in. This is so that we can see the asynchronous nature of the method.
This method gets data from a WebAPI service (
person-api-core). We'll make sure that that service is running when we're ready to use it.
Running the Console Application
All of our work today will be done in the
Program.cs file of the console application. The GitHub project has the completed code, but we'll be starting from scratch. If you want to follow along, you can get the GitHub files and then remove everything in the Program.cs file.
Here's how we'll start out:
This will print something to the console and then wait for us to press "Return" before it exits.
From the command line, just type
dotnet run
to build and run the application:
If you see something different than this, it may be because the files are not saved. I'm used to Visual Studio automatically saving files whenever I build, so I've spent a bit of time trying to figure out why my code changes aren't working. It's usually because I forgot to save the files before running the application.
Starting the Service
Before going any further, let's fire up the service in another terminal window. To do this, we just need to navigate to the folder that contains the WebAPI project ("PersonAPI" from
the prior article).
Then we just need to type
dotnet run
to start the service:
Now we can find our data at http://localhost:9874/api/people.
Using a Task (Success Path)
Now we'll flip back to the console application (make sure you leave the service terminal window open). Let's consume the asynchronous method that we looked at above.
We need to create an instance of the "PersonRepository" class and then call the "GetAsync" method. Here's some initial code:
For now, I'll use "var result" to grab the return from the method. We'll give this a better name in just a bit. But before that, let's take a look at the parameter.
The "GetAsync" method needs a CancellationToken parameter, but we don't really care about cancellation at the moment. Rather than passing in a "null" or create a token that we won't use, we can use the static property "CancellationToken.None". This is designed so that we can just ignore cancellation.
Adding Using Statements
But when we type this into our code file, we see that "CancellationToken" does not resolve. In the .NET Core world, we create new class files that are empty; they don't have any "using" statements at the top. So we get to add them ourselves. But the C# Extension in Visual Studio Code will help us out a bit.
If we put our cursor on "CancellationToken", we see a lightbulb offering us help. You can press "Cmd+." on macOS (or "Ctrl+." on Windows, the same as Visual Studio) to activate the popup. You can also click on the lightbulb to get the popup:
The option "using System.Threading" will add the appropriate "using" statement to the top of our file. Since we're mostly starting with empty files, I find myself using this feature quite a bit.
Fixing the Result
I don't like using "var" to capture the return in this scenario because the return type isn't clear by just looking at the code. We can always hover our mouse over the "var" keyword to see the actual type, but I like the code to be readable without needing the extra assistance. So we'll change this to be more explicit with "Task<List<Person>>" and also by renaming "result" to "peopleTask".
This gives us a better idea of what is going on just by looking at the code.
Adding a Continuation
We can run the application now, but we'll get the same result that we had earlier. We're calling the "GetAsync" method, but we aren't doing anything to get the results out of it.
Task has a "Result" property, and it's really tempting to use it directly. The problem with using it directly is that it may not be populated yet. If we try to access the "Result" property before it is populated, then the current thread will be blocked until it is populated.
How do we get the result without blocking the current thread? We set up a continuation.
A continuation is a delegate that will run after the Task is complete. We can set this up with the "ContinueWith" method on the task. And this is where things get interesting:
Notice that the parameter for "ContinueWith" is "Action<Task<List<Person>>>". Yikes! That's a bit of a mouthful. (As a side note, there are many overloads for this method; we'll see another in a bit.)
"Action" represents a delegate that returns "void". In this case, the delegate takes one parameter of type "Task<List<Person>>". (For more information on Delegates, check out
Get Func<>-y: Delegates in .NET.
To fulfill this delegate parameter, we just need to create a method that matches the method signature:
This method returns "void" and takes "Task<List<Person>>" as a parameter.
Inside this method, we can access the "Result" property safely. That's because this code will not run until *after* the Task has completed. So we know we won't inadvertently block the thread waiting for that. (We will still need to deal with exceptions, but we'll see that in a bit.)
"Result" is "List<Person>" in this case. I'm putting it into an explicit variable of that type to help with readability. After we have that, we just loop through the items and output them to the console. The "Person" class has a good "ToString()" method, so we don't have to do any custom formatting for basic output.
Then we just need to use "PrintNames" as the parameter for the "ContinueWith" call.
Here's our current "Program.cs":
This is enough to show our "success" path. To run the application, we'll go back to the command line (the one in the "TaskApp" folder).
Just type
dotnet run
to see the result:
This console will display "One Moment Please..." and then after 3 seconds, it will list out the names that came from the service.
Just hit "Return" to exit the console application.
Because things are running asynchronously, the "ReadLine" method will run immediately. This means that if we press "Return" after "One Moment Please..." appears (but before the names appear), the application will exit and we will not see the names listed. (It will also exit if we press it *before* "One Moment Please..." appears because the console input gets cached until something uses it.)
Cleaning Things Up (and a Lambda Expression!)
I don't like the idea of needing to press "Return" to close the console application. If the data is printed successfully, then I'd like it to automatically exit. We can do this with the "Environment.Exit()" method:
The parameter is the exit code. "0" denotes no error, while other values are generally used to show that something went wrong.
Now when we run the application, we do not need to press "Return" after the names are printed:
Moving the Delegate to a Lambda Expression
The other thing I'd like to do is change the named delegate into a lambda expression. I've talked about lambda expressions many times, so I'll let you refer to
those resources. In this case, the lambda expression is more-or-less an inline delegate.
Instead of using "PrintNames" as the parameter for "ContinueWith", we can use a lambda expression. The first part, "task", is the parameter for the delegate. This is the same parameter from "PrintNames", so it is a "Task<List<Person>>" (the compiler knows that so we don't need to type it explicitly). Then we have the lambda operator =>. And after that is the body of our lambda expression.
In this case, we can use the separate delegate or the in-lined lambda expression and get the same results. There are a few advantages to lambdas that are helpful with Task (such as captured variables). The main reason I encourage people to learn how to use lambdas here is because that's what you'll see in the wild when looking at code.
Breaking the Application
What we have now works -- as long as nothing goes wrong. Once we have something unexpected happen, things go a bit wonky.
Let's shut down the service to see how our application handles it. Just go back to the console window where the service is running and press "Ctrl+C" to stop the service:
Now when we run the console application, it just hangs:
Since it's hard to tell what's going on, we're going to use Visual Studio Code for some debugging. We'll go to the "Debug" menu and choose "Start with Debugging" (or press F5). This will stop on the actual error:
We can dig right into the middle and see that the error is "Couldn't connect to server". That makes sense since the server is no longer running.
But notice where we got this error. We actually get this error when we try to access the "Result" property on the Task. When an exception occurs inside Task code, the Task goes into a "faulted" state. When a Task is "faulted", then the "Result" property is invalid. So if we try to look at "Result" on a faulted Task, we'll get the exception instead.
Notice that the exception is an "AggregateException". This is a special exception that can hold other exceptions. If you'd like further details, you can take a look at this article:
Task and Await: Basic Exception Handling.
Limiting Damage
We don't want to access the "Result" property on a faulted task, so we'll limit when our continuation runs. The "ContinueWith" method has many overloads, and a handy one takes "TaskContinuationOptions" as another parameter:
"TaskContinuationsOptions" is an enum. The "OnlyOnRanToCompletion" value means that this continuation will only run if the Task completes successfully. That takes care of our initial problem.
But our application still behaves the same way. If we run the application, it just hangs:
And if we use the debugger in Visual Studio Code, we don't see the exception since we're not doing anything to look for it.
Exception Handling (Error)
We've limited the damage, but we still want our application to behave nicely if something goes wrong. For this we'll add another continuation:
Now we have a continuation that's marked "OnlyOnFaulted". This will only run if the Task is in a faulted state. We can check the "Exception" property of the Task for details, but we'll just print out a "something went wrong" message here. For details on getting into the exception, check the
Basic Exception Handling article mentioned above.
Another thing to note is that we're using an exit code of "1". We haven't defined what the different exit codes mean, but as noted, anything other than "0" denotes that something went wrong.
When we run the application now, it lets us know that something went wrong:
And if we start the service back up, we can see that the happy path still works for our application:
Is This Really Asynchronous?
It's hard to tell whether this code is really asynchronous. We saw above that if we pressed "Return" before the data returns that our application would exit. That shows us that things are still processing. But let's add a bit of code to see things more clearly.
At the bottom of the application, we'll add a few more "ReadLine"/"WriteLine" calls:
Now when we run our application, we can press "Return" three times, and we'll see the messages printed out:
If we press "Return" four times, then the application would exit. Let's add one more message to the code:
Then if we press "Return" four times, the application will exit before it prints out the names:
This is a bit of a simple demonstration, but it shows that our application is still running and processing input while the asynchronous method is running.
Stopping the Process Early (Cancellation)
The last thing we want to look at today is how to deal with cancellation. Our asynchronous method is already set up to handle cancellation -- that's what the CancellationToken parameter is for. On the consumer side, we need to be able to request cancellation and also deal with the results.
It's really tempting to simply new up a "CancellationToken" and pass it as a parameter to our method. But that will not work the way we want it to. That's because once we create a CancellationToken manually, there is no way for us to change the state. That's not very useful.
CancellationTokenSource
But we can use a "CancellationTokenSource" instead. This is an object manages a cancellation token.
Here's how we'll update our code:
At the top of our "Main" method, we create a new "CancellationTokenSource" object. Then when we call the "GetAsync" method, instead of passing in "CancellationToken.None", we'll pass in the "Token" property of our token source.
This gets the cancellation token to our asynchronous method. Now we need to set the token to a cancelled state.
Cancelling
For our console application, we'll give our user a chance to cancel the operation with the "X" key. We'll look for an "X" with the "ReadKey" method. This won't operate exactly how we would like, but we will clean that up in a little bit.
For now, we'll add a "ReadKey" just above our "ReadLine"/"WriteLine" methods:
If someone presses "X" (and it doesn't matter if it is upper case or lower case), then we call the "Cancel()" method on the CancellationTokenSource. This will put the cancellation token into a "cancellation requested" state. From there, it's up to the asynchronous method to figure out how it wants to handle that request.
Handling a Canceled Task
When a Task is canceled, it is put into a "canceled" state. This is different from the "faulted" state and the "ran to completion" state. With our current code, this means that we need another continuation to deal with cancellation.
Here's that continuation:
This is marked to run "OnlyOnCanceled", and we'll print a message to the output.
Testing Cancellation
With the pieces in place, we can run the application and try out cancellation. After "One Moment Please..." appears, press the "X" key. There will still be a 3 second pause because the asynchronous method doesn't check for cancellation until after the 3 second delay.
But then we should see our "canceled" message:
And if we don't press any keys, we'll see the success path:
This lets us see that cancellation is working, but we can't really get to our "Waiting..." logic anymore. We'll have to do some modification for that.
Better User Interaction
It's usually a challenge to get a good user experience with a console application, but we'll give it a shot. Here's the approach that I took for this code:
This replaces the previous "if (ReadKey..)" and "ReadLine"/"WriteLine" code. The endless loop lets us press "X" to cancel, "Q" to quit, and "Return" to get a "Waiting..." message. It's still a bit odd, but it handles the bulk of this application's needs.
The only other code update is to change the initial message:
Now we can test our interactivity. First, we'll press "X" for cancellation:
Then we'll try "Q" to quit:
And then we'll try "Return" a few times for the "Waiting..." message:
And if we do nothing, we'll still get the same results that we saw earlier.
Not a Ton of Code
We've implemented quite a bit of functionality here. We're consuming a method asynchronously; we're processing results when it completes, we're dealing with exceptions, and we can cancel the operation before it completes.
Here's the entire console application:
You can also look at this code on GitHub:
Program.cs. The online code is a little different because I extracted out some separate methods for readability.
As an alternate, we can also create a single continuation and have code that branches based on the state of the Task (success, faulted, canceled). For more information, you can take a look at "
Checking IsFaulted, IsCompleted, and TaskStatus".
Wrap Up
The good news is that using Task with .NET Core 2.0 is not much different from using it with the full .NET framework. The main challenges with porting over the full-framework code (available here:
GitHub: jeremybytes/using-task) have to do with the UI demonstration. It's a bit easier to show some features when we have a desktop UI application, but we can still see the features in a console application.
Showing "await" with a console application is a bit more of a challenge. "Await" works just fine (with C# 7.1 which allows us to have an "async Main()" method), but since the code looks like blocking code, it makes it harder to show the asynchronous interactions. I'm still working on a demonstration for that.
If you want more information on Task and Await, be sure to check out my materials online:
I'll Get Back to You: Task, Await, and Asynchronous Methods. This includes articles, code, videos, and much more. And also be on the lookout for my live presentations on the topic. I'm also available for
workshops where we spend a full day on the topic of asynchronous programming.
I'm still exploring in the .NET Core 2.0 world. And I'm sure that there will be much more fun to come.
Happy Coding!