TaskCompletionSource in C#: Wrap Callback APIs

The TaskCompletionSource<TResult> class in C# provides a mechanism to manually control the lifecycle and completion state of a Task<TResult>. This article explains what TaskCompletionSource is, why it is essential for modernizing asynchronous code, and how to use it to convert legacy callback-driven methods into clean, awaitable Task-based operations.


What is TaskCompletionSource?

TaskCompletionSource<TResult> creates and manages a Task<TResult> that is not bound to a specific delegate or CPU-bound thread. Instead of relying on Task.Run or native async I/O drivers to transition a task's state, TaskCompletionSource exposes explicit methods that allow developers to set the outcome manually:

  • SetResult(TResult) / TrySetResult(TResult): Completes the task in the RanToCompletion state.
  • SetException(Exception) / TrySetException(Exception): Faults the task with the provided exception.
  • SetCanceled() / TrySetCanceled(): Moves the task to the Canceled state.

Accessing the .Task property yields a standard Task<TResult> that can be awaited using the async and await keywords.


Why Wrap Callback APIs?

Older .NET libraries, external SDKs, and legacy services often rely on callback delegates to notify callers of completion. For example:

public interface ILegacyService
{
    void FetchData(string query, Action<string, Exception> callback);
}

Consuming callback APIs often leads to nested closures ("callback hell"), poor error handling, and incompatibility with modern async/await patterns. Wrapping these APIs with TaskCompletionSource transforms them into idiomatic asynchronous methods.


Step-by-Step Implementation

To wrap a callback-based API:

  1. Instantiate TaskCompletionSource<TResult>.
  2. Configure it with TaskCreationOptions.RunContinuationsAsynchronously to prevent continuation code from running synchronously on the thread that calls SetResult.
  3. Call the legacy method, passing a callback that handles success, error, and cancellation cases by updating the source.
  4. Return the associated TaskCompletionSource.Task.

Example

using System;
using System.Threading.Tasks;

public static class LegacyServiceWrapper
{
    public static Task<string> FetchDataAsync(this ILegacyService service, string query)
    {
        // 1. Initialize the TaskCompletionSource
        var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);

        // 2. Invoke the callback-based method
        service.FetchData(query, (result, error) =>
        {
            // 3. Complete the task based on the callback arguments
            if (error != null)
            {
                tcs.TrySetException(error);
            }
            else
            {
                tcs.TrySetResult(result);
            }
        });

        // 4. Return the underlying Task
        return tcs.Task;
    }
}

Now, the legacy method can be consumed cleanly using standard modern syntax:

public async Task ProcessQueryAsync(ILegacyService service)
{
    try
    {
        string data = await service.FetchDataAsync("SearchQuery");
        Console.WriteLine($"Received: {data}");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Operation failed: {ex.Message}");
    }
}

Key Best Practices

  • Always Use RunContinuationsAsynchronously: Without this option, awaiting code (continuations) might execute synchronously on the thread invoking SetResult, which can lead to deadlocks or thread starvation in callback worker pools.
  • Prefer TrySet* Over Set*: Methods like TrySetResult return a bool instead of throwing an InvalidOperationException if the task has already been completed, canceled, or faulted. This is vital when callbacks might fire more than once or when integrating cancellation tokens.
  • Handling Non-Generic Tasks: If the legacy method does not return a value, use TaskCompletionSource<bool> or the non-generic TaskCompletionSource available in modern .NET (.NET 5+) and supply a dummy value (e.g., tcs.SetResult(true)) or call tcs.SetResult().