async marks a method so that await can be used inside it. await suspends the
method without blocking the thread — control returns to the caller and resumes when
the awaited Task completes. The compiler transforms the method into a state
machine: a struct that tracks which await the method is currently paused at and
resumes from the correct point when the Task signals completion.
public async Task<string> FetchAsync(string url)
{
// Thread is released here; resumes on completion of GetAsync
HttpResponseMessage response = await httpClient.GetAsync(url);
// Thread is released here; resumes when ReadAsStringAsync finishes
string body = await response.Content.ReadAsStringAsync();
return body; // Task<string> is completed with this value
}
The generated state machine roughly:
- Calls
GetAsync, registers a continuation on the returned Task. - Returns to the caller immediately (the method returns an incomplete
Task<string>). - When
GetAsyncfinishes, the continuation runs — resuming after the firstawait. - Repeats for
ReadAsStringAsync, then completes the outer Task with the body.
Rule of thumb: async / await is syntactic sugar over Task continuations. It
lets you write asynchronous code that reads like synchronous code without blocking threads.
A Thread is an OS-level execution resource. Creating one allocates a stack (~1 MB) and involves a kernel transition — expensive. A Task is a higher-level abstraction representing a unit of work that may or may not require a dedicated thread.
// Thread — dedicated OS thread, always uses a thread
var thread = new Thread(() => Console.WriteLine("thread"));
thread.Start();
// Task — scheduled on the ThreadPool; may reuse threads
var task = Task.Run(() => Console.WriteLine("task on pool thread"));
// async Task — may use zero threads while awaiting I/O
var ioTask = FetchAsync("https://example.com");
// While awaiting the network, NO thread is consumed
Key distinctions:
Threadis 1-to-1 with an OS thread.Taskis a promise of a result.- I/O-bound
asyncTasks consume no thread while waiting — they rely on OS completion ports / epoll callbacks. - CPU-bound work still needs a thread;
Task.Runputs it on the ThreadPool. - ThreadPool threads are reused; creating raw Threads for short-lived work wastes resources.
Rule of thumb: Use Task / async-await for I/O-bound work. Use Task.Run
to offload CPU-bound work to the ThreadPool. Only use raw Thread when you need precise
control over thread identity, apartment state, or priority.
By default, after an await, C# tries to resume on the original
SynchronizationContext (e.g., the UI thread in WPF/WinForms, or the ASP.NET
Classic request context). ConfigureAwait(false) tells the awaiter not to capture
the context — the continuation can run on any ThreadPool thread.
// Library code — does NOT need to return to a specific context:
public async Task<Data> GetDataAsync()
{
var raw = await httpClient.GetStringAsync(url).ConfigureAwait(false);
// Continuation runs on a pool thread — no context marshalling overhead
return Parse(raw);
}
// Application code — DOES need UI context (e.g., updating a Label):
private async void Button_Click(object sender, EventArgs e)
{
var data = await GetDataAsync(); // resume on UI thread — default behaviour
label.Text = data.Name; // safe: we're on the UI thread
}
Benefits of ConfigureAwait(false):
- Performance: avoids context-switch overhead.
- Deadlock prevention: a common deadlock occurs when library code awaits without
ConfigureAwait(false)and the caller blocks with.Resulton a single-threaded context — the continuation never runs because the context is blocked.
In ASP.NET Core there is no SynchronizationContext, so ConfigureAwait(false)
has no functional effect — but it is still best practice in library code for portability.
Rule of thumb: Use ConfigureAwait(false) in all library / reusable code. Omit it
in application code that needs to update UI after the await.
The classic deadlock: a single-threaded SynchronizationContext (UI thread, or
ASP.NET Classic) blocks waiting for a Task to finish (.Result / .Wait()), while
the Task's continuation needs that same context to resume — so it can never run.
// Deadlock scenario (WinForms, WPF, or old ASP.NET):
public string GetData()
{
// .Result BLOCKS the current (UI) thread:
return FetchAsync().Result; // <- deadlock!
}
public async Task<string> FetchAsync()
{
await Task.Delay(100); // default ConfigureAwait(true)
// Continuation tries to resume on the UI thread — but it's BLOCKED by .Result!
return "done";
}
// Fix option 1: go async all the way:
public async Task<string> GetDataAsync() => await FetchAsync();
// Fix option 2: break context capture in the library:
public async Task<string> FetchAsync()
{
await Task.Delay(100).ConfigureAwait(false); // no context needed
return "done";
}
ASP.NET Core has no SynchronizationContext, so .Result does not deadlock there —
but it still blocks a ThreadPool thread unnecessarily, harming scalability.
Rule of thumb: Never call .Result, .Wait(), or GetAwaiter().GetResult() on
a Task in code that might run on a single-threaded context. Go async all the way.
Task.WhenAll returns a Task that completes when all provided Tasks finish.
Task.WhenAny returns a Task that completes when any one of the provided Tasks
finishes (the rest keep running).
var t1 = FetchUserAsync(1); // 200 ms
var t2 = FetchUserAsync(2); // 150 ms
var t3 = FetchUserAsync(3); // 300 ms
// WhenAll — waits for ALL; total time ≈ 300 ms (longest)
User[] users = await Task.WhenAll(t1, t2, t3);
// If any task faults, WhenAll re-throws the FIRST exception;
// use the Task objects directly to inspect all exceptions:
var tasks = new[] { t1, t2, t3 };
await Task.WhenAll(tasks);
// foreach (var t in tasks) if (t.IsFaulted) { ... t.Exception ... }
// WhenAny — completes when the FIRST task finishes; total time ≈ 150 ms
Task<User> first = await Task.WhenAny(t1, t2, t3);
Console.WriteLine($"First result: {first.Result.Name}");
// Timeout pattern with WhenAny:
var work = DoExpensiveWorkAsync();
var timeout = Task.Delay(TimeSpan.FromSeconds(5));
if (await Task.WhenAny(work, timeout) == timeout)
throw new TimeoutException("Operation exceeded 5 s");
var result = await work;
Rule of thumb: Use WhenAll to run independent tasks in parallel and collect
all results. Use WhenAny for timeouts, racing alternatives, or processing results
as they arrive.
async void is a method signature that returns void instead of Task. Callers
cannot await it, cannot observe its completion, and — critically — exceptions
escape to the SynchronizationContext, not to the caller, crashing the process.
// async void — caller cannot catch the exception
async void LoadDataBad()
{
await Task.Delay(100);
throw new InvalidOperationException("boom"); // crashes the app!
}
// Caller:
try
{
LoadDataBad(); // fire and forget — can't await, exception is NOT caught here
}
catch (Exception) { } // never catches the async exception!
// Correct: return Task and await it
async Task LoadDataGood()
{
await Task.Delay(100);
throw new InvalidOperationException("boom");
}
try
{
await LoadDataGood(); // exception propagates correctly here
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message); // "boom" — caught!
}
The only legitimate use of async void is event handlers, where the event
contract requires void:
private async void Button_Click(object sender, EventArgs e)
{
// Wrap in try/catch to handle exceptions manually
try { await DoWorkAsync(); }
catch (Exception ex) { ShowError(ex); }
}
Rule of thumb: Never write async void outside of event handlers. Always return
Task or Task<T> so callers can await and observe exceptions.
ValueTask<T> is a struct that wraps either a synchronously available result
or a reference to a Task<T>. Unlike Task<T> (a class), it avoids a heap
allocation when the result is available synchronously — common in hot code paths
like caches or buffer reads.
// Every call allocates a Task<T> object on the heap:
public async Task<int> GetCountTaskAsync()
{
if (_cache.TryGetValue("count", out int v)) return v; // still allocates Task!
return await FetchCountFromDbAsync();
}
// ValueTask: zero allocation on the fast (cache-hit) path:
public ValueTask<int> GetCountValueTaskAsync()
{
if (_cache.TryGetValue("count", out int v))
return new ValueTask<int>(v); // struct, no heap allocation
return new ValueTask<int>(FetchCountFromDbAsync());
}
Restrictions on ValueTask<T> (unlike Task<T>):
- Await only once — a
ValueTaskmust not be awaited more than once. - Do not call
.Resultbefore the task completes. - Do not use in concurrent scenarios — one consumer only.
// Correct usage:
var result = await GetCountValueTaskAsync(); // await once, done
// Wrong — multi-await:
var vt = GetCountValueTaskAsync();
var a = await vt; // ok
var b = await vt; // undefined behaviour!
Rule of thumb: Default to Task<T>. Use ValueTask<T> only when profiling shows
that the synchronous-result fast path is frequent enough that the allocation overhead matters.
CancellationToken is the standard .NET mechanism for cooperative cancellation.
A CancellationTokenSource issues tokens; calling .Cancel() on the source signals
all tokens issued from it. Receivers check the token and throw OperationCanceledException.
// Caller: create source and pass token
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10)); // auto-cancel after 10 s
try
{
await DoWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancelled");
}
// Method: accept token and forward it
public async Task DoWorkAsync(CancellationToken ct = default)
{
// Pass to awaitable calls — they throw OperationCanceledException on cancel
await Task.Delay(5000, ct);
// Manual check in CPU-bound loops:
for (int i = 0; i < 1_000_000; i++)
{
ct.ThrowIfCancellationRequested(); // throws OperationCanceledException
Process(i);
}
// Registration callback (for non-cancellable APIs):
using var reg = ct.Register(() => socket.Close());
await socket.ReceiveAsync(buffer);
}
Rule of thumb: Accept CancellationToken ct = default in every async method
and forward it to every awaitable call. Never swallow OperationCanceledException
— let it propagate so the caller knows the operation was cancelled.
SynchronizationContext is an abstraction that lets code schedule work on a
specific thread or context. The await machinery captures the current
SynchronizationContext before suspending and posts the continuation back to it
on resume — ensuring UI updates happen on the UI thread.
// WPF SynchronizationContext routes continuations to the Dispatcher (UI thread)
private async void Button_Click(object sender, EventArgs e)
{
// Currently on UI thread; SynchronizationContext = DispatcherSynchronizationContext
var data = await FetchDataAsync();
// Resumed on UI thread — safe to update UI
label.Content = data;
}
// Console apps and ASP.NET Core have SynchronizationContext.Current == null
// Continuations run on any ThreadPool thread
ConfigureAwait(false) bypasses the context:
public async Task<string> LibraryMethodAsync()
{
// Does NOT capture the context; continuation may run on any pool thread
var result = await httpClient.GetStringAsync(url).ConfigureAwait(false);
return result; // fine — no UI/context operations needed here
}
Rule of thumb: Library code should always use ConfigureAwait(false) so it does
not accidentally pin continuations to a specific context. Application code that updates
UI or uses request-scoped services should NOT use ConfigureAwait(false).
Exceptions thrown in an async Task method are stored in the returned Task and
re-thrown when the Task is awaited. If you never await the Task, the exception is
unobserved.
public async Task FaultyAsync()
{
await Task.Delay(10);
throw new InvalidOperationException("failed");
}
// Correct: await and catch
try
{
await FaultyAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message); // "failed"
}
// WhenAll re-throws the FIRST exception; inspect all via AggregateException:
var t1 = ThrowAsync("err1");
var t2 = ThrowAsync("err2");
try
{
await Task.WhenAll(t1, t2);
}
catch
{
// t1.Exception and t2.Exception hold the individual exceptions
foreach (var t in new[] { t1, t2 })
if (t.IsFaulted)
Console.WriteLine(t.Exception!.InnerException!.Message);
}
For fire-and-forget patterns, always log unobserved exceptions:
_ = DoWorkAsync().ContinueWith(
t => logger.LogError(t.Exception, "Background task failed"),
TaskContinuationOptions.OnlyOnFaulted);
Rule of thumb: Always await Tasks. If you must fire-and-forget, attach a
.ContinueWith error handler so exceptions are never silently swallowed.
Both schedule a delegate on the ThreadPool, but Task.Run has safer defaults and
should be the default choice. Task.Factory.StartNew gives more control but is
error-prone.
// Task.Run — the recommended way to offload CPU work:
var result = await Task.Run(() => ExpensiveCpuWork());
// Task.Factory.StartNew — returns Task<Task> for async delegates!
// You MUST Unwrap() or you await the outer task, not the inner one:
var badTask = Task.Factory.StartNew(async () =>
{
await Task.Delay(1000);
return 42;
});
// badTask is Task<Task<int>> — awaiting it gives Task<int>, not 42!
var result2 = await await badTask; // double-await needed!
// Task.Run handles async delegates correctly (implicitly unwraps):
var goodTask = Task.Run(async () =>
{
await Task.Delay(1000);
return 42;
});
int value = await goodTask; // 42 — correct
Task.Factory.StartNew options you might still need:
TaskCreationOptions.LongRunning— hints to use a dedicated thread (not pool).TaskScheduler— run on a custom scheduler.
Rule of thumb: Always use Task.Run for offloading CPU-bound work to the
ThreadPool. Use Task.Factory.StartNew only when you need options not available
in Task.Run (long-running, custom scheduler).
IAsyncEnumerable<T> (C# 8 / .NET Core 3) is the async equivalent of
IEnumerable<T> — a stream of values that are produced asynchronously one at a
time. Unlike returning Task<IEnumerable<T>> (which waits for all items before
yielding any), IAsyncEnumerable<T> streams items as they become available.
// Producer: async iterator method
public async IAsyncEnumerable<int> GenerateAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
for (int i = 0; i < 100; i++)
{
await Task.Delay(50, ct); // async work per item
yield return i; // yield each item as ready
}
}
// Consumer: await foreach
await foreach (var value in GenerateAsync(cancellationToken))
{
Console.WriteLine(value); // processes each item as it arrives
}
// Real-world: streaming large DB results with EF Core
public async IAsyncEnumerable<Order> StreamOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in dbContext.Orders
.Where(o => o.Status == "Pending")
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return order;
}
}
Rule of thumb: Use IAsyncEnumerable<T> when producing a large or unbounded
stream where you want to process or forward items before the entire sequence is
ready — large database results, real-time feeds, or paginated API calls.
The standard approach uses CancellationTokenSource.CancelAfter or
Task.WhenAny with Task.Delay. Each has trade-offs in resource cleanup
and exception semantics.
// Option 1: CancellationTokenSource.CancelAfter (preferred)
public async Task<string> FetchWithTimeoutAsync(string url, int timeoutMs)
{
using var cts = new CancellationTokenSource(timeoutMs);
try
{
return await httpClient.GetStringAsync(url, cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
throw new TimeoutException($"Request to {url} exceeded {timeoutMs} ms");
}
}
// Option 2: Task.WhenAny with Task.Delay (when the API does not accept a token)
public async Task<string> FetchWithWhenAnyAsync(string url, int timeoutMs)
{
var workTask = httpClient.GetStringAsync(url);
var timeoutTask = Task.Delay(timeoutMs);
if (await Task.WhenAny(workTask, timeoutTask) == timeoutTask)
throw new TimeoutException($"Request exceeded {timeoutMs} ms");
return await workTask; // already completed
}
// Note: WhenAny does NOT cancel the workTask — it keeps running in the background.
// Prefer Option 1 so the underlying operation is truly stopped on timeout.
// Option 3: CancellationTokenSource.CreateLinkedTokenSource to merge timeouts
public async Task<string> FetchAsync(string url, CancellationToken ct)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
return await httpClient.GetStringAsync(url, linkedCts.Token);
}
Rule of thumb: Always prefer CancellationTokenSource with CancelAfter over
Task.WhenAny + Task.Delay. The token approach actually cancels the underlying
operation; WhenAny just abandons it, leaving the work running and the connection open.
TaskCompletionSource<T> lets you manually control when a Task<T> completes,
faults, or is cancelled. It bridges callback-based or event-based APIs into the
Task-based async world.
// Bridge a callback API to Task:
public Task<string> ReadLineAsync(Socket socket)
{
var tcs = new TaskCompletionSource<string>();
socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, ar =>
{
try
{
int bytes = socket.EndReceive(ar);
tcs.SetResult(Encoding.UTF8.GetString(buffer, 0, bytes)); // signals completion
}
catch (Exception ex)
{
tcs.SetException(ex); // signals fault
}
}, null);
return tcs.Task; // caller awaits this — completes when callback fires
}
// Gate pattern — signal readiness from outside:
public class ReadyGate
{
private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task WaitAsync() => _tcs.Task;
public void Signal() => _tcs.TrySetResult(); // complete the gate
}
var gate = new ReadyGate();
_ = Task.Run(async () =>
{
await Task.Delay(500);
gate.Signal(); // unblocks all waiters
});
await gate.WaitAsync(); // blocks until gate is signalled
// Note: TaskCreationOptions.RunContinuationsAsynchronously prevents
// SetResult from running continuations synchronously on the caller's stack.
Rule of thumb: Use TaskCompletionSource<T> to wrap legacy callback or event APIs
into awaitable Tasks. Always pass TaskCreationOptions.RunContinuationsAsynchronously
to avoid deadlocks from continuations running inline inside the callback.
IProgress<T> is the standard interface for async progress reporting. Progress<T>
captures the SynchronizationContext at construction time and marshals calls to
Report back to that context (e.g., the UI thread), so subscribers can safely update UI.
// Method: accept IProgress<T> and call Report as work advances
public async Task DownloadFilesAsync(
IEnumerable<string> urls,
IProgress<int>? progress = null,
CancellationToken ct = default)
{
var list = urls.ToList();
for (int i = 0; i < list.Count; i++)
{
await DownloadOneAsync(list[i], ct);
progress?.Report(i + 1); // report count completed so far
}
}
// Caller in a WPF / WinForms app — Progress<T> marshals to UI thread:
var progressHandler = new Progress<int>(count =>
{
// This runs on the UI thread — safe to update controls:
progressBar.Value = count;
statusLabel.Text = $"Downloaded {count} files";
});
await DownloadFilesAsync(urls, progressHandler, cts.Token);
// Caller in a console app — no SynchronizationContext; runs inline:
var consoleProgress = new Progress<int>(n => Console.Write($"\r{n} done"));
await DownloadFilesAsync(urls, consoleProgress);
// Note: IProgress<T> is accepted as nullable so callers can opt out:
await DownloadFilesAsync(urls); // progress = null, no overhead
Rule of thumb: Accept IProgress<T>? (nullable) in long-running async methods
so callers can opt in to progress updates without changing the method signature. Use
Progress<T> — not a raw delegate — so progress callbacks are marshalled back to the
correct thread automatically.
More C# Core interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.