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

📚 Interview question

What are task continuations? Compare ContinueWith with await and explain the options that control how continuations run.

ContinueWith, TaskContinuationOptions, synchronous versus asynchronous continuations, RunContinuationsAsynchronously, Unwrap and why await is usually better.

Senior Theory Advanced async continuations continuewith task

🎯 Key points

  • A continuation runs when a task completes; await creates them for you
  • ContinueWith runs on any outcome unless options restrict it
  • ContinueWith uses TaskScheduler.Current by default; pass Default
  • Async lambdas with ContinueWith or StartNew need Unwrap
  • Continuations can run inline on the completing thread
  • Use RunContinuationsAsynchronously on TaskCompletionSource and complete outside locks
  • Prefer await for readability and correct exception flow

⏱ 30-second answer

A continuation is code registered to run when a task completes. await creates continuations for you, with context capture, unwrapped results and normal exception flow. ContinueWith is the lower-level API: it runs regardless of outcome unless you pass options such as OnlyOnRanToCompletion or OnlyOnFaulted, uses TaskScheduler.Current by default, and returns Task<Task> for async lambdas (needs Unwrap). Continuations may run synchronously on the completing thread (ExecuteSynchronously, or by default for await when safe), so producers should use RunContinuationsAsynchronously to avoid inline re-entrancy.

🗣 Full answer (senior)

How I would answer this in an interview

"A continuation is code registered to run when a task completes. await is the modern way to create continuations: it captures the context, gives you the result directly, and makes exceptions flow like normal synchronous code. ContinueWith is the lower-level TPL API. It runs on every outcome unless you restrict it with TaskContinuationOptions; it uses TaskScheduler.Current by default; you have to inspect antecedent.Exception yourself; and with an async lambda it returns a Task<Task> that needs Unwrap(). The subtle part is where continuations run: when a task completes, its continuations may run inline on the completing thread, which is fast but dangerous if that thread holds a lock or is an I/O or timer thread. That's why producers of tasks, especially TaskCompletionSource, should use RunContinuationsAsynchronously and complete outside locks."


1. await vs ContinueWith

await ContinueWith
Readability Sequential code, try/catch/finally Callback chains
Exceptions Rethrown at the await (original type) Inspect antecedent.Exception (AggregateException); reading .Result rethrows wrapped
Runs on fault/cancel? Throws into your code instead Runs by default unless options restrict it
Scheduler Captured SynchronizationContext → non-default Current → pool TaskScheduler.Current unless specified
Async lambda Natural Returns Task<Task>; needs Unwrap()
Unmet OnlyOn... condition N/A Continuation task becomes Canceled
Allocation State machine box on suspension Continuation task + delegate (+ closure)

2. TaskContinuationOptions worth knowing

Option Effect
OnlyOnRanToCompletion / OnlyOnFaulted / OnlyOnCanceled Run only for that outcome (otherwise the continuation is canceled)
NotOnRanToCompletion / NotOnFaulted / NotOnCanceled Exclusions
ExecuteSynchronously Prefer running inline on the completing thread (only for tiny continuations)
RunContinuationsAsynchronously Force queueing, never inline
LazyCancellation Don't complete as canceled until the antecedent completes
DenyChildAttach, HideScheduler Same as for StartNew
LongRunning Dedicated thread for a blocking continuation (rare)

3. Inline continuations: why they matter

Hazard Cause Fix
Deadlock Completer calls SetResult inside a lock; continuation runs inline and needs the same lock Complete outside locks; RunContinuationsAsynchronously
Re-entrancy Continuation mutates state the completer is in the middle of updating Same
Stalled I/O/timer thread Heavy continuation runs inline on a completion thread Let continuations queue to the pool
Stack dives Long chains of synchronous completions Runtime guards stack depth; avoid ExecuteSynchronously for heavy work

await continuations run inline only when it's considered safe (no captured context to honour, not a forced-async task), which is one reason they perform well, and why producers must think about it.

4. When I still use ContinueWith

  • Attaching a cheap fault logger to a task I intentionally don't await.
  • Hot infrastructure code where a state machine isn't wanted.
  • Always with explicit options and scheduler:
work.ContinueWith(static t => Log.Error(t.Exception!, "Background work failed"),
    CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
    TaskScheduler.Default);

Production example

Our [custom database connection pool] completed waiting requests with TaskCompletionSource.SetResult(connection) while holding its pool lock. Under load, requests hung forever with idle CPU and a flat thread count. A dump showed a thread inside SetResult whose inline continuation, the awaiting request, had finished quickly and was trying to return a connection, which needed the same pool lock that this thread already held through the SetResult call. I changed the waiters to new TaskCompletionSource<Connection>(TaskCreationOptions.RunContinuationsAsynchronously) and moved SetResult after the lock was released. The hang disappeared; we added a stress test with [500] concurrent waiters and a review rule that every TaskCompletionSource must be created with that option.

