📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does Task of T differ from Task, and what should you know about getting its result safely?

`Task<T>` results, await versus .Result versus GetAwaiter().GetResult(), TaskCompletionSource of T, cached completed tasks and why `Task<T>` is not covariant.

Mid-Level Theory Intermediate async task task-of-t taskcompletionsource

🎯 Key points

  • Task<T> extends Task with a typed Result
  • await does not block and rethrows the original exception
  • .Result blocks and throws AggregateException; GetAwaiter().GetResult() blocks and throws the original
  • Use Task.FromResult for known values, not Task.Run
  • TaskCompletionSource<T> bridges callbacks; complete once and use RunContinuationsAsynchronously
  • Task<T> is a class, so it is not covariant

⏱ 30-second answer

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>.

🗣 Full answer (senior)

How I would answer this in an interview

"Task<T> derives from Task and adds a typed Result. Everything else (status, exceptions, continuations, cancellation) is the same. The safe way to get the value is await: it doesn't block, and if the task failed it rethrows the original exception. .Result and .Wait() block the calling thread if the task isn't finished, and they wrap failures in an AggregateException. GetAwaiter().GetResult() also blocks but throws the original exception, so it's the least-bad option at a forced synchronous boundary. Reading .Result is perfectly fine once you know the task has completed, for example after await Task.WhenAll. For producing a Task<T>: Task.FromResult for values you already have, Task.Run for CPU work, async methods for I/O, and TaskCompletionSource<T> with RunContinuationsAsynchronously to bridge callbacks. And Task<T> is a class, so it isn't covariant: a Task<string> is not a Task<object>."


1. Ways to get the value

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

2. Ways to create a 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)

3. Why the exception shapes differ

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.

4. No covariance

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;

Production example

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.

🔍 What the interviewer is checking

Checks practical knowledge of retrieving results and creating tasks.

Green flags

  • Explains exception differences between await, .Result and GetResult.
  • Uses Task.FromResult for synchronous results.
  • Knows TaskCompletionSource and the RunContinuationsAsynchronously flag.
  • Knows Task<T> is not covariant.

Red flags

  • Uses .Result everywhere.
  • Wraps synchronous code in Task.Run to make it "async".

📚 Learn in depth

1. What 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.

2. Why blocking can deadlock

// 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.

3. TaskCompletionSource<T> rules

var 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.

4. Cached completed tasks

  • Task.CompletedTask is a singleton.
  • The async method builder caches some completed 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.
  • Never rely on task reference identity.

5. Reading Result safely after WhenAll

Task<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.

6. Version notes

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

7. Try it yourself

  1. Run the code example and compare exception types from await, .Result and GetResult().
  2. In a WPF or WinForms app, call .Result on an async method from a button click and observe the deadlock; fix it with await.
  3. Create a TaskCompletionSource without the async-continuations flag, complete it inside a lock, and log which thread the continuation runs on.
  4. Benchmark Task.FromResult vs Task.Run vs ValueTask for a cache hit with BenchmarkDotNet.
  5. Try Task<object> o = Task.FromResult("x"); and read the compiler error; then write an adapter.

💻 Code

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");
    });
}

⚠ Common mistakes

1. .Result on incomplete tasks in request code

What 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.

2. Catching the original exception type around .Result

What happens: catch (HttpRequestException) around .Result. Why it is wrong: .Result throws AggregateException; the catch never matches. Do instead: await, or unwrap InnerExceptions.

3. Wrapping synchronous values in Task.Run

What happens: return Task.Run(() => cachedValue);. Why it is wrong: a pool hop and allocation for nothing. Do instead: Task.FromResult(value) or ValueTask<T>.

4. TaskCompletionSource without RunContinuationsAsynchronously

What 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.

5. Not completing a TaskCompletionSource on every path

What 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....

6. Calling SetResult twice

What happens: a duplicate response triggers a second completion. Why it is wrong: throws InvalidOperationException. Do instead: use TrySetResult when completion can race.

7. Expecting Task<T> covariance

What 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.

8. GetAwaiter().GetResult() as a "safe" .Result

What 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.

9. Re-awaiting by calling the method twice

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.

10. Returning null instead of a task

What 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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions allocation of completed tasks Why: Checks ValueTask knowledge
    How to answer

    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.

  2. When: Candidate mentions AggregateException Why: Probes async exception handling
    How to answer

    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.

  3. When: Candidate uses TaskCompletionSource Why: Checks delegate versus promise tasks
    How to answer

    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.

🛠 Real-world problems: how you handle them

Cache hits paying a thread-pool hop
Situation
A [pricing service] cache returned `Task.Run(() => dict[key])` for hits on a path called [20,000 times per second].
Signals
High CPU in ThreadPool dispatch frames in a CPU profile; work item count far above request count.
Investigation
PerfView showed most samples in thread-pool queueing and task allocation for cache hits.
Resolution
Returned `ValueTask<T>` with the cached value directly, and only went async on misses.
Prevention
Added a benchmark in BenchmarkDotNet for the cache path to CI.

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.

↩ Usually asked after

More in Tasks

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me