We're still looking at things from the consumer side: methods that make calls to asynchronous methods. In future articles, we'll see how to write our own async methods.
We'll continue with our previous example project which is available on GitHub: https://github.com/jeremybytes/using-task. The articles that describe the project up to this point are available here: Exploring Task, Await, and Asynchronous Methods.
The completed code for this article is in the 09-CancellationBasics branch.
So let's jump into some code!
Cancellation in the Asynchronous Method
For our asynchronous method, we're using the "Get()" method on the "PersonRepository" class. This method does not support cancellation. So we'll add a parameter to accept a "CancellationToken" and use it in our code:
There are a couple of things to note here. First, we have a default value for our "cancellationToken" parameter. This is because we don't want to break our existing method calls that don't pass a cancellation token.
In this case, if the "Get()" method is called without any parameters, then the "cancellationToken" will be a instantiated with the CancellationToken constructor. Now, as we saw when we looked at "UI Considerations When Using Asynchronous Methods", calling the constructor like this will create a cancellation token that is in the "not canceled" state.
Also, as we saw in the update in that same article, we swapped that out to "CancellationToken.None". Unfortunately, that's not something that we can use for a default parameter assignment, so we have to use the constructor here.
The second thing to note is that after waiting for 3 seconds, we call "ThrowIfCancellationRequested()" on our cancellation token. This does the following:
- It sets the "IsCanceled" and "Status" properties on our Task to "true" and "TaskStatus.Canceled", respectively.
- It throws an exception. This is a special exception that is automatically handled (sort of).
- It does not set the "IsFaulted" state on our Task.
- It does not add the exception to the AggregateException of the Task (the property we saw when we looked at exception handling).
Cancelling a Method with Task
Here's how we left our method that interacts with the Task returned from the "Get()" method:
After getting a Task back from the "Get()" method, we add a continuation that runs on the UI thread. This currently checks for a faulted state (if there's an exception thrown) and checks for successful completion.
To test the cancellation functionality, we'll just hard-code a cancellation token:
When we pass in "true" to the CancellationToken constructor, it creates a cancellation token that is in the "cancellation requested" state. (We'll get rid of this hard-coded value a bit later).
So now when we run our application and click our "Task" button, it will call the "Get" method with cancellation requested.
One thing that is very important to note about using cancellation tokens is that they do not directly affect processing -- meaning, they don't stop operations or cause an abort. Instead, the cancellation token is more of a flag; it gives us a way to let the asynchronous method know that we want to cancel the operation, but it's up to the asynchronous method itself to decide how to handle this request.
In our case, the "Get()" method will still have the 3 second delay. This is because the delay is in the method before we check the cancellation token at all. After the delay is done, then the method checks the cancellation token state and throws the exception if cancellation is requested.
So let's run our application to see what happens. When we click the button, it is disabled while we wait:
Then the button is re-enabled:
Nothing else happens. And this makes sense if we review our method. We check for "faulted" and show a message box. We check for "RanToCompletion" and populate the list box. But we don't have anything that checks for the "canceled" status. So we end up skipping both of the current "if" blocks and just re-enable the button.
Checking for Cancellation
It's easy enough to add functionality to check for cancellation. When we updated our continuations, we saw that Task has an "IsCanceled" property. We can easily use this in our continuation.
If "IsCanceled" it true, then show a message to the user. And when we run our application, we see just that:
Cleaning Things Up
Now our code is getting a bit hard to read. One thing we can do to keep things consistent is to check the "Status" property for all 3 of our "if" blocks (we saw the TaskStatus options when we updated our continuations as well).
This is a little bit better. At least we're checking the same property in all of our options now. But we can take this a step further and turn this into a "switch" statement:
To me, this is much easier to follow. We can see what happens with each of the different states. And it's actually a bit more correct, because we probably wanted to have "if / else if / else" in our previous code to make things mutually exclusive. With this "switch" statement, this is much more clear.
Note: There are a lot of other possible values for Status, but we're sticking with just the ones that we care about here. These are the statuses that we get after a Task has stopped running, which is the state that we should have since we are in a continuation.
This is fine for test code, but we probably don't want to leave the hard-coded cancellation in place. We'll want to make it so that we can cancel from the UI.
But before we do that, let's get our "await" method working with cancellation.
Cancelling a Method with Await
Here's how we left our method that uses "await":
And we can change the "Get()" call so that is passed in a hard-coded cancellation token just like we did with our Task method.
Now let's run the application to see what happens.
Interesting. We get a message box. This means that it looks like we're hitting the "catch" block of our method. Let's verify that by setting a breakpoint and checking the exception:
This shows that we do hit the "catch" block, and the exception is an "OperationCanceledException". So when we use the "await" keyword, the special cancellation exception gets surfaced on our UI thread.
But since this is a specific exception, it's easy enough to add another "catch" block just for this:
Now we have a "catch" block that just handles the cancellation and another "catch" that handles all of our other exceptions.
When we run our application, we see the new message box (note the "Canceled" in the caption):
As usual, we see that things are a bit easier to deal with when we use "await" rather than dealing with "Task" directly. So if we have a situation where we can "await" a method, that code will be a bit easier to read and write.
As with our "Task" method, our "await" method has a hard-coded cancellation token. This is fine for testing our functionality, but it's not what we want in our final application. Instead, we want to give our users a way to cancel the operation from the application while it is running.
Managing a Cancellation Token
The first thing that we'll do is add another button to our UI. This will be a cancellation button, and we want this to be disabled when our application starts. That's because we only want our button to be enabled when we have something that can be canceled.
Here's our updated UI:
Now we might be inclined to create a CancellationToken and then set its value when our cancel button is clicked. But this doesn't work. When I was first working with "Task", this is what I struggled with.
The problem is when we create a CancellationToken directly, it is not modifiable. We can check its state, but this is a read-only property. And there are no methods for changing the state.
Instead, we need to create a CancellationTokenSource. This is an object that we can use to manage a cancellation token.
We'll create a class-level field for our CancellationTokenSource. This way, we'll be able to access the object inside our various methods of our class:
We're just declaring the field here. We'll see where we instantiate it in just a bit.
Setting the Token to Canceled
Inside the "Click" event handler for our cancel button, we'll set our token to a canceled state:
This will set the "IsCancellationRequested" property of the cancellation token to "true". I would highly recommend taking a look at the methods and properties on both CancellationTokenSource and CancellationToken. There are a lot of options available. We're just using a few of the methods and properties here.
The other thing that we do in the method is disable the "Cancel" button. If we click it once, there's no reason for us to click it again. As a reminder, our method will not immediately return; we still need to wait for that 3 second delay to complete before the cancellation token is checked. By disabling the button, we give the user a visual clue that it was actually clicked.
Using the Token with Task
Now that we have a CancellationTokenSource, we can start to update our methods. We'll start with the method that uses Task.
Here are the bulk of the changes:
First, we create a new instance of the CancellationTokenSource and assign that to our field. The reason that I'm doing this is because we'll have similar code in the "await" method, and we're sharing the "tokenSource" field. I want to make sure that we get a new token source (which has an uncanceled token) at the start of each method.
Next, we enable the "CancelButton". This will light up the button in the UI so that we can request cancellation.
Then we pass the "Token" property of our CancellationTokenSource to our "Get" method. The "Token" property is a cancellation token, and this is how we get our live token to our asynchronous method.
Finally, at the bottom of our method, we disable the cancellation button in the UI (since the process is complete):
Running the Code
With this in place, let's run our application to see what happens. After we click the "Fetch" button, we see that the "Cancel" button is enabled:
Then if we click the "Cancel" button before our 3 seconds is up, we see that the "Cancel" button is disabled, but we're still waiting on the "Get()" method.
Then we get the cancellation message that we saw earlier:
After we clear this message, the "Fetch" button will re-enable just like we saw previously.
And if we do not cancel, things still run as expected:
Final Task Method
Here's our final method that uses "Task" with the cancellation functionality in place:
Now let's take a look at our "await" method.
Using the Token with Await
As you can imagine, we'll do something similar with our "await" method: use the CancellationTokenSource and enable/disable buttons as appropriate. Here's the final method:
Just like with our "Task" method, we create a new instance of the "CancellationTokenSource". This will ensure that things are set back to an initial state. We use the "Token" property as a parameter for the "Get" method. And we just enable our "Cancel" button and disable it again in our "finally" block.
And as you can imagine, this functions similar to our other method. First, when we click the "Fetch" button, our "Cancel" button will enable:
Then if we click our "Cancel" button, it will be disabled while we wait for the "Get()" method.
Then we get our cancellation message.
And, of course, our functionality works just the same as before if we do not cancel it.
The biggest stumbling block I had when I started working with "Task" was understanding that you couldn't use a "CancellationToken" directly. Instead, we need to work with a "CancellationTokenSource". This object has a property, "Token", which is a cancellation token. And we set the state of the token by calling methods on the CancellationTokenSource.
And as we saw, when an asynchronous method is canceled, we need to pay attention to the state of the Task that returns. If we're using "Task" directly, then we can check the "Status" or the "IsCanceled" properties to know if the Task has been canceled.
And if we're using "await", then we can check for an "OperationCanceledException". This will let us know that the operation was canceled before it ran to completion.
Overall, canceling an asynchronous method is not all that complex. But there are a few things that aren't obvious. By doing some exploration, we can see how things work and how we can take advantage of these features in our own applications.
Post a Comment