C# Async and Await – How They Work Internally
1. What are async and await in C#?
async and await are C# keywords used to implement asynchronous programming without blocking the calling thread while waiting for I/O operations.
They allow developers to write asynchronous code in a readable, sequential style.
-
async– Marks a method or lambda as asynchronous and enables the use ofawaitinside it. -
await– Waits asynchronously for an operation to complete. If the operation is incomplete, the method is suspended without blocking the calling thread. -
Task– Represents an operation that may complete in the future. -
Task<T>– Represents an operation that returns a result when completed.
Simple example
public async Task<string> GetCustomerAsync()
{
await Task.Delay(3000);
return "Customer Retrieved";
}
Explanation:
-
asyncallows the method to useawait. -
Task.Delay(3000)represents a non-blocking delay of 3 seconds. -
awaitsuspends the method if the task has not completed. -
After 3 seconds, the method resumes.
-
The method completes its
Task<string>with the returned string.
Important: async does not automatically create a new thread, and await does not always suspend execution. If the awaited task is already complete, execution continues synchronously.
2. Complete example program
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Console.WriteLine("1. Application Started");
Task<string> task = GetCustomerAsync();
Console.WriteLine("3. Main method continues");
string result = await task;
Console.WriteLine($"5. Result: {result}");
Console.WriteLine("6. Application Completed");
}
static async Task<string> GetCustomerAsync()
{
Console.WriteLine("2. Fetching Customer...");
await Task.Delay(3000);
Console.WriteLine("4. Customer data received");
return "Syed Mohamed";
}
}
Output
1. Application Started
2. Fetching Customer...
3. Main method continues
(Approximately 3 seconds later)
4. Customer data received
5. Result: Syed Mohamed
6. Application Completed
Program explanation
Step 1: Main starts
Console.WriteLine("1. Application Started");
Prints the first message.
Step 2: Call the asynchronous method
Task<string> task = GetCustomerAsync();
GetCustomerAsync() begins executing immediately.
It prints "Fetching Customer..." and reaches:
await Task.Delay(3000);
Because the delay is incomplete, GetCustomerAsync() suspends and returns an incomplete Task<string> to the caller.
Step 3: Main continues
Console.WriteLine("3. Main method continues");
The main method continues while the delay is pending.
Step 4: Main awaits
string result = await task;
If the task is still incomplete, Main() suspends asynchronously.
Step 5: The delay completes
After approximately 3 seconds, the continuation of GetCustomerAsync() executes and returns "Syed Mohamed".
Step 6: Main resumes
The await task expression produces the string result, and the remaining statements execute.
3. How do async and await work internally?
This is the most important part for senior .NET interviews.
When the C# compiler encounters an async method containing await, it typically transforms the method into a compiler-generated state machine.
The state machine tracks:
-
Where execution was suspended.
-
Which local variables must survive across suspension.
-
Which task is being awaited.
-
Where execution should resume.
-
Whether the operation completed successfully or failed.
Internal execution flow
Caller
|
v
Call GetCustomerAsync()
|
v
Compiler-generated State Machine
|
v
Execute method synchronously
until first incomplete await
|
v
Start Task.Delay(3000)
|
v
Is Task completed?
|
+---- YES -------------------+
| |
| v
| Continue execution
|
+---- NO
|
v
Save execution state
|
v
Register continuation
|
v
Return incomplete Task
|
v
Calling thread is free
|
v
Task.Delay completes
|
v
Schedule continuation
|
v
Resume state machine
|
v
Execute remaining code
|
v
Complete Task<string>
|
v
Caller receives result
Important clarification
The compiler does not literally pause a thread and later restart the same thread.
Instead, it generates logic that remembers the method's execution state and arranges for the remaining code to run after the awaited operation completes.
4. What is an async state machine?
An async state machine is a compiler-generated structure that manages the execution and continuation of asynchronous methods.
Consider:
public async Task<string> GetDataAsync()
{
Console.WriteLine("Step 1");
await Task.Delay(2000);
Console.WriteLine("Step 2");
return "Completed";
}
Conceptually, the compiler divides this method into execution states.
State -1: Initial execution
|
| Print "Step 1"
|
| Start Task.Delay(2000)
|
v
State 0: Awaiting completion
|
| Save required variables
|
| Register continuation
|
| Suspend method
|
v
State -1: Resumed execution
|
| Print "Step 2"
|
| Return "Completed"
|
v
State -2: Completed
These state numbers are illustrative of the usual compiler-generated implementation. They are not application-level states that developers normally manage.
Simplified compiler-generated structure
The following is conceptual code, not the exact output of the C# compiler.
struct GetDataStateMachine : IAsyncStateMachine
{
public int State;
public AsyncTaskMethodBuilder<string> Builder;
public TaskAwaiter Awaiter;
public void MoveNext()
{
// Execute method according to State.
// If awaited task is incomplete:
// Save State
// Register continuation
//
// When continuation runs:
// Resume execution
// Complete Builder with result/exception
}
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
}
}
Important internal components
|
Component |
Responsibility |
|---|---|
|
|
Defines the asynchronous state machine contract |
|
|
Executes or resumes the method |
|
|
Manages completion of the asynchronous method |
|
|
Supports checking and awaiting task completion |
|
Continuation |
Code that executes after the awaited operation completes |
|
State field |
Tracks the suspension point |
What does MoveNext() do?
MoveNext() is the central method of the generated state machine.
It executes the asynchronous method until one of the following happens:
-
An incomplete
awaitis encountered. -
The method finishes successfully.
-
An exception occurs.
When the awaited operation completes, the continuation causes MoveNext() to execute again and resume from the saved state.
Key Points
-
The compiler generates the state machine automatically.
-
Developers do not manually create it.
-
Local variables needed after an
awaitare preserved. -
The state machine enables asynchronous execution without keeping a thread blocked.
-
State machines can introduce allocations and overhead, although modern .NET optimizes many common cases.
5. What happens to the thread when await is executed?
Consider:
public async Task<Customer?> GetCustomerAsync(int id)
{
return await _context.Customers
.FirstOrDefaultAsync(c => c.Id == id);
}
Thread execution
ASP.NET Core Thread Pool
Thread 1
|
|-- Controller receives request
|
|-- Calls GetCustomerAsync()
|
|-- Starts database operation
|
|-- Encounters incomplete await
|
+-- Returns to Thread Pool
|
| Can process other requests
|
v
SQL Server executes query
|
| Database operation completes
|
v
Thread Pool schedules continuation
|
v
Thread 2 (or another available thread)
|
|-- Resumes GetCustomerAsync()
|
|-- Processes database result
|
|-- Completes Task<Customer?>
|
v
Controller resumes and returns response
Important observation
Thread 1 and Thread 2 may be different threads.
In ASP.NET Core, continuations normally execute using available Thread Pool threads because ASP.NET Core does not install a request-specific SynchronizationContext.
await does not guarantee continuation on the same thread.
In UI applications such as WPF and Windows Forms, await normally captures the UI synchronization context and resumes on the UI thread, unless context capture is disabled.
6. What happens if the awaited task is already completed?
This is an important interview question.
Consider:
public async Task<int> GetNumberAsync()
{
await Task.CompletedTask;
return 100;
}
Task.CompletedTask is already completed.
Therefore:
-
The state machine checks the awaiter's completion status.
-
The task is already complete.
-
Execution continues immediately.
-
The method returns a successfully completed task.
No asynchronous suspension is required.
await Task.CompletedTask
|
v
IsCompleted = true
|
v
Continue synchronously
|
v
Return 100
This illustrates that await does not always release the thread. It only suspends the method when the awaited operation is incomplete.
7. How are exceptions handled internally?
Exceptions in async Task and async Task<T> methods are stored in the returned task.
Consider:
public async Task<int> GetCustomerAsync()
{
await Task.Delay(1000);
throw new InvalidOperationException(
"Customer retrieval failed");
}
Caller:
try
{
int result = await GetCustomerAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
Internal flow
GetCustomerAsync()
|
v
await Task.Delay(1000)
|
v
Continuation resumes
|
v
Exception thrown
|
v
State machine catches exception
|
v
Task marked as Faulted
|
v
Caller awaits Task
|
v
Exception rethrown at await
|
v
Caller catch block executes
Key Points
-
Exceptions from
async Taskmethods are captured by the returned task. -
awaitpropagates the exception to the caller. -
Use
try-catcharoundawaitwhen the caller must handle the error. -
Avoid
async voidexcept for event handlers because exceptions cannot be observed through a returned task.
8. What is the difference between async/await and Task.Run()?
These concepts are frequently confused.
|
|
|
|---|---|
|
Supports asynchronous execution |
Schedules work on Thread Pool |
|
Ideal for I/O-bound operations |
Useful for CPU-bound work |
|
Does not automatically create a new thread |
Uses Thread Pool threads |
|
Can free a thread during I/O waits |
Occupies a worker thread while CPU work runs |
|
Example: EF Core |
Example: expensive calculation |
Asynchronous I/O
public async Task<string> GetApiDataAsync()
{
return await _httpClient.GetStringAsync(
"https://example.com/api/data");
}
This uses asynchronous network I/O.
CPU-bound operation
public async Task<long> CalculateAsync()
{
return await Task.Run(() =>
{
long total = 0;
for (int i = 0; i < 1000000; i++)
{
total += i;
}
return total;
});
}
Task.Run() schedules the calculation on a Thread Pool thread.
In ASP.NET Core, avoid wrapping database or HTTP asynchronous operations in Task.Run(). It generally does not improve scalability.
9. Real-world ASP.NET Core Web API example
Consider the following application flow:
HTTP Request
|
v
CustomerController
|
| await
v
CustomerService
|
| await
v
CustomerRepository
|
| await
v
Entity Framework Core
|
v
SQL Server
Repository
public async Task<Customer?> GetByIdAsync(
int id,
CancellationToken cancellationToken)
{
return await _context.Customers
.FirstOrDefaultAsync(
c => c.Id == id,
cancellationToken);
}
Service
public async Task<Customer?> GetCustomerAsync(
int id,
CancellationToken cancellationToken)
{
return await _repository.GetByIdAsync(
id,
cancellationToken);
}
Controller
[HttpGet("{id}")]
public async Task<IActionResult> GetCustomer(
int id,
CancellationToken cancellationToken)
{
var customer = await _service.GetCustomerAsync(
id,
cancellationToken);
if (customer == null)
return NotFound();
return Ok(customer);
}
Why use async at every layer?
The Controller, Service, and Repository can all suspend without blocking a thread while waiting for the database result.
This is called async all the way.
It helps avoid synchronous blocking and improves request throughput under I/O-heavy workloads.
10. Important key points for interviews
-
asyncandawaitare language features, not separate threading mechanisms. -
The C# compiler typically transforms an async method into a state machine.
-
IAsyncStateMachineandMoveNext()are important internal implementation concepts. -
awaitchecks whether the awaited operation has completed. -
If completed, execution continues synchronously.
-
If incomplete, the state machine saves its state and registers a continuation.
-
The current thread is not blocked while the asynchronous operation is pending.
-
A continuation may run on a different thread depending on the synchronization context.
-
Exceptions are captured in the returned task and propagated when awaited.
-
async/awaitis ideal for I/O-bound operations, whileTask.Run()is typically used to offload CPU-bound operations. -
In ASP.NET Core, use asynchronous APIs throughout the request pipeline where practical.
-
Avoid blocking async code with
.Result,.Wait(), or.GetAwaiter().GetResult().
Interview-ready answer
asyncandawaitare C# keywords used to implement asynchronous programming. Theasynckeyword enables the use ofawait, whileawaitallows a method to wait for an asynchronous operation without blocking the calling thread.Internally, the C# compiler transforms an async method into a state machine that implements
IAsyncStateMachine. When an incomplete task is awaited, the state machine saves its execution state, registers a continuation, and returns control to the caller. Once the operation completes, the continuation resumes execution throughMoveNext().In ASP.NET Core Web API, we use
asyncandawaitfor database operations and external API calls to improve thread utilization, responsiveness, and application scalability.