- Item 27: Use Async Methods for Async Work
- Item 28: Never Write async void Methods
- Item 29: Avoid Composing Synchronous and Asynchronous Methods
- Item 30: Use Async Methods to Avoid Thread Allocations and Context Switches
- Item 31: Avoid Marshalling Context Unnecessarily
- Item 32: Compose Asynchronous Work Using Task Objects
- Item 33: Consider Implementing the Task Cancellation Protocol
- Item 34: Cache Generalized Async Return Types
Item 33: Consider Implementing the Task Cancellation Protocol
The task asynchronous programming model includes standard APIs for cancellation and reporting progress. These APIs are optional, but should be implemented correctly when the asynchronous work can effectively report progress or be cancelled.
Not every asynchronous task can be cancelled, because the underlying mechanism may not always support a cancellation protocol. In those cases, your asynchronous API should not support any overloads that indicate cancellation is possible. You don’t want callers to do extra work to implement a cancellation protocol when it doesn’t have any effect.
The same is true when reporting progress. The programming model supports reporting progress, but APIs should implement this protocol only when they can actually report progress. Don’t implement the progress reporting overload when you’re not able to accurately report how much of the asynchronous work has been done. For example, consider a Web request. You won’t receive any interim progress updates from the network stack about delivery of the request, processing of the request, or any other action before receiving the response. Once you’ve received the response, the task is complete. Progress reporting does not provide any added value.
Contrast that with a task that makes a series of five Web requests to different services to perform a complex operation. Suppose you wrote an API to process payroll. It might take the following steps:
Call a Web service to retrieve the list of employees and their reported hours.
Call another Web service to calculate and report taxes.
Call a third Web service to generate paystubs and email them to employees.
Call a fourth Web service to deposit wages.
Close the payroll period.
You might reasonably assume that each of those services represents 20% of the work. You might implement a progress reporting overload to report the program’s progress after each of the five steps is complete. Furthermore, you might implement the cancellation API. Until the fourth step begins, this operation could be cancelled. Once the money has been paid, however, the operation is no longer cancellable.
Let’s look at the different overloads you should support in this example. First, let’s start with the simplest—running the payroll processing without support for either cancellation or progress reporting:
public async Task RunPayroll(DateTime payrollPeriod)
{
// Step 1: Calculate hours and pay
var payrollData = await RetrieveEmployeePayrollDataFor(
payrollPeriod);
// Step 2: Calculate and report tax
var taxReporting = new Dictionary<EmployeePayrollData,
TaxWithholding>();
foreach(var employee in payrollData)
{
var taxWithholding = await RetrieveTaxData(employee);
taxReporting.Add(employee, taxWithholding);
}
// Step 3: Generate and email paystub documents
var paystubs = new List<Task>();
foreach(var payrollItem in taxReporting)
{
var payrollTask = GeneratePayrollDocument(
payrollItem.Key, payrollItem.Value);
var emailTask = payrollTask.ContinueWith(
paystub => EmailPaystub(
payrollItem.Key.Email, paystub.Result));
paystubs.Add(emailTask);
}
await Task.WhenAll(paystubs);
// Step 4: Deposit pay
var depositTasks = new List<Task>();
foreach(var payrollItem in taxReporting)
{
depositTasks.Add(MakeDeposit(payrollItem.Key,
payrollItem.Value));
}
await Task.WhenAll(depositTasks);
// Step 5: Close payroll period
await ClosePayrollPeriod(payrollPeriod);
}
Next, let’s add the overload that supports progress reporting. Here’s what it would look like:
public async Task RunPayroll2(DateTime payrollPeriod,
IProgress<(int, string)> progress)
{
progress?.Report((0, "Starting Payroll"));
// Step 1: Calculate hours and pay
var payrollData = await RetrieveEmployeePayrollDataFor(
payrollPeriod);
progress?.Report((20, "Retrieved employees and hours"));
// Step 2: Calculate and report tax
var taxReporting = new Dictionary<EmployeePayrollData,
TaxWithholding>();
foreach (var employee in payrollData)
{
var taxWithholding = await RetrieveTaxData(employee);
taxReporting.Add(employee, taxWithholding);
}
progress?.Report((40, "Calculated Withholding"));
// Step 3: Generate and email paystub documents
var paystubs = new List<Task>();
foreach (var payrollItem in taxReporting)
{
var payrollTask = GeneratePayrollDocument(
payrollItem.Key, payrollItem.Value);
var emailTask = payrollTask.ContinueWith(
paystub => EmailPaystub(payrollItem.Key.Email,
paystub.Result));
paystubs.Add(emailTask);
}
await Task.WhenAll(paystubs);
progress?.Report((60, "Emailed Paystubs"));
// Step 4: Deposit pay
var depositTasks = new List<Task>();
foreach (var payrollItem in taxReporting)
{
depositTasks.Add(MakeDeposit(payrollItem.Key,
payrollItem.Value));
}
await Task.WhenAll(depositTasks);
progress?.Report((80, "Deposited pay"));
// Step 5: Close payroll period
await ClosePayrollPeriod(payrollPeriod);
progress?.Report((100, "complete"));
}
Callers would use this idiom as follows:
public class ProgressReporter :
IProgress<(int percent, string message)>
{
public void Report((int percent, string message) value)
{
WriteLine(
$"{value.percent} completed: {value.message}");
}
}
await generator.RunPayroll(DateTime.Now,
new ProgressReporter());
Now that you’ve added progress reporting, let’s implement cancellation. Here’s the implementation that handles cancellation, but not progress reporting:
public async Task RunPayroll(DateTime payrollPeriod,
CancellationToken cancellationToken)
{
// Step 1: Calculate hours and pay
var payrollData = await RetrieveEmployeePayrollDataFor(
payrollPeriod);
cancellationToken.ThrowIfCancellationRequested();
// Step 2: Calculate and report tax
var taxReporting = new Dictionary<EmployeePayrollData,
TaxWithholding>();
foreach (var employee in payrollData)
{
var taxWithholding = await RetrieveTaxData(employee);
taxReporting.Add(employee, taxWithholding);
}
cancellationToken.ThrowIfCancellationRequested();
// Step 3: Generate and email paystub documents
var paystubs = new List<Task>();
foreach (var payrollItem in taxReporting)
{
var payrollTask = GeneratePayrollDocument(
payrollItem.Key, payrollItem.Value);
var emailTask = payrollTask.ContinueWith(
paystub => EmailPaystub(payrollItem.Key.Email,
paystub.Result));
paystubs.Add(emailTask);
}
await Task.WhenAll(paystubs);
cancellationToken.ThrowIfCancellationRequested();
// Step 4: Deposit pay
var depositTasks = new List<Task>();
foreach (var payrollItem in taxReporting)
{
depositTasks.Add(MakeDeposit(payrollItem.Key,
payrollItem.Value));
}
await Task.WhenAll(depositTasks);
// Step 5: Close payroll period
await ClosePayrollPeriod(payrollPeriod);
}
A caller would access this method as follows:
var cts = new CancellationTokenSource();
generator.RunPayroll(DateTime.Now, cts.Token);
// To cancel:
cts.Cancel();
The caller requests cancellation by using the CancellationTokenSource. Like the TaskCompletionSource you saw in Item 32, this class provides the intermediary between code that requests cancellation and code that supports cancellation.
Next, notice that the idiom reports cancellation by throwing a TaskCancelledException to indicate that the work did not complete. Cancelled tasks are faulted tasks. It follows that you should never support cancellation for async void methods (see Item 28). If you try to do so, the cancelled task will call the unhandled exception handler.
Finally, let’s combine these combinations into a common implementation:
public Task RunPayroll(DateTime payrollPeriod) =>
RunPayroll(payrollPeriod, new CancellationToken(), null);
public Task RunPayroll(DateTime payrollPeriod,
CancellationToken cancellationToken) =>
RunPayroll(payrollPeriod, cancellationToken, null);
public Task RunPayroll(DateTime payrollPeriod,
IProgress<(int, string)> progress) =>
RunPayroll(payrollPeriod, new CancellationToken(),
progress);
public async Task RunPayroll(DateTime payrollPeriod,
CancellationToken cancellationToken,
IProgress<(int, string)> progress)
{
progress?.Report((0, "Starting Payroll"));
// Step 1: Calculate hours and pay
var payrollData = await RetrieveEmployeePayrollDataFor(
payrollPeriod);
cancellationToken.ThrowIfCancellationRequested();
progress?.Report((20, "Retrieved employees and hours"));
// Step 2: Calculate and report tax
var taxReporting = new Dictionary<EmployeePayrollData,
TaxWithholding>();
foreach (var employee in payrollData)
{
var taxWithholding = await RetrieveTaxData(employee);
taxReporting.Add(employee, taxWithholding);
}
cancellationToken.ThrowIfCancellationRequested();
progress?.Report((40, "Calculated Withholding"));
// Step 3: Generate and email paystub documents
var paystubs = new List<Task>();
foreach (var payrollItem in taxReporting)
{
var payrollTask = GeneratePayrollDocument(
payrollItem.Key, payrollItem.Value);
var emailTask = payrollTask.ContinueWith(
paystub => EmailPaystub(payrollItem.Key.Email,
paystub.Result));
paystubs.Add(emailTask);
}
await Task.WhenAll(paystubs);
cancellationToken.ThrowIfCancellationRequested();
progress?.Report((60, "Emailed Paystubs"));
// Step 4: Deposit pay
var depositTasks = new List<Task>();
foreach (var payrollItem in taxReporting)
{
depositTasks.Add(MakeDeposit(payrollItem.Key,
payrollItem.Value));
}
await Task.WhenAll(depositTasks);
progress?.Report((80, "Deposited pay"));
// Step 5: Close payroll period
await ClosePayrollPeriod(payrollPeriod);
cancellationToken.ThrowIfCancellationRequested();
progress?.Report((100, "complete"));
}
Note that all the common code is factored into a single method. Progress is reported only when requested. The cancellation token is created for all overloads that don’t support cancellation, but these overloads will never request cancellation.
You can see that the task asynchronous programming model supports a rich vocabulary to start, cancel, and monitor asynchronous operations. These protocols enable you to design an asynchronous API that represents the capabilities of the underlying asynchronous work. Support either or both of these optional protocols when you can support them effectively. When you can’t, don’t implement them and mislead callers.