`Task<T>` results, await versus .Result versus GetAwaiter().GetResult(), TaskCompletionSource of T, cached completed tasks and why `Task<T>` is not covariant.
Task<T> derives from Task and adds a Result of type T. The safe way to get it is await, which unwraps the value and rethrows the original exception. .Result blocks the calling thread and wraps failures in AggregateException; GetAwaiter().GetResult() also blocks but throws the original exception. Because Task<T> is a class, it is not covariant: a Task<string> is not a Task<object>. Return Task.FromResult for synchronous results and use TaskCompletionSource<T> to turn callbacks into a Task<T>.
"
Task<T>derives fromTaskand adds a typedResult. Everything else (status, exceptions, continuations, cancellation) is the same. The safe way to get the value isawait: it doesn't block, and if the task failed it rethrows the original exception..Resultand.Wait()block the calling thread if the task isn't finished, and they wrap failures in anAggregateException.GetAwaiter().GetResult()also blocks but throws the original exception, so it's the least-bad option at a forced synchronous boundary. Reading.Resultis perfectly fine once you know the task has completed, for example afterawait Task.WhenAll. For producing aTask<T>:Task.FromResultfor values you already have,Task.Runfor CPU work, async methods for I/O, andTaskCompletionSource<T>withRunContinuationsAsynchronouslyto bridge callbacks. AndTask<T>is a class, so it isn't covariant: aTask<string>is not aTask<object>."
| Way | Blocks if incomplete? | Exception surfaced | When to use |
|---|---|---|---|
await task |
No | Original exception (first, if several) | Default |
task.Result |
Yes | AggregateException |
Only when the task is known complete |
task.GetAwaiter().GetResult() |
Yes | Original exception | Forced sync boundaries (legacy entry points), with care |
task.Wait() then task.Result |
Yes | AggregateException |
Avoid |
task.IsCompletedSuccessfully ? task.Result : await task |
Only awaits if needed | Original | Micro-optimised hot paths |
Task<T>| Situation | Use | Notes |
|---|---|---|
| I/O-bound work | async Task<T> method |
Promise task, no thread while waiting |
| Value already available (cache hit) | Task.FromResult(value) |
No thread hop |
| CPU-bound work off the caller | Task.Run(() => Compute()) |
Delegate task on the pool |
| Callback/event-based API | TaskCompletionSource<T> |
Complete exactly once; use RunContinuationsAsynchronously |
| Error without throwing | Task.FromException<T>(ex) |
Faulted task |
| Cancelled result | Task.FromCanceled<T>(token) |
Canceled task |
| Hot path, often synchronous | ValueTask<T> |
Avoids allocating a Task<T> (see next question) |
A task can hold multiple exceptions (from WhenAll, or attached child tasks). Blocking APIs predate await and expose them all as an AggregateException. await (via the awaiter's GetResult) throws the first inner exception with its original stack trace preserved via ExceptionDispatchInfo, which is what most code expects to catch.
C# variance only applies to generic interfaces and delegates. Task<T> is a class:
Task<string> s = GetNameAsync();
Task<object> o = s; // CS0029: cannot convert
Task<object> ok = Adapt(s);
static async Task<object> Adapt(Task<string> t) => await t;
In our [pricing service], a cache wrapper returned Task.Run(() => _cache[key]) for hits on a path called [20,000 times/second]. A PerfView CPU profile showed a large share of samples in thread-pool queueing and task allocation, and the pool's completed work-item count was several times the request rate. Every cache hit was paying for a pool hop and a task allocation for a value already in memory. I changed the method to return ValueTask<Price>, returning the cached value directly and only awaiting the backing store on a miss. CPU on that path dropped by [about half], and I added a BenchmarkDotNet benchmark with [MemoryDiagnoser] to CI so it couldn't regress.
Checks practical knowledge of retrieving results and creating tasks.
Green flags
await, .Result and GetResult.Task.FromResult for synchronous results.TaskCompletionSource and the RunContinuationsAsynchronously flag.Task<T> is not covariant.Red flags
.Result everywhere.Task.Run to make it "async".await does with a Task<T>int x = await GetCountAsync();
becomes (simplified):
TaskAwaiter<int> awaiter = GetCountAsync().GetAwaiter();
if (!awaiter.IsCompleted)
{
// store state, register continuation, return to caller
}
int x = awaiter.GetResult(); // returns Result, or rethrows the original exception
TaskAwaiter<T>.GetResult() validates the task's final state: RanToCompletion → value; Faulted → rethrow first exception via ExceptionDispatchInfo; Canceled → throw TaskCanceledException.
// WinForms/WPF button handler, or old ASP.NET
var data = LoadAsync().Result; // blocks the UI thread
async Task<string> LoadAsync()
{
await Task.Delay(100); // continuation wants to resume on the UI thread
return "done"; // ...which is blocked waiting for us -> deadlock
}
ASP.NET Core has no SynchronizationContext, so it usually doesn't deadlock there, but it still blocks a pool thread per call, which leads to starvation under load.
TaskCompletionSource<T> rulesvar tcs = new TaskCompletionSource<Reply>(TaskCreationOptions.RunContinuationsAsynchronously);
using CancellationTokenRegistration reg = ct.Register(() => tcs.TrySetCanceled(ct));
_pending[id] = tcs;
await SendAsync(request, ct);
Reply reply = await tcs.Task; // completed when the matching response arrives
| Rule | Why |
|---|---|
Complete once (prefer TrySet... when completion can race) |
Second SetResult throws InvalidOperationException |
RunContinuationsAsynchronously |
Otherwise the awaiting code runs inline inside SetResult, possibly while the completer holds a lock |
| Always complete on every path (success, error, cancel, timeout) | Otherwise awaiters hang forever |
| Dispose cancellation registrations | Avoid leaking callbacks on long-lived tokens |
The non-generic TaskCompletionSource (.NET 5+) is the result-less version for pure signals.
Task.CompletedTask is a singleton.Task<T> instances for common results (e.g. Task<bool> true/false, small int values in some versions, null for reference types). Otherwise each synchronously completing call allocates.Result safely after WhenAllTask<User> u = GetUserAsync(id);
Task<Order[]> o = GetOrdersAsync(id);
await Task.WhenAll(u, o);
return new Profile(u.Result, o.Result); // fine: both are complete; no blocking
Or just await u again: awaiting a completed task is cheap and keeps exception shapes consistent.
| Version | Change |
|---|---|
| .NET Framework 4.0 | Task<T>, generic TaskCompletionSource<T> |
| .NET 4.5 | Task.FromResult, await |
| .NET Framework 4.6 | Task.CompletedTask, FromException, FromCanceled, RunContinuationsAsynchronously |
| .NET Core 2.1 | ValueTask<T> with IValueTaskSource<T> |
| .NET 5 | Non-generic TaskCompletionSource |
| .NET 10 | Same semantics |
await, .Result and GetResult()..Result on an async method from a button click and observe the deadlock; fix it with await.TaskCompletionSource without the async-continuations flag, complete it inside a lock, and log which thread the continuation runs on.Task.FromResult vs Task.Run vs ValueTask for a cache hit with BenchmarkDotNet.Task<object> o = Task.FromResult("x"); and read the compiler error; then write an adapter.Task<int> failing = Task.Run<int>(() => throw new InvalidOperationException("boom"));
try { await failing; }
catch (Exception ex) { Console.WriteLine($"await: {ex.GetType().Name}"); } // InvalidOperationException
try { _ = failing.Result; }
catch (Exception ex) { Console.WriteLine($".Result: {ex.GetType().Name}"); } // AggregateException
try { _ = failing.GetAwaiter().GetResult(); }
catch (Exception ex) { Console.WriteLine($"GetResult: {ex.GetType().Name}"); } // InvalidOperationException
// Bridging a callback API with TaskCompletionSource<T>.
static Task<string> DownloadAsync(LegacyClient client)
{
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
client.Completed += (_, text) => tcs.TrySetResult(text);
client.Failed += (_, ex) => tcs.TrySetException(ex);
client.Start();
return tcs.Task;
}
Console.WriteLine(await DownloadAsync(new LegacyClient()));
public sealed class LegacyClient
{
public event EventHandler<string>? Completed;
public event EventHandler<Exception>? Failed;
public void Start() => ThreadPool.QueueUserWorkItem(_ =>
{
Thread.Sleep(50);
Completed?.Invoke(this, "payload");
});
}
.Result on incomplete tasks in request codeWhat happens: sync code calls async code and blocks.
Why it is wrong: blocks a pool thread; deadlocks under a SynchronizationContext; starvation under load.
Do instead: await all the way up.
.ResultWhat happens: catch (HttpRequestException) around .Result.
Why it is wrong: .Result throws AggregateException; the catch never matches.
Do instead: await, or unwrap InnerExceptions.
Task.RunWhat happens: return Task.Run(() => cachedValue);.
Why it is wrong: a pool hop and allocation for nothing.
Do instead: Task.FromResult(value) or ValueTask<T>.
TaskCompletionSource without RunContinuationsAsynchronouslyWhat happens: SetResult is called while holding a lock.
Why it is wrong: awaiting continuations run inline inside the lock: deadlocks, re-entrancy, stalls.
Do instead: always pass TaskCreationOptions.RunContinuationsAsynchronously.
TaskCompletionSource on every pathWhat happens: the error or timeout path forgets to call TrySetException/TrySetCanceled.
Why it is wrong: awaiters hang forever and leak.
Do instead: complete in finally/cancellation registrations; prefer TrySet....
SetResult twiceWhat happens: a duplicate response triggers a second completion.
Why it is wrong: throws InvalidOperationException.
Do instead: use TrySetResult when completion can race.
Task<T> covarianceWhat happens: code tries to return a Task<Derived> as Task<Base>.
Why it is wrong: classes aren't variant in C#.
Do instead: an async adapter, or return Task<Base> from the start.
GetAwaiter().GetResult() as a "safe" .ResultWhat happens: developers replace .Result with GetResult() everywhere.
Why it is wrong: it still blocks and can still deadlock; only the exception shape changes.
Do instead: use it only at unavoidable sync boundaries; otherwise await.
What happens: await GetAsync(); ... var x = await GetAsync(); to get the value again.
Why it is wrong: starts the operation twice.
Do instead: store the task or the result.
null instead of a taskWhat happens: a Task<T>-returning (non-async) method returns null on some path.
Why it is wrong: await null throws NullReferenceException.
Do instead: return Task.FromResult(default(T)) or Task.CompletedTask.
Seniors treat the result-access method as a decision about blocking and exception shape, not a style choice. I also check for TaskCompletionSource without the async-continuations flag, because it causes some of the hardest deadlocks to diagnose.
| Situation | Use |
|---|---|
| Normal async code | await |
| Task known to be complete | Result is fine |
| Forced sync boundary (Main, legacy) | GetAwaiter().GetResult() with care |
| Synchronous fast path | Task.FromResult or ValueTask<T> |
| Callback or event API | TaskCompletionSource<T> with async continuations |
ValueTask<T> is a struct that holds either a result, a Task<T> or an IValueTaskSource<T>. It avoids allocating a task when a method usually completes synchronously (cache hits, buffered reads) and lets libraries pool the async case. The cost is stricter rules: await it once, never await it concurrently, and never read .Result before it completes; call .AsTask() if you need any of that. Use Task by default and choose ValueTask for measured hot paths or when an interface already uses it, like Stream.ReadAsync(Memory<byte>) or IAsyncDisposable.DisposeAsync.
An exception inside a task or async method is captured and stored in the task, which becomes Faulted. await rethrows the first stored exception with its original stack; .Wait()/.Result throw an AggregateException. await Task.WhenAll(...) also throws only the first, so inspect each task (or the WhenAll task's Exception.InnerExceptions) to see all failures. OperationCanceledException for the task's token makes it Canceled, not Faulted. Exceptions in tasks nobody observes raise TaskScheduler.UnobservedTaskException later and are otherwise ignored. Exceptions in async void go to the SynchronizationContext or the ThreadPool and crash the process.
A Task is an object that represents an operation that will complete in the future, with a status, an optional exception and the ability to attach continuations. It is not a thread. Some tasks run code on a pool thread (Task.Run, called delegate tasks); many represent work with no thread at all, such as a pending network read, a timer (Task.Delay) or a TaskCompletionSource (promise tasks). You compose tasks with await, Task.WhenAll, Task.WhenAny and WaitAsync, and avoid blocking on them.
How to tell it (STAR)
Profiling our [pricing service] showed thread-pool overhead on cache hits, because the wrapper used `Task.Run` for values already in memory. I returned the value directly with `ValueTask<T>` and only awaited on misses. CPU on that path dropped by [about half], and I added a benchmark to keep it that way.
Last updated 31 Oct 2026.