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

📚 Interview question

What do the async and await keywords actually do? Explain what happens when execution reaches an await.

The async modifier, the awaitable pattern, the IsCompleted fast path, continuation registration, context capture and valid async return types.

Mid-Level Theory Intermediate async await awaitable tap

🎯 Key points

  • async enables await and makes the compiler build a state machine; it does not create threads
  • await checks IsCompleted and continues synchronously if already done
  • Otherwise it registers a continuation, captures context and returns an incomplete task
  • Continuations resume on the captured SynchronizationContext or the ThreadPool
  • Exceptions, even before the first await, are stored in the returned task
  • Avoid async void except for event handlers
  • Do not elide async when the method has using or try/catch

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

🗣 Full answer (senior)

How I would answer this in an interview

"async does two things: it lets you use await in the method, and it tells the compiler to rewrite the method into a state machine whose builder produces the returned task. It does not create a thread or run anything in the background. At await x, the generated code calls x.GetAwaiter() and checks IsCompleted. If the operation has already finished, it carries on synchronously: no yield, no thread switch. If not, it saves the method's state, registers 'the rest of the method' as a continuation, captures the current SynchronizationContext (unless ConfigureAwait(false)), and returns an incomplete task to the caller, so the thread is free to do other work. When the awaited operation completes, the continuation runs, on the captured context if there was one, otherwise on the ThreadPool. GetResult() then returns the value or rethrows the exception. Results and exceptions, even ones thrown before the first await, travel through the returned task."


1. What async does and doesn't do

Does Doesn't
Enables await in the body Create a thread
Generates a state machine + method builder Make code parallel
Stores exceptions in the returned task Throw exceptions at the call site (for Task-returning methods)
Lets the method return before its work is done Move CPU work off the current thread

2. await expr, step by step

Step What happens
1 Evaluate expr, call GetAwaiter() (any type following the awaitable pattern works)
2 if (awaiter.IsCompleted) → call GetResult() and continue synchronously
3 Otherwise save locals and the current position (state) into the state machine
4 First real yield only: box the state machine onto the heap
5 Register the continuation via UnsafeOnCompleted / OnCompleted; capture SynchronizationContext / non-default TaskScheduler if present
6 Return to the caller (the first time, the caller gets the incomplete task)
7 Operation completes → continuation scheduled → MoveNext() resumes at the saved position
8 GetResult() returns the value or rethrows the original exception

3. Where code resumes

Environment Default resume location
ASP.NET Core, console, worker services ThreadPool (no SynchronizationContext)
WPF / WinForms / MAUI UI thread The UI thread (captured context)
Legacy ASP.NET (System.Web) The request context (one thread at a time)
Code using ConfigureAwait(false) ThreadPool, regardless of context
Custom TaskScheduler current That scheduler

ExecutionContext (with AsyncLocal<T> values, culture, activity) flows across await regardless of ConfigureAwait.

4. Valid async return types

Return type Use
Task / Task<T> Default
ValueTask / ValueTask<T> Hot paths, often synchronous
IAsyncEnumerable<T> / IAsyncEnumerator<T> Async streams (yield return)
Custom task-like with [AsyncMethodBuilder] Advanced libraries
void Event handlers only: can't be awaited; exceptions crash the process

