The Task-based Asynchronous Pattern

Naming, Parameters, and Return Types
Initiation and completion of an asynchronous operation in the TAP are represented by a single method, and thus there is only one method to name. This is in contrast to the IAsyncResult pattern, or APM pattern, where BeginMethodName and EndMethodName methods are required, and in contrast to the event-based asynchronous pattern, or EAP, where a MethodNameAsync is required in addition to one or more events, event handler delegate types, and EventArg-derived types. Asynchronous methods in the TAP are named with an “Async” suffix that follows the operation’s name, e.g. MethodNameAsync. The singular TAP method returns either a Task or a Task<TResult>, based on whether the corresponding synchronous method would return void or a type TResult, respectively.

For example, consider a “Read” method that reads a specified amount of data into a provided buffer starting at a specified offset:

public class MyClass
{
    public int Read(byte [] buffer, int offset, int count);
}

The APM counterpart to this method would expose the following two methods:

public class MyClass
{
    public IAsyncResult BeginRead(
        byte [] buffer, int offset, int count, 
        AsyncCallback callback, object state);
    public int EndRead(IAsyncResult asyncResult);
}

The EAP counterpart would expose the following set of types and members:

public class MyClass
{
    public void ReadAsync(byte [] buffer, int offset, int count);
    public event ReadCompletedEventHandler ReadCompleted;
}

public delegate void ReadCompletedEventHandler(
    object sender, ReadCompletedEventArgs eventArgs);

public class ReadCompletedEventArgs : AsyncCompletedEventArgs
{
    public int Result { get; }
}

The TAP counterpart would expose the following single method:
public class MyClass
{
    public Task<int> ReadAsync(byte [] buffer, int offset, int count);
}

The parameters to a basic TAP method should be the same parameters provided to the synchronous counterpart, in the same order. However, “out” and “ref” parameters are exempted from this rule and should be avoided entirely. Any data that would have been returned through an out or ref parameter should instead be returned as part of the returned Task<TResult>’s Result, utilizing a tuple or a custom data structure in order to accommodate multiple values.

Methods devoted purely to the creation, manipulation, or combination of tasks (where the asynchronous intent of the method is clear in the method name or in the name of the type on which the method lives) need not follow the aforementioned naming pattern; such methods are often referred to as “combinators”. Examples of such methods include Task.WhenAll and Task.WhenAny, and are discussed in more depth later in this document.

Behavior
Initiating the Asynchronous Operation
An asynchronous method based on the TAP is permitted to do a small amount of work synchronously before it returns the resulting Task. The work should be kept to the minimal amount necessary, performing operations such as validating arguments and initiating the asynchronous operation. It is likely that asynchronous methods will be invoked from user interface threads, and thus any long-running work in the synchronous up-front portion of an asynchronous method could harm responsiveness. It is also likely that multiple asynchronous methods will be launched concurrently, and thus any long-running work in the synchronous up-front portion of an asynchronous method could delay the initiation of other asynchronous operations, thereby decreasing benefits of concurrency.

Exceptions
An asynchronous method should only directly raise an exception to be thrown out of the MethodNameAsync call in response to a usage error*. For all other errors, exceptions occurring during the execution of an asynchronous method should be assigned to the returned Task. This is the case even if the asynchronous method happens to complete synchronously before the Task is returned. Typically, a Task will contain at most one exception. However, for cases where a Task is used to represent multiple operations (e.g. Task.WhenAll), multiple exceptions may be associated with a single Task.

(*Per .NET design guidelines, a usage error is something that can be avoided by changing the code that calls the method. For example, if an error state results when a null is passed as one of the method’s arguments, an error condition usually represented by an ArgumentNullException, the calling code can be modified by the developer to ensure that null is never passed. In other words, the developer can and should ensure that usage errors never occur in production code.)

