Mastering TaskCompletionSource in C#

C# Jun 19, 2023

Asynchronous programming is at the heart of modern .NET applications to enable scalable, responsive systems without blocking threads. While async/await and the built-in Task returning APIs cover most scenarios, sometimes you need custom asynchronous operations, particularly when bridging callback-based code, implementing complex synchronization primitives or exposing event-driven logic as a Task. TaskCompletionSource<TResult> (TCS) is the low level building block that makes this possible.

Anatomy of a TaskCompletionSource

At its core, a TaskCompletionSource<TResult> wraps a Task<TResult> that you can manually control. Unlike the Task.Run or existing methods that return Task, the TCS gives you:

  • A producer of a Task<TResult> which clients get the result from via await tcs.Task
  • Explicit methods to complete the task using SetResult, SetException, SetCanceled or their TrySet... variants.
var tcs = new TaskCompletionSource<int>();
// Consumer side
var consumer = Task.Run(async () =>
{
    int result = await tcs.Task; // waits until someone calls SetResult/SetException
    Console.WriteLine($"Result: {result}");
});

// Producer side (somewhere else)
Task.Delay(500).ContinueWith(_ => tcs.SetResult(42));

Internally, TCS schedules continuations when the result is set. If the Task is already completed, awaiting the task yields immediately.

Completion, Faulting and Cancellation

SetResult vs TrySetResult

  • SetResult(value) throws an exception if the TCS has already been completed.
  • TrySetResult(value) returns false instead, allowing for safe and idempotent completion of the TCS.
if (!tcs.TrySetResult(responseCode))
{
    // Another code path already completed the task
}

Faulting with Exceptions

To propagate errors, we use:

tcs.SetException(new InvalidOperationException("Failure reason"));

Any awaiting code will receive that exception. For multiple exceptions, pass an IEnumerable<Exception>. Like SetResult, it has its own TrySetException implementation that attempts to transition the result into fault state and returns false if the result of the underlying Task has already been set.

Cancellation

You can mark a TCS-coupled task as canceled

var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(1));
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
cts.Token.Register(() => tcs.TrySetCanceled(), useSynchronizationContext: false);

Adapting Callbacks and Events

Callback-to-Task Pattern

Bridge legacy APIs that take callbacks

Task<string> ReadDataAsync()
{
    var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
    legacyApi.BeginRead(
        callback: result =>
        {
            try { tcs.SetResult(legacyApi.EndRead(result)); }
            catch (Exception ex) { tcs.SetException(ex); }
        },
        state: null);
    return tcs.Task;
}

Event-to-Task pattern

Task WaitForSignalAsync(IEventSource source)
{
    var tcs = new TaskCompletionSource<object>();
    EventHandler handler = null;
    handler = (s, e) =>
    {
        source.SignalReceived -= handler;
        tcs.TrySetResult(null);
    };
    source.SignalReceived += handler;
    return tcs.Task;
}

Building Synchronization Primitives

By combing TCS with atomic operations, you can craft async-friendly locks, barriers and semaphores. Example is an async mutual-exclusion (AsyncLock)

public class AsyncLock
{
    private readonly Queue<TaskCompletionSource<IDisposable>> _waiters = new();
    private bool _isLocked;

    public Task<IDisposable> LockAsync()
    {
        lock (_waiters)
        {
            if (!_isLocked)
            {
                _isLocked = true;
                return Task.FromResult((IDisposable)new Releaser(this));
            }
            var tcs = new TaskCompletionSource<IDisposable>(TaskCreationOptions.RunContinuationsAsynchronously);
            _waiters.Enqueue(tcs);
            return tcs.Task;
        }
    }

    private void Release()
    {
        TaskCompletionSource<IDisposable> toRelease = null;
        lock (_waiters)
        {
            if (_waiters.TryDequeue(out toRelease)) { /* next */ }
            else { _isLocked = false; }
        }
        toRelease?.SetResult(new Releaser(this));
    }

    private struct Releaser : IDisposable
    {
        private readonly AsyncLock _toRelease;
        public Releaser(AsyncLock toRelease) => _toRelease = toRelease;
        public void Dispose() => _toRelease.Release();
    }
}

Consumers await myLock.LockAsync() and get an IDisposable the Dispose() to release.

Common Pitfalls

  • Never completing the TCS leave the tasks in limbo. Introduce timeouts or line to a CancellationToken to ensure you can abort and observe failures instead of callers waiting eternally.
  • Blocking Continuations: avoid calling .Result or .Wait() inside TCS callbacks or continuations. Blocking on the resulting task can lead to deadlock. Use async/await all the way through or offload work to the thread pool.
  • Context capture: By default, continuations resume on the captured SynchronizationContext (e.g a UI thread). Long-running producers can deadlock and starve that context. Use RunContinuationsAsynchronously to force continuations on the thread pool.
  • Memory leaks: If you adapt events or queue waits in TCS based primitives, be sure to unsubscribe dequeue even on cancellation or failure. Otherwise, you'll accumulate dangling handles or TCS objects that never complete.

Tags

Views: Loading...