Leveraging the Task Parallel Library for Asynchronous Work in .NET

The Task Parallel Library (TPL), introduced in .NET 4.0 and refined in 4.5, provides a higher-level abstraction over the thread pool. It shifts focus from raw threads to tasks—units of asynchronous work that may or may not execute on separate threads. A primary strength is its rich set of combinators that simplify chaining and error handling. Exceptions thrown inside tasks are wrapped in AggregateException, letting calllers inspect and handle them individually.

Defining and Starting Tasks

Tasks can be created through several approaches. The preferred modern pattern is Task.Run, which queues work on the thread pool and prevents child tasks from attaching to the parent (via DenyChildAttach).

Task<int> compute = Task.Run(() =>
{
    // Simulate work
    Thread.Sleep(200);
    return 42;
});

An alternative is constructing a Task instance and later calling Start:

var coldTask = new Task<int>(() => 99);
coldTask.Start();

Task.Factory.StartNew offers more detailed control. Passing TaskCreationOptions.LongRunning hints the scheduler to avoid the thread pool, typically creating a dedicated background thread:

var longOp = Task.Factory.StartNew(() =>
{
    // long-running CPU or I/O work
}, TaskCreationOptions.LongRunning);

A task can be forced to execute on the calling thread with RunSynchronously, which is useful when the overhead of thread-pool queuing outweighs the operation's cost. The Status property (Created, Running, RanToCompletion, etc.) lets you poll task lifecycles manually, though combining tasks is generally cleaner.

Chaining and Continuations

ContinueWith schedules a delegate that runs after a target task finishes. By default it grabs a new thread-pool worker. Adding TaskContinuationOptions.ExecuteSynchronously changes the scheduling slightly: if the antecedent hasn't completed, the continuation runs on the antecedent's thread; if it has already finished, the continuation executes on the current thread.

Task initial = Task.Run(() => PrepareData());
Task follow = initial.ContinueWith(prev =>
{
    Process(prev.Result);
}, TaskContinuationOptions.ExecuteSynchronously);

An alternative hook is GetAwaiter().OnCompleted, which behaves differently depending on completion status: it runs on the antecedent's thread when the task is still in progress, and otherwice on a thread-pool thread.

Parent-child relationships are established with TaskCreationOptions.AttachedToParent. A child must be started while the parent is active. The parent transitions to WaitingForChildrenToComplete and finishes only after all attached children have finished, even if children spawn further nested children.

Bridging Legacy Asynchronous Patterns

APM to Tasks
Task<T>.Factory.FromAsync converts the Asynchronous Programming Model (APM) pattern into a task. Three overloads are commonly used:

// Overload 1: wrapping an already-started IAsyncResult
IAsyncResult ar = someDelegate.BeginInvoke("arg", null, null);
Task<string> t1 = Task<string>.Factory.FromAsync(ar, asyncResult => someDelegate.EndInvoke(asyncResult));

// Overload 2: supplying begin/end methods and arguments
Task<int> t2 = Task<int>.Factory.FromAsync(
    (arg, callback, state) => BeginOperation(arg, callback, state),
    asyncResult => EndOperation(asyncResult),
    "input",
    null
);

// Overload 3: using a lambda that captures the IAsyncResult
IAsyncResult ar2 = BeginOtherOp(null);
Task<decimal> t3 = Task<decimal>.Factory.FromAsync(ar2, _ => EndOtherOp(ar2));

EAP to Tasks
The Event-based Asynchronous Pattern (EAP) can be wrapped with TaskCompletionSource<T>. Assign SetResult, SetException, or SetCanceled inside the completion callback. Always embed these calls in a try/catch so that any exception in the callback itself is reflected on the task.

var tcs = new TaskCompletionSource<string>();
someComponent.DownloadCompleted += (sender, args) =>
{
    try
    {
        if (args.Error != null)
            tcs.TrySetException(args.Error);
        else if (args.Cancelled)
            tcs.TrySetCanceled();
        else
            tcs.TrySetResult(args.Result);
    }
    catch (Exception ex)
    {
        tcs.TrySetException(ex);
    }
};
someComponent.StartDownload();
string result = tcs.Task.Result;

Cooperative Cancellation

A CancellationTokenSource paired with a CancellationToken enables clean task cancellation. Cancel before starting and the task transitions immediately to Canceled; calling Start again throws InvalidOperationException. Cancel after the task is running, and the task will likely reach RanToCompletion, possibly after checking the token and terminating early.

var source = new CancellationTokenSource();
CancellationToken token = source.Token;

Task worker = Task.Run(() =>
{
    for (int i = 0; i < 100; i++)
    {
        token.ThrowIfCancellationRequested();
        // perform work
    }
}, token);

source.CancelAfter(500);

Exception Handling Strategies

  1. Accessing Task.Result blocks and propagates the original exception wrapped inside AggregateException.
  2. AggregateException.InnerException yields the first exception; the collection may itself contain other AggregateException instances.
  3. Flattening the root AggregateException with Flatten() retrieves all leaf-level exceptions across nested aggregates.
  4. Using GetAwaiter().GetResult() unwraps the original exception. When caught inside a try/catch, the TPL infrastructure surfaces the raw exception without an outer AggregateException.
  5. Fault-only continuations (TaskContinuationOptions.OnlyOnFaulted) act as specialized exception handlers:
Task.Run(() => PossiblyFaultyWork())
    .ContinueWith(faultedTask => Log(faultedTask.Exception),
                  TaskContinuationOptions.OnlyOnFaulted);

Running Tasks in Parallel

Task.WhenAll returns a task that completes when every supplied task has finished; its result is an array of the individual results. Task.WhenAny finishes as soon as any one of the provided tasks completes, returning the winner.

var tasks = new[] { DownloadAsync(url1), DownloadAsync(url2) };
string[] allResults = await Task.WhenAll(tasks);

Task<int> fastest = await Task.WhenAny(tasks);
int firstResult = await fastest;

Custom Scheduling and UI Threads

The default TaskScheduler puts work on thread-pool threads. For UI frameworks, TaskScheduler.FromCurrentSynchronizationContext() captures the synchronization context, enabling continuations to marshal back to the UI thread.

Task.Run(() => LoadData())
    .ContinueWith(dataTask =>
    {
        // Update UI safely on the captured context
        textBox.Text = dataTask.Result;
    }, TaskScheduler.FromCurrentSynchronizationContext());

Avoid synchronous blocks on the UI thread, as they can lead to deadlocks. Prefer ContinueWith with the correct scheduler or the async/await pattern, which automatically resumes on the captured context.

Tags: .NET Task Parallel Library Asynchronous Programming TPL Task

Posted on Wed, 09 Sep 2026 16:56:20 +0000 by timvw