Target Environment
It is up to the TAP method’s implementation to determine where asynchronous execution occurs. The developer of the TAP method may choose to execute the workload on the ThreadPool, may choose to implement it using asynchronous I/O and thus without being bound to a thread for the majority of the operation’s execution, may choose to run on a specific thread as need-be, such as the UI thread, or any number of other potential contexts. It may even be the case that a TAP method has no execution to perform, returning a Task that simply represents the occurrence of a condition elsewhere in the system (e.g. a Task<TData> that represents TData arriving at a queued data structure).

The caller of the TAP method may block waiting for the TAP method to complete (by waiting on the resulting Task), or may utilize a continuation to execute additional code when the asynchronous operation completes. The creator of the continuation has control over where that continuation code executes. These continuations may be created either explicitly through methods on the Task class or implicitly using language support on top of continuations.

Task Status
The Task class provides a life cycle for asynchronous operations, and that cycle is represented by the TaskStatus enumeration. In order to support corner cases of types deriving from Task and Task<TResult> as well as the separation of construction from scheduling, the Task class exposes a Start method. Tasks created by its public constructors are referred to as “cold” tasks, in that they begin their life cycle in the non-scheduled TaskStatus.Created state, and it’s not until Start is called on these instances that they progress to being scheduled. All other tasks begin their life cycle in a “hot” state, meaning that their asynchronous execution has already been initiated and their TaskStatus is a value other than Created.

All tasks returned from TAP methods must be “hot.” If a TAP method internally uses a Task’s constructor to instantiate the task to be returned, the TAP method must call Start on the Task object prior to returning it.

Consumers of a TAP method may safely assume that the returned task is “hot,” and should not attempt to call Start on any Task returned from a TAP method. Calling Start on a “hot” task will result in an InvalidOperationException (this check is handled automatically by the Task class).

Optional: Cancellation
Cancellation in the TAP is optional for asynchronous method implementers and opt-in for asynchronous method consumers. If an operation is built to be cancelable, it will expose an overload of the MethodNameAsync method that accepts a System.Threading.CancellationToken. The asynchronous operation will monitor this token for cancellation requests, and if a cancellation request is received, may choose to honor that request and cancel the operation. If the cancellation request is honored such that work is ended prematurely, the Task returned from the TAP method will end in the TaskStatus.Canceled state.

To expose a cancelable asynchronous operation, a TAP implementation provides an overload that accepts a CancellationToken after the synchronous counterpart method’s parameters. By convention, the parameter should be named “cancellationToken”.

public Task<int> ReadAsync(
    byte [] buffer, int offset, int count, 
    CancellationToken cancellationToken);

If the token has cancellation requested and the asynchronous operation is able to respect that request, the returned task will end in the TaskStatus.Canceled state; there will be no available Result and no Exception. The Canceled state is considered to be a final, or completed, state for a task, along with Faulted and RanToCompletion. Thus, a task in the Canceled state will have its IsCompleted property returning true. When a task completes in the Canceled state, any continuations registered with the task will be scheduled or executed, unless such continuations opted out at the time they were created, through use of specific TaskContinuationOptions (e.g. TaskContinuationOptions.NotOnCanceled). Any code asynchronously waiting for a canceled task through use of language features will continue execution and receive an OperationCanceledException. Any code blocked synchronously waiting on the task (through methods like Wait or WaitAll) will similarly continue execution with an exception.

For methods that desire having cancellation first and foremost in the mind of a developer using the asynchronous method, an overload need not be provided that doesn’t accept a CancellationToken. For methods that are not cancelable, overloads accepting CancellationToken should not be provided; this helps indicate to the caller whether the target method is actually cancelable. A consumer that does not desire cancellation may call a method that accepts a CancellationToken and provide CancellationToken.None as the argument value.

Optional: Progress Reporting
Some asynchronous operations benefit from providing progress notifications; these are typically utilized to update a user interface with information about the progress of the asynchronous operation.

