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

📚 Interview question

What is a Task in .NET? Is a Task the same as a thread?

Task as a promise of completion, delegate tasks versus promise tasks, TaskStatus, and the core combinators in .NET 10.

Mid-Level Theory Intermediate async concurrency task tpl

🎯 Key points

  • A Task represents a future completion with status, exception and continuations
  • A Task is not a thread; promise tasks have no thread while pending
  • Task.Run creates delegate tasks on the ThreadPool
  • Final states: RanToCompletion, Faulted, Canceled
  • Tasks from methods are hot; start independent work then await Task.WhenAll
  • Use WhenAny, WaitAsync and WhenEach for timeouts and completion order
  • Avoid new Task().Start() and blocking with .Result

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

"A Task is an object representing an operation that will complete in the future. It carries a status, a result for Task<T>, any exception or cancellation, and a list of continuations to run when it completes. It is not a thread. Some tasks do run code on a pool thread: Task.Run creates a delegate task. But most tasks in a server are promise tasks: a pending socket read, a Task.Delay timer, an async method waiting on I/O, or a TaskCompletionSource. While those are pending, no thread is involved at all. That separation of what is happening from which thread runs it is what lets a few dozen threads serve thousands of concurrent requests. I compose tasks with await, Task.WhenAll, WhenAny, WhenEach and WaitAsync, and I don't block on them with .Result or .Wait()."


1. Task vs thread

Thread Task
What it is An OS thread with a stack A representation of a future completion
Uses a thread? Is one Only delegate tasks, and only while running
Result / exception Manual plumbing Built in (Result, Exception, await)
Composition Join, events await, WhenAll, WhenAny, WhenEach, ContinueWith
Cancellation Manual CancellationToken integration, Canceled state
Cost ~MB stack reservation, kernel object A small heap object

2. Two kinds of tasks

Kind Created by Thread while pending? Example
Delegate task Task.Run, Task.Factory.StartNew Yes, while the delegate runs Task.Run(() => ResizeImage(bytes))
Promise task async I/O, Task.Delay, async methods, TaskCompletionSource<T> No await httpClient.GetAsync(url)

3. Status and completion

TaskStatus Meaning
Created Cold task from new Task(...), not started (avoid)
WaitingForActivation Promise task / async method not finished
WaitingToRun → Running Delegate task queued, then executing
WaitingForChildrenToComplete Has attached children (rare; Task.Run denies attachment)
RanToCompletion Success
Faulted Exception(s) in Task.Exception (an AggregateException)
Canceled Cancelled via token / OperationCanceledException

IsCompleted is true for the last three; IsCompletedSuccessfully only for RanToCompletion.

4. Composition toolkit (.NET 10)

Need API
Wait for one asynchronously await task
Run independent work concurrently Start all, then await Task.WhenAll(...)
First to finish Task.WhenAny(...)
Process results in completion order await foreach (var t in Task.WhenEach(tasks)) (.NET 9+)
Stop waiting after a timeout / on cancellation task.WaitAsync(timeout, token) (.NET 6+)
Already-completed tasks Task.CompletedTask, Task.FromResult, Task.FromException, Task.FromCanceled
Bridge callbacks/events TaskCompletionSource<T> with RunContinuationsAsynchronously

5. Things to avoid

Avoid Because Use
new Task(...).Start() Cold tasks are easy to forget; uses TaskScheduler.Current Task.Run
.Result / .Wait() on incomplete tasks Blocks a thread; can deadlock under a SynchronizationContext await
Task.Factory.StartNew(async () => ...) Returns Task<Task>; outer completes at the first await Task.Run(async () => ...) (unwraps)
Awaiting independent calls one by one Latency = sum WhenAll

Production example

Our [dashboard endpoint] aggregated data from three internal services and had a p95 of [900 ms]. The distributed trace showed the three HTTP spans strictly back to back, with no data dependency between them: the code awaited each call before starting the next. I started all three tasks first, then awaited Task.WhenAll(...) with a shared WaitAsync(TimeSpan.FromSeconds(2), ct) timeout. p95 dropped to [about 400 ms], the slowest dependency, with no extra threads, because the tasks were I/O promises. We added a review checklist item for sequential independent awaits.

🔍 What the interviewer is checking

Checks the core mental model of tasks.

Green flags

  • States clearly that a Task is not a thread.
  • Distinguishes delegate tasks from promise tasks.
  • Uses WhenAll for independent work.
  • Knows the final statuses and IsCompletedSuccessfully.

Red flags

  • "Each Task runs on its own thread."
  • Blocks on tasks as normal practice.

📚 Learn in depth

1. What's inside a Task

Conceptually:

Task
 ├─ m_stateFlags        (status bits: started, completed, faulted, canceled, ...)
 ├─ m_continuationObject (null | single continuation | list of continuations)
 ├─ m_action / m_stateObject (delegate tasks only)
 ├─ m_contingentProperties (lazily: exception holder, cancellation registration, parent, ...)
 └─ (Task<T>) m_result

The common case is lean: a completed async method with no exception doesn't allocate the contingent properties. Completing a task atomically sets the state and then runs or schedules continuations.

2. How a promise task completes

var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
socket.OnMessage += msg => tcs.TrySetResult(msg);   // completed by an event, no thread waiting
string first = await tcs.Task;

RunContinuationsAsynchronously makes the awaiting code run on the pool instead of synchronously inside the TrySetResult call, which avoids surprising re-entrancy and deadlocks in the completing code.

3. Hot tasks and concurrency

Task<int> a = GetAAsync();   // starts now
Task<int> b = GetBAsync();   // starts now
int[] r = await Task.WhenAll(a, b);   // observes both

await never starts anything; it only observes. Tasks returned by methods are already running. That's why the order of starting vs awaiting decides whether work is sequential or concurrent.

4. WhenAll exception semantics

  • The returned task faults with all inner exceptions in its AggregateException.
  • await Task.WhenAll(...) rethrows only the first; inspect the WhenAll task's Exception.InnerExceptions (or each task) to see all.
  • It completes only when all tasks finish, even if one fails early; use cancellation or WhenEach/WhenAny if you want to stop early.

5. WhenEach (.NET 9+)

Task<Quote>[] quotes = providers.Select(p => p.GetQuoteAsync(ct)).ToArray();
await foreach (Task<Quote> done in Task.WhenEach(quotes))
{
    if (done.IsCompletedSuccessfully) Show(done.Result);   // safe: it's complete
}

This replaces the old while (tasks.Count > 0) { var t = await Task.WhenAny(tasks); tasks.Remove(t); } pattern, which was O(n²).

6. WaitAsync vs cancellation

task.WaitAsync(TimeSpan.FromSeconds(2)) stops waiting; the underlying operation keeps running. To actually stop the work, pass a CancellationToken into the operation itself.

7. Version notes

Version Change
.NET Framework 4.0 TPL: Task, Task<T>
C# 5 / .NET 4.5 async/await, Task.Run, Task.Delay, WhenAll, WhenAny
.NET Core 2.0 IsCompletedSuccessfully
.NET 6 WaitAsync
.NET 9 Task.WhenEach; params ReadOnlySpan<Task> overloads for WhenAll/WhenAny
.NET 10 Same model

8. Try it yourself

  1. Run the code example and compare sequential vs concurrent timings.
  2. Print ThreadPool.ThreadCount during 1,000 concurrent Task.Delay(1000) calls: it stays small.
  3. Make two of three tasks throw and inspect await vs the WhenAll task's Exception.InnerExceptions.
  4. Replace a WhenAny loop with Task.WhenEach and print results in completion order.
  5. Use Task.Factory.StartNew(async () => { await Task.Delay(1000); }) and observe it "completes" immediately; fix it with Task.Run or .Unwrap().
  6. Create a TaskCompletionSource without RunContinuationsAsynchronously, complete it while holding a lock, and observe the continuation running inside the lock.

💻 Code

using System.Diagnostics;

var sw = Stopwatch.StartNew();
int a = await FetchAsync("orders", 300);
int b = await FetchAsync("stock", 400);
int c = await FetchAsync("prices", 200);
Console.WriteLine($"Sequential: {sw.ElapsedMilliseconds} ms"); // about 900

sw.Restart();
Task<int> orders = FetchAsync("orders", 300); // hot: already running
Task<int> stock = FetchAsync("stock", 400);
Task<int> prices = FetchAsync("prices", 200);
int[] all = await Task.WhenAll(orders, stock, prices);
Console.WriteLine($"Concurrent: {sw.ElapsedMilliseconds} ms, total {all.Sum()}"); // about 400

// A promise task with no thread behind it.
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Delay(100).ContinueWith(_ => tcs.SetResult("signalled"), TaskScheduler.Default);
Console.WriteLine(await tcs.Task);

static async Task<int> FetchAsync(string name, int ms)
{
    await Task.Delay(ms); // stands in for an HTTP call
    return ms;
}

⚠ Common mistakes

1. Saying a Task is a lightweight thread

What happens: "each task runs on its own green thread". Why it is wrong: most tasks have no thread; they're promises completed by I/O or timers. Do instead: describe a task as a representation of a future result.

2. Awaiting independent calls sequentially

What happens: await A(); await B(); await C(); with no dependencies. Why it is wrong: latency becomes the sum. Do instead: start all, then await Task.WhenAll(...).

3. Using new Task(...).Start()

What happens: cold tasks created and started manually. Why it is wrong: easy to forget Start; uses TaskScheduler.Current, which may not be the pool. Do instead: Task.Run for CPU work, async methods for I/O.

4. Blocking with .Result or .Wait()

What happens: sync code calls into async code and blocks. Why it is wrong: holds a pool thread, risks starvation and deadlocks. Do instead: await all the way up.

5. Task.Factory.StartNew with an async lambda

What happens: await Task.Factory.StartNew(async () => ...) "finishes" instantly. Why it is wrong: it returns Task<Task>; you awaited only the outer task. Do instead: Task.Run(async () => ...) or .Unwrap().

6. Thinking WaitAsync cancels the work

What happens: a timeout is added with WaitAsync and the operation is assumed stopped. Why it is wrong: only the wait stops; the work continues and may still have side effects. Do instead: pass a CancellationToken into the operation.

7. Losing exceptions from WhenAll

What happens: catch (Exception ex) after await Task.WhenAll logs only one failure. Why it is wrong: await rethrows the first exception only. Do instead: inspect the WhenAll task's Exception.InnerExceptions or each task.

8. Wrapping already-async calls in Task.Run

What happens: await Task.Run(() => httpClient.GetAsync(url)). Why it is wrong: an extra pool hop for nothing. Do instead: await httpClient.GetAsync(url).

9. Starting thousands of tasks without limits

What happens: Task.WhenAll(items.Select(CallApiAsync)) over 50,000 items. Why it is wrong: unbounded concurrency overwhelms downstream services and sockets. Do instead: Parallel.ForEachAsync with MaxDegreeOfParallelism, or a SemaphoreSlim.

10. Disposing tasks

What happens: task.Dispose() calls sprinkled "for cleanliness". Why it is wrong: unnecessary in almost all code and throws if the task isn't complete. Do instead: don't dispose tasks.

🏛 Senior insight: trade-offs

The key insight is that tasks separate "what is happening" from "which thread does it". That is what lets a server run thousands of operations on a few threads, and it is why blocking on a task throws that advantage away.

Need API
CPU work off the current thread Task.Run
Wait for several Task.WhenAll
First to finish or timeout Task.WhenAny or WaitAsync
Process results as they finish Task.WhenEach (.NET 9+)
Bridge a callback API TaskCompletionSource<T> with RunContinuationsAsynchronously

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says you compose tasks with await Why: Checks what await does mechanically
    How to answer

    async tells the compiler to rewrite the method into a state machine and lets you use await inside it; it does not create a thread. At await x, the code calls x.GetAwaiter() and checks IsCompleted. If it is already complete, execution continues synchronously. If not, the method registers the rest of itself as a continuation, captures the current SynchronizationContext (or non-default TaskScheduler), and returns an incomplete task to its caller, releasing the thread. When the awaited work finishes, the continuation resumes, on the captured context if there was one, otherwise on the ThreadPool. Exceptions and results flow through the returned task.

  2. When: Candidate mentions getting a result from a task Why: Probes await versus .Result
    How to 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>.

  3. When: Candidate contrasts tasks with threads Why: Checks when a dedicated thread is still right
    How to answer

    A System.Threading.Thread is a managed wrapper over one dedicated operating-system thread with its own stack and scheduling. Creating one is expensive (a stack reservation, a kernel object, scheduler work), so normal application code uses the ThreadPool through Task.Run, async/await or framework hosts. I only create a Thread directly for long-lived, dedicated work: a message pump, a blocking legacy loop, a thread that needs a specific apartment state, name, priority or stack size, or work that must never compete with pool threads.

🛠 Real-world problems: how you handle them

Sequential awaits tripled endpoint latency
Situation
A [dashboard endpoint] aggregated data from three internal services.
Signals
p95 latency of [900 ms] matched the sum of the three dependency latencies in traces.
Investigation
The distributed trace showed the three HTTP spans strictly one after the other, with no data dependency between them.
Resolution
Started all three tasks first and awaited `Task.WhenAll`, with a shared timeout using `WaitAsync`.
Prevention
Added a code-review checklist item for independent awaits and a latency budget alert.

How to tell it (STAR)

Our [dashboard] was slow and the trace showed three independent calls running back to back. I started all three tasks and awaited them together, which brought p95 from [900 ms] to about [400 ms], the slowest dependency. It needed no extra threads because the tasks were I/O promises.

↩ Usually asked after

More in Tasks

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me