5. Rules worth knowing

  • await is allowed in catch and finally (C# 6+), not inside lock (use SemaphoreSlim).
  • async Main is allowed (C# 7.1+).
  • async without any await runs synchronously (warning CS1998).
  • Eliding async (returning the inner task directly) changes exception timing and breaks using/try semantics.

Production example

In our [document export endpoint], a developer marked a CPU-heavy PDF generation method async Task<byte[]> expecting it to "run in the background" and free the request thread. Request threads still stayed busy for [3–5 s] per export, and the build log had warning CS1998. I showed the team that the method contained no await on incomplete work, so it executed entirely on the request thread; async never moves work. We moved PDF generation behind a background queue with a status endpoint (the request now returns 202 Accepted), removed the misleading async, and turned CS1998 into a build error so the pattern couldn't return.

🔍 What the interviewer is checking

Checks a correct, mechanical understanding of async/await.

Green flags

  • Describes GetAwaiter, IsCompleted, continuation registration.
  • Says the thread is released and no thread waits.
  • Explains context capture and ConfigureAwait(false).
  • Knows exceptions are stored in the task and async void is dangerous.

Red flags

  • "async makes it run in parallel."
  • Cannot say where code resumes.

📚 Learn in depth

1. The awaitable pattern

The compiler only needs:

TAwaiter GetAwaiter();                 // instance or extension method
// TAwaiter must have:
bool IsCompleted { get; }
void OnCompleted(Action continuation);  // INotifyCompletion (and optionally ICriticalNotifyCompletion.UnsafeOnCompleted)
T GetResult();                          // or void

That's why you can await a Task, a ValueTask, Task.Yield(), ConfiguredTaskAwaitable, a TimeSpan (with an extension), or your own types.

2. Simplified lowering

async Task<int> GetAsync()
{
    var a = await LoadAsync();
    return a + 1;
}

becomes roughly:

void MoveNext()
{
    int result;
    try
    {
        TaskAwaiter<int> aw;
        if (state == 0) { aw = savedAwaiter; state = -1; goto Resume; }
        aw = LoadAsync().GetAwaiter();
        if (!aw.IsCompleted)
        {
            state = 0; savedAwaiter = aw;
            builder.AwaitUnsafeOnCompleted(ref aw, ref this);  // box (first time) + register
            return;                                             // yield to caller
        }
    Resume:
        int a = aw.GetResult();
        result = a + 1;
    }
    catch (Exception ex) { state = -2; builder.SetException(ex); return; }
    state = -2;
    builder.SetResult(result);
}

The next question goes deeper into the state machine itself.

3. Exceptions before the first await

Task t = ValidateAsync(null);    // no exception here
await t;                         // ArgumentNullException surfaces here

async Task ValidateAsync(string? s)
{
    ArgumentNullException.ThrowIfNull(s);   // stored in the task
    await Task.Yield();
}

If you need argument validation to throw eagerly, split into a non-async public method that validates and an async private implementation (or use a local function).

4. Task.Yield() and forcing asynchrony

await Task.Yield() always reports IsCompleted == false, so the method always yields and the rest runs as a scheduled continuation. Useful in tests, in UI code to let the message loop run, or to avoid deep synchronous recursion. It's not a way to "run in the background" in ASP.NET Core; it just requeues to the pool.

5. Context capture in detail

await SomethingAsync();                     // captures SynchronizationContext.Current (if any)
await SomethingAsync().ConfigureAwait(false); // doesn't

Capture happens at the await, only if the awaitable is incomplete. If SynchronizationContext.Current is null and TaskScheduler.Current is the default, there's nothing to capture, so the continuation can run directly on the completing thread or be queued to the pool.

6. Eliding async: when it's safe

Method body Safe to return the task directly?
=> _inner.GetAsync(id); (pure pass-through) Usually yes
Has using / try / finally No: disposal/finally runs before the task completes
Validates arguments that should throw from the task No: exceptions become synchronous
Uses AsyncLocal values set in the method No: subtle context differences

7. Version notes

Version Change
C# 5 async/await
C# 6 await in catch/finally
C# 7 Generalised async return types (ValueTask, [AsyncMethodBuilder])
C# 7.1 async Main
C# 8 Async streams (IAsyncEnumerable<T>, await foreach)
C# 13 ref locals and ref struct locals allowed in async methods (not across await)
.NET 8 ConfigureAwaitOptions

8. Try it yourself

  1. Run the code example: compare thread ids before/after the fast and slow awaits.
  2. Throw before the first await, call without awaiting, check IsFaulted, then await.
  3. Write a custom awaitable (GetAwaiter extension on TimeSpan) and await TimeSpan.FromSeconds(1).
  4. In a WPF app, print the thread id after await with and without ConfigureAwait(false).
  5. Elide async in a method with using var conn = ... and observe ObjectDisposedException.
  6. Decompile an async method with SharpLab and find the IsCompleted check.

💻 Code

Console.WriteLine($"Main start on thread {Environment.CurrentManagedThreadId}");

Task<int> pending = ComputeAsync(fast: false); // returns at the first incomplete await
Console.WriteLine("Caller continues while ComputeAsync waits");
Console.WriteLine($"Result {await pending}");

Console.WriteLine($"Fast path result {await ComputeAsync(fast: true)}");

Task faulty = ThrowsEarlyAsync();               // no exception here
Console.WriteLine($"Faulted before await? {faulty.IsFaulted}");
try { await faulty; }
catch (InvalidOperationException ex) { Console.WriteLine($"Observed: {ex.Message}"); }

static async Task<int> ComputeAsync(bool fast)
{
    Console.WriteLine($"  before await on {Environment.CurrentManagedThreadId}");
    if (fast)
        await Task.CompletedTask;   // IsCompleted is true: continues synchronously
    else
        await Task.Delay(100);      // incomplete: method yields
    Console.WriteLine($"  after await on {Environment.CurrentManagedThreadId}");
    return 42;
}

static async Task ThrowsEarlyAsync()
{
    throw new InvalidOperationException("stored in the task");
#pragma warning disable CS0162
    await Task.CompletedTask;
#pragma warning restore CS0162
}

⚠ Common mistakes

1. Believing async runs the method on a background thread

What happens: CPU-heavy code is marked async to "offload" it. Why it is wrong: code runs on the caller's thread until the first incomplete await. Do instead: Task.Run for CPU work you need off the current thread (in UI apps), a queue for long server work.

2. async with no await

What happens: CS1998 is ignored. Why it is wrong: it's synchronous with extra overhead. Do instead: remove async and return Task.FromResult, or add real async work.

3. Eliding async in methods with using or try

What happens: Task GetAsync() { using var c = Open(); return c.QueryAsync(); }. Why it is wrong: c is disposed before the task completes; the try doesn't catch async failures. Do instead: keep async/await unless the method is a trivial pass-through.

4. async void outside event handlers

What happens: public async void SaveAsync(). Why it is wrong: not awaitable; exceptions crash the process; tests can't wait for it. Do instead: return Task.

5. Expecting exceptions at the call site

What happens: try { var t = DoAsync(); } catch {...} without awaiting. Why it is wrong: exceptions are stored in the task and surface only when awaited. Do instead: await inside the try.

6. Assuming code after await is on the same thread

What happens: thread-affine code (thread-static state, Monitor held across await) breaks. Why it is wrong: in server code the continuation can run on any pool thread. Do instead: don't rely on thread identity across awaits; use SemaphoreSlim for async locking.

7. Awaiting inside a lock

What happens: trying to await within lock (x) { ... }. Why it is wrong: the compiler forbids it because the lock is thread-affine. Do instead: await _semaphore.WaitAsync() with try/finally Release().

8. Sequential awaits for independent work

What happens: await A(); await B(); where B doesn't need A. Why it is wrong: latency adds up. Do instead: start both, then await Task.WhenAll(a, b).

9. Using Task.Yield() to make work "background"

What happens: await Task.Yield(); at the top of a CPU-heavy method in ASP.NET Core. Why it is wrong: the work still runs on a pool thread, just a different one, after a requeue. Do instead: design CPU-heavy work explicitly (queue, Task.Run in UI apps).

10. Thinking ConfigureAwait(false) stops AsyncLocal flow

What happens: it's used to "drop" logging scopes or activity. Why it is wrong: ExecutionContext always flows; only SynchronizationContext capture changes. Do instead: use ExecutionContext.SuppressFlow() when you truly need to stop flow.

🏛 Senior insight: trade-offs

I explain await as "pause this method, give the thread back, resume later". The senior points are the fast path (no yield if already complete), where continuations resume, and that exceptions travel in the task. Those three explain most async bugs I see in reviews.

Question Short answer
Does async create a thread? No
When does a method yield? At the first await on an incomplete awaitable
Where does it resume? Captured context, else ThreadPool
Where do exceptions go? Into the returned task
When is async void OK? Event handlers only

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says the compiler builds a state machine Why: Checks compiler-generated code
    How to answer

    The compiler turns an async method into a small stub plus a generated type implementing IAsyncStateMachine. The type holds a state integer, a method builder (such as AsyncTaskMethodBuilder<T>), awaiter fields and any locals that live across an await. The stub initialises it and calls builder.Start, which runs MoveNext synchronously. MoveNext is a switch on state: it runs until an incomplete await, stores the awaiter, sets state, and asks the builder to call MoveNext again on completion. In Release builds the machine is a struct that is only boxed to the heap the first time the method actually suspends, so synchronously completing async methods are cheap.

  2. When: Candidate says no thread waits Why: Checks the Task mental model
    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.

  3. When: Candidate talks about compiler lowering Why: Connects to C# to IL lowering
    How to answer

    Roslyn parses C# into syntax trees, binds names and types into a semantic model, then lowers high-level constructs into simpler ones before emitting IL. foreach over an array becomes an index loop; over `List<T>` it uses the struct enumerator in a try/finally; using becomes try/finally with Dispose; lock becomes Monitor.Enter(obj, ref lockTaken) / Monitor.Exit (or Lock.EnterScope() for System.Threading.Lock); a + b + c on strings becomes one string.Concat; a switch on strings may become a length or hash-based jump. async methods and iterators become state machines. Roslyn does little optimisation; the JIT does the heavy work.

🛠 Real-world problems: how you handle them

async keyword expected to offload CPU work
Situation
A [document export endpoint] marked a CPU-heavy PDF generation method `async` expecting it to free the request thread.
Signals
Request threads stayed busy for [3-5 s]; compiler warning CS1998 was in the build log.
Investigation
The method had no awaits on incomplete work, so it ran synchronously. A CPU profile showed the work on the request thread.
Resolution
Moved PDF generation to a background queue with a status endpoint, removing the misleading `async`.
Prevention
Turned CS1998 into an error and added an async guidelines page.

How to tell it (STAR)

Our [export endpoint] held request threads for seconds even though the method was `async`. I showed the team that `async` does not move work, and the CS1998 warning proved there was no real await. I moved the CPU work to a background queue and made CS1998 a build error.

↩ Usually asked after

More in Tasks

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me