Mastering TaskCompletionSource in C#
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 viaawait tcs.Task
- Explicit methods to complete the task using
SetResult
,SetException
,SetCanceled
or theirTrySet...
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)
returnsfalse
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. Useasync
/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. UseRunContinuationsAsynchronously
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.