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 theRanToCompletionstate.SetException(Exception)/TrySetException(Exception): Faults the task with the provided exception.SetCanceled()/TrySetCanceled(): Moves the task to theCanceledstate.
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:
- Instantiate
TaskCompletionSource<TResult>. - Configure it with
TaskCreationOptions.RunContinuationsAsynchronouslyto prevent continuation code from running synchronously on the thread that callsSetResult. - Call the legacy method, passing a callback that handles success, error, and cancellation cases by updating the source.
- 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 invokingSetResult, which can lead to deadlocks or thread starvation in callback worker pools. - Prefer
TrySet*OverSet*: Methods likeTrySetResultreturn aboolinstead of throwing anInvalidOperationExceptionif 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-genericTaskCompletionSourceavailable in modern .NET (.NET 5+) and supply a dummy value (e.g.,tcs.SetResult(true)) or calltcs.SetResult().