In the TAP, progress is handled through an IProgress<T> interface (defined later in this document) passed into the asynchronous method as a parameter named “progress”. Providing the progress interface at the time of the asynchronous method’s invocation helps to eliminate race conditions that result from incorrect usage where event handlers incorrectly registered after the invocation of the operation may miss updates. It also enables varying implementations of progress to be utilized, as determined by the consumer. The consumer may, for example, only care about the latest progress update, or may want to buffer them all, or may simply want to invoke an action for each update; all of this may be achieved by utilizing a different implementation of the interface, each of which may be customized to the particular consumer’s need. As with cancellation, TAP implementations should only provide an IProgress<T> parameter if the API supports progress notifications.

For example, if our aforementioned ReadAsync method was able to report intermediate progress in the form of the number of bytes read thus far, the progress callback could be an IProgress<int>:

public Task<int> ReadAsync(
    byte [] buffer, int offset, int count, 
    IProgress<int> progress);

If a FindFilesAsync method returned a list of all files that met a particular search pattern, the progress callback could provide an estimation as to the percentage of work completed as well as the current set of partial results. It could do this either with a tuple, e.g.:

public Task<ReadOnlyCollection<FileInfo>> FindFilesAsync(
    string pattern, 
    IProgress<Tuple<double,ReadOnlyCollection<List<FileInfo>>>> progress);

or with a data type specific to the API, e.g.:
public Task<ReadOnlyCollection<FileInfo>> FindFilesAsync(
    string pattern, 
    IProgress<FindFilesProgressInfo> progress);

In the latter case, the special data type should be suffixed with “ProgressInfo”.
If TAP implementations provide overloads that accept a progress parameter, they must allow the argument to be null, in which case no progress will be reported. TAP implementations should synchronously report the progress to the IProgress<T> object, making it cheap for the async implementation to quickly provide progress, and allowing the consumer of the progress to determine how and where best to handle the information (e.g. the progress instance itself could choose to marshal callbacks and raise events on a captured synchronization context).

IProgress<T> Implementations
A single IProgress<T> implementation, EventProgress<T>, is provided built-in (more implementations may be provided in the future). The EventProgress<T> class is declared as follows:

public sealed class EventProgress<T> : IProgress<T>
{
    public event EventHandler<EventArgs<T>> ProgressChanged;

    public static EventProgress<T> FromAction(Action<T> handler);
}

An instance of EventProgress<T> exposes a ProgressChanged event, which is raised every time the asynchronous operation reports a progress update. The ProgressChanged event is raised on whatever SynchronizationContext was captured when the EventProgress<T> instance was instantiated (if no context was available, a default context is used, targeting the ThreadPool). Progress updates are raised asynchronously so as to avoid delaying the asynchronous operation while event handlers are executing.

The FromAction static method provides for a simple mechanism to easily create an EventProgress<T> from an action delegate.

Choosing Which Overloads to Provide
With both the optional CancellationToken and optional IProgress<T> parameters, an implementation of the TAP could potentially demand up to four overloads:

public Task MethodNameAsync(…);
public Task MethodNameAsync(…, CancellationToken cancellationToken);
public Task MethodNameAsync(…, IProgress<T> progress); 
public Task MethodNameAsync(…, 
    CancellationToken cancellationToken, IProgress<T> progress);

However, many TAP implementations will have need for only the shortest overload, as they will not provide either cancellation or progress capabilities:

public Task MethodNameAsync(…);

If an implementation supports either cancellation or progress but not both, a TAP implementation may provide two overloads:

public Task MethodNameAsync(…);
public Task MethodNameAsync(…, CancellationToken cancellationToken);

// … or …

public Task MethodNameAsync(…);
public Task MethodNameAsync(…, IProgress<T> progress); 

If an implementation supports both cancellation and progress, it may expose all four potential overloads. However, it is valid to provide only two:

public Task MethodNameAsync(…);
public Task MethodNameAsync(…, 
    CancellationToken cancellationToken, IProgress<T> progress);

To make up for the missing two intermediate combinations, developers may pass CancellationToken.None for the cancellationToken parameter and/or null for the progress parameter.
If it is expected that every usage of the TAP method should utilize cancellation and/or progress, the overloads that don’t accept the relevant parameter may be omitted.

One Comment