Canceling a Task

Earlier in this chapter, we described why it’s a bad idea to rudely abort a thread so as to cancel a task being performed by that thread. The TPL uses cooperative cancellation, a far more polite, robust, and reliable technique for safely canceling a task that is no longer needed. A task that supports cancellation monitors a CancellationToken object (found in the System.Threading namespace) by periodically polling it to see if a cancellation request has been issued. Listing 19.8 demonstrates both the cancellation request and the response to the request. Output 19.5 shows the results.

Listing 19.8: Canceling a Task Using CancellationToken
using System;
using System.Threading;
using System.Threading.Tasks;
using AddisonWesley.Michaelis.EssentialCSharp.Shared;
 
public class Program
{
    public static void Main()
    {
        string stars =
            "*".PadRight(Console.WindowWidth - 1, '*');
        Console.WriteLine("Push ENTER to exit.");
 
        CancellationTokenSource cancellationTokenSource = new();
        // Use Task.Factory.StartNew<string>() for
        // TPL prior to .NET 4.5
        Task task = Task.Run(
            () =>
                WritePi(cancellationTokenSource.Token),
                    cancellationTokenSource.Token);
 
        // Wait for the user's input
        Console.ReadLine();
 
        cancellationTokenSource.Cancel();
        Console.WriteLine(stars);
        task.Wait();
        Console.WriteLine();
    }
 
    private static void WritePi(
        CancellationToken cancellationToken)
    {
        const int batchSize = 1;
        string piSection = string.Empty;
        int i = 0;
 
        while (!cancellationToken.IsCancellationRequested
            || i == int.MaxValue)
        {
            piSection = PiCalculator.Calculate(
                batchSize, (i++) * batchSize);
            Console.Write(piSection);
        }
    }
}
Output 19.5
Push ENTER to exit.
3.141592653589793238462643383279502884197169399375105820974944592307816
40628620899862803482534211706798214808651328230664709384460955058223172
5359408128481117450
**********************************************************************
2

After starting the task, a Console.Read() blocks the main thread. At the same time, the task continues to execute, calculating the next digit of pi and printing it out. Once the user presses Enter, the execution encounters a call to CancellationTokenSource.Cancel(). In Listing 19.8, we split the call to task.Cancel() from the call to task.Wait() and print out a line of asterisks in between. The purpose of this step is to show that quite possibly an additional iteration will occur before the cancellation token is observed—hence the additional 2 in Output 19.5 following the stars. The 2 appears because the CancellationTokenSource.Cancel() doesn’t rudely stop the task from executing. The task keeps on running until it checks the token, and politely shuts down when it sees that the owner of the token is requesting cancellation of the task.

The Cancel() call effectively sets the IsCancellationRequested property on all cancellation tokens copied from CancellationTokenSource.Token. There are a few things to note, however:

A CancellationToken, not a CancellationTokenSource, is given to the asynchronous task. A CancellationToken enables polling for a cancellation request; the CancellationTokenSource provides the token and signals it when it is canceled (see Figure 19.3). By passing the CancellationToken rather than the CancellationTokenSource, we don’t have to worry about thread synchronization issues on the CancellationTokenSource because the latter remains accessible to only the original thread.
A CancellationToken is a struct, so it is copied by value. The value returned by CancellationTokenSource.Token produces a copy of the token. The fact that CancellationToken is a value type and a copy is created results in thread-safe access to CancellationTokenSource.Token—it is available only from within the WritePi() method.
Figure 19.3: CancellationTokenSource and CancellationToken class diagrams

To monitor the IsCancellationRequested property, a copy of the CancellationToken (retrieved from CancellationTokenSource.Token) is passed to the task. In Listing 19.7, we then occasionally check the IsCancellationRequested property on the CancellationToken parameter; in this case, we check after each digit calculation. If IsCancellationRequested returns true, the while loop exits. Unlike a thread abort, which would throw an exception at essentially a random point, we exit the loop using normal control flow. We guarantee that the code is responsive to cancellation requests by polling frequently.

One other point to note about the CancellationToken is the overloaded Register() method. Via this method, you can register an action that will be invoked whenever the token is canceled. In other words, calling the Register() method subscribes to a listener delegate on the corresponding CancellationTokenSource’s Cancel().

Given that canceling before completing is the expected behavior in this program, the code in Listing 19.7 does not throw a System.Threading.Tasks.TaskCanceledException. As a consequence, task.Status will return TaskStatus.RanToCompletion—providing no indication that the work of the task was, in fact, canceled. In this example, there is no need for such an indication; however, the TPL does include the capability to do this. If the cancel call were disruptive in some way—preventing a valid result from returning, for example—throwing a TaskCanceledException (which derives from System.OperationCanceledException) would be the TPL pattern for reporting it. Instead of throwing the exception explicitly, CancellationToken includes a ThrowIfCancellationRequested() method to report the exception more easily, assuming an instance of CancellationToken is available.

If you attempt to call Wait() (or obtain the Result) on a task that threw TaskCanceledException, the behavior is the same as if any other exception had been thrown in the task: The call will throw an AggregateException. The exception is a means of communicating that the state of execution following the task is potentially incomplete. Unlike a successfully completed task in which all expected work executed successfully, a canceled task potentially has partially completed work—the state of the work is untrusted.

This example demonstrates how a long-running processor-bound operation (calculating pi almost indefinitely) can monitor for a cancellation request and respond if one occurs. There are some cases, however, when cancellation can occur without explicitly coding for it within the target task. For example, the Parallel class, discussed in Chapter 21, offers such a behavior by default.

Guidelines
DO cancel unfinished tasks rather than allowing them to run during application shutdown.
Task.Run(): A Shortcut and Simplification to Task.Factory.StartNew()

In .NET 4.0, the general practice for obtaining a task was to call Task.Factory.StartNew(). In .NET 4.5, a simpler calling structure was provided in Task.Run(). Like Task.Run(), Task.Factory.StartNew() could be used14 to invoke CPU-intensive methods that require an additional thread to be created.

Given .NET 4.5, Task.Run() should be used by default unless it proves insufficient. For example, if you need to control the task with TaskCreationOptions, if you need to specify an alternative scheduler, or if, for performance reasons, you want to pass in object state, you should consider using Task.Factory.StartNew(). Only in rare cases, where you need to separate creation from scheduling, should constructor instantiation followed by a call to Start() be considered.

Listing 19.9 provides an example of using Task.Factory.StartNew().

Listing 19.9: Using Task.Factory.StartNew()
public static Task<string> CalculatePiAsync(int digits)
{
    return Task.Factory.StartNew<string>(
        () => Program.CalculatePi(digits));
}
 
private static string CalculatePi(int digits)
{
    // ...
}
Long-Running Tasks

The thread pool assumes that work items will be processor bound and relatively short-lived; it makes these assumptions to effectively throttle the number of threads created. This prevents both overallocation of expensive thread resources and oversubscription of processors that would lead to excessive context switching and time slicing.

But what if the developer knows that a task will be long-running and, therefore, will hold on to an underlying thread resource for a long time? In this case, the developer can notify the scheduler that the task is unlikely to complete its work anytime soon. This has two effects. First, it hints to the scheduler that perhaps a dedicated thread ought to be created specifically for this task rather than attempting to use a thread from the thread pool. Second, it hints to the scheduler that perhaps this would be a good time to allow more tasks to be scheduled than there are processors to handle them. This will cause more time slicing to happen, which is a good thing. We do not want one long-running task to hog an entire processor and prevent shorter-running tasks from using it. The short-running tasks will be able to use their time slices to finish a large percentage of their work, and the long-running task is unlikely to notice the relatively slight delays caused by sharing a processor with other tasks. To accomplish this, use the TaskCreationOptions.LongRunning option when calling StartNew(), as shown in Listing 19.10. (Task.Run() does not support a TaskCreationOptions parameter.)

Listing 19.10: Cooperatively Executing Long-Running Tasks
using System.Threading.Tasks;
// ...
        Task task = Task.Factory.StartNew(
            () => WritePi(cancellationTokenSource.Token),
                    TaskCreationOptions.LongRunning);
 
        //...
Guidelines
DO inform the task factory that a newly created task is likely to be long-running so that it can manage it appropriately.
DO use TaskCreationOptions.LongRunning sparingly.
Tasks Are Disposable

Note that Task also supports IDisposable. This is necessary because Task may allocate a WaitHandle when waiting for it to complete; since WaitHandle supports IDisposable, Task also supports IDisposable in accordance with best practices. However, note that the preceding code samples do not include a Dispose() call, nor do they rely on such a call implicitly via the using statement. Instead, the listings rely on an automatic WaitHandle finalizer invocation when the program exits.

This approach leads to two notable results. First, the handles live longer and hence consume more resources than they ought to. Second, the garbage collector is slightly less efficient because finalized objects survive into the next generation. However, both of these concerns are inconsequential in the Task case unless an extraordinarily large number of tasks are being finalized. Therefore, even though technically speaking all code should be disposing of tasks, you needn’t bother to do so unless performance metrics require it and it’s easy—that is, if you’re certain that Tasks have completed and no other code is using them.

________________________________________

14. Starting in C# 4.0.
{{ snackbarMessage }}