🔍 What the interviewer is checking

Checks deep TPL understanding and awareness of continuation hazards.

Green flags

  • Compares await and ContinueWith on exceptions, scheduler and unwrapping.
  • Knows TaskContinuationOptions values.
  • Explains inline continuations and RunContinuationsAsynchronously.

Red flags

  • Prefers ContinueWith chains for normal code.
  • Unaware continuations may run inline.

📚 Learn in depth

1. How continuations are stored and run

Task.m_continuationObject:
   null                      -> no continuations
   single object             -> one continuation (common case for await)
   List<object>              -> several

On completion (FinishContinuations):
   for each continuation:
       if it may run inline (and stack depth OK) -> run now on this thread
       else                                       -> queue to its scheduler / sync context

The completing thread decides. If the task was created with RunContinuationsAsynchronously, nothing runs inline.

2. The "unmet condition" gotcha

Task failing = Task.Run(() => throw new InvalidOperationException());
Task onSuccess = failing.ContinueWith(_ => Console.WriteLine("ok"),
    CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);

await onSuccess;   // throws TaskCanceledException, not InvalidOperationException!

The continuation didn't run, so its task is canceled. The original failure is only visible on failing. Code that awaits the continuation chain silently loses the real error.

3. Unwrap

Task<Task> outer = first.ContinueWith(async t => { await Task.Delay(100); Save(t.Result); }, TaskScheduler.Default);
await outer;            // completes at the inner lambda's first await!
await outer.Unwrap();   // completes when the inner async work completes

Task.Run performs this unwrapping automatically for async lambdas; StartNew and ContinueWith don't.

4. Rewriting a ContinueWith chain with await

// Old
LoadAsync().ContinueWith(t =>
{
    if (t.IsFaulted) { Log(t.Exception); return; }
    Render(t.Result);
}, TaskScheduler.FromCurrentSynchronizationContext());

// New
try { Render(await LoadAsync()); }   // resumes on UI context by default in UI apps
catch (Exception ex) { Log(ex); }

Shorter, correct exception flow, correct scheduler by default.

5. Producer-side rules

If you complete tasks (TCS, custom awaitables, IValueTaskSource) Why
Use RunContinuationsAsynchronously (or ManualResetValueTaskSourceCore.RunContinuationsAsynchronously = true) Consumers' code won't run inside your critical section
Never complete while holding a lock Even with async continuations, keeps lock hold times short
Complete on every path (success, error, cancel) Prevent hung awaiters

6. Version notes

Version Change
.NET Framework 4.0 ContinueWith, TaskContinuationOptions
.NET 4.5 await; LazyCancellation, DenyChildAttach, HideScheduler
.NET Framework 4.6 RunContinuationsAsynchronously
.NET Core 2.1+ Lower-allocation await continuations (state machine box registered directly)
.NET 10 Same semantics

7. Try it yourself

  1. Run the code example and compare the thread ids of the inline and queued continuations.
  2. Use OnlyOnRanToCompletion on a faulting antecedent and await the continuation; observe TaskCanceledException.
  3. Reproduce the pool deadlock: complete a TCS inside lock (gate) while the continuation also does lock (gate) from another method on the same thread path... then fix it.
  4. Rewrite a three-step ContinueWith chain as async/await and compare exception output.
  5. Benchmark ContinueWith vs await for a completed task with [MemoryDiagnoser].

💻 Code

Task<int> work = Task.Run(() => 21 * 2);

// ContinueWith done properly: explicit options and scheduler.
Task logged = work.ContinueWith(
    t => Console.WriteLine($"Completed with {t.Result}"),
    CancellationToken.None,
    TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously,
    TaskScheduler.Default);

// Async lambda with ContinueWith needs Unwrap.
Task chained = work.ContinueWith(async t =>
{
    await Task.Delay(50);
    Console.WriteLine($"Async continuation saw {t.Result}");
}, TaskScheduler.Default).Unwrap();

await Task.WhenAll(logged, chained);

// Inline completion demo.
var inline = new TaskCompletionSource();
var queued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task a = Report("inline", inline.Task);
Task b = Report("queued", queued.Task);

var completer = new Thread(() =>
{
    Console.WriteLine($"Completer thread {Environment.CurrentManagedThreadId}");
    inline.SetResult();  // continuation may run right here
    queued.SetResult();  // continuation is queued to the pool
});
completer.Start();
completer.Join();
await Task.WhenAll(a, b);

static async Task Report(string name, Task t)
{
    await t;
    Console.WriteLine($"{name} continuation on thread {Environment.CurrentManagedThreadId}");
}

⚠ Common mistakes

1. ContinueWith without options, reading t.Result

What happens: the continuation runs after a fault and t.Result throws AggregateException. Why it is wrong: continuations run on every outcome by default. Do instead: await, or OnlyOnRanToCompletion plus a separate fault handler.

2. Awaiting a continuation that was skipped

What happens: await task.ContinueWith(..., OnlyOnRanToCompletion) after a failure. Why it is wrong: the continuation is canceled; you get TaskCanceledException and lose the real error. Do instead: await the original task, or use await + try/catch.

3. Async lambdas with ContinueWith without Unwrap

What happens: code awaits a Task<Task>. Why it is wrong: it completes at the inner lambda's first await. Do instead: .Unwrap() or rewrite with await.

4. Completing a TaskCompletionSource inside a lock

What happens: lock (_gate) { tcs.SetResult(x); }. Why it is wrong: continuations can run inline inside the lock: deadlocks and re-entrancy. Do instead: RunContinuationsAsynchronously and complete after releasing the lock.

5. Relying on the default scheduler

What happens: ContinueWith(action) in library code. Why it is wrong: uses TaskScheduler.Current, possibly a UI or custom scheduler. Do instead: pass TaskScheduler.Default (CA2008).

6. ExecuteSynchronously on heavy continuations

What happens: a long-running continuation is marked synchronous "for speed". Why it is wrong: it runs on the completing thread, possibly an I/O or timer thread, blocking others. Do instead: use it only for tiny, non-blocking continuations.

7. Building long ContinueWith chains in application code

What happens: nested callbacks with manual error checks. Why it is wrong: hard to read, easy to drop exceptions, wrong schedulers. Do instead: async/await.

8. Capturing large state in continuation closures

What happens: lambdas capture request objects in long-lived continuations. Why it is wrong: keeps object graphs alive and allocates closures per call. Do instead: use the state overloads with static lambdas, or await.

9. Assuming await continuations always run on a new thread

What happens: code relies on the continuation not running inline. Why it is wrong: await continuations can run synchronously on the completing thread when safe. Do instead: make producers async-completing; don't depend on thread identity.

10. Not completing on every path

What happens: a TCS is completed only on success. Why it is wrong: awaiters hang forever on errors or cancellation. Do instead: complete with TrySetException / TrySetCanceled in all failure paths.

🏛 Senior insight: trade-offs

In modern code, await is the continuation API. I know ContinueWith so I can review older code and write tight infrastructure, and I care most about inline continuations, because they cause deadlocks that look impossible in a stack trace.

Use Prefer
Application flow await
Cheap logging on a detached task ContinueWith(..., OnlyOnFaulted, Default)
Completing promises TaskCompletionSource with async continuations
Composing async lambdas await, or Unwrap() if forced

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions TaskScheduler.Current Why: Checks scheduler semantics
    How to answer

    A TaskScheduler decides where and when queued delegate tasks run. TaskScheduler.Default queues to the ThreadPool. TaskScheduler.Current is the scheduler of the task currently executing, or Default if none. Task.Run always uses Default, but Task.Factory.StartNew and ContinueWith use Current unless you pass one, so code called from inside a task on a custom or UI scheduler can unexpectedly run there. Pass TaskScheduler.Default explicitly. Custom schedulers (such as ConcurrentExclusiveSchedulerPair) can limit concurrency or serialise work.

  2. When: Candidate inspects antecedent exceptions 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 mentions TaskCompletionSource Why: Checks promise task creation
    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>.

🛠 Real-world problems: how you handle them

Deadlock from an inline continuation inside a pool lock
Situation
A [custom database connection pool] completed waiting requests with `TaskCompletionSource.SetResult` inside its lock.
Signals
Under load, requests hung forever; CPU was idle; thread count stayed flat.
Investigation
A dump showed a thread inside `SetResult`, whose inline continuation was trying to take the same pool lock to return a connection.
Resolution
Created waiters with `RunContinuationsAsynchronously` and moved `SetResult` outside the lock.
Prevention
Added a stress test with [500] concurrent waiters and a review rule for TCS creation.

How to tell it (STAR)

Our [connection pool] hung under load with idle CPU. The dump showed the continuation running inline inside `SetResult` and blocking on the pool lock that the same thread held. I made the waiters complete asynchronously and released the lock before signalling. The hang disappeared and we added a stress test for it.

↩ Usually asked after

More in Tasks

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me