Concepts
What you will learn: An waits on a database or payment service: how can it serve other requests while waiting, and how can it prevent conflicting concurrent work? We will distinguish asynchrony from parallelism, then use cancellation and synchronization when there is a concrete need.
Before you start: Synchronous execution completes one step before moving on. In C#, a Task represents work or a result that may complete later; by itself it does not mean a new thread. Begin with the restaurant analogy, understand Task, then move to and locks.
Select a term with a dotted underline for its meaning, then continue reading from the same place.
#Sync vs async: the restaurant analogy
The synchronous waiter takes an order, walks to the kitchen and stands there waiting until the food is ready. During that time nobody else is served, so ten customers need ten waiters.
The asynchronous waiter hands the order to the kitchen and serves another table. When the food is ready the kitchen calls, and any free waiter delivers it. One or two waiters can now serve ten tables.
The waiter is a thread and the kitchen is : databases, calls, files. Async doesn't make the kitchen faster; it stops waiters from wasting time waiting.
public IActionResult Get(int id)
{
var order = db.Orders.Find(id); // thread blocked ~20 ms
var rate = http.GetStringAsync(ratesUrl).GetAwaiter().GetResult(); // blocks
return Ok(order);
}public async Task<IActionResult> Get(int id, CancellationToken ct)
{
var order = await db.Orders.FindAsync([id], ct); // thread released while the DB works
var rate = await http.GetStringAsync(ratesUrl, ct);
return Ok(order);
}The benefit in an : the same number of threads can handle far more concurrent requests. That's scalability, not speed: a single request doesn't get faster.
#How async/await works under the hood
- A request needs a shipping price from another service. Sending takes little work, but the reply arrives later: most elapsed time is waiting rather than computation.
- Awaiting an incomplete task saves the state needed to continue and returns control to the caller. Execution resumes after the await when the task completes.
- This avoids reserving a thread throughout waiting. Await does not automatically create a new thread, and an already completed task may continue synchronously.
The compiler rewrites an async method into a state machine: a type with a MoveNext() method and a field that remembers which await it reached.
- The method runs synchronously until the first
await. - If the awaited task is already complete, for example a cache hit, execution simply continues on the same thread.
- Otherwise it registers a continuation, returns an incomplete
Taskto its caller, and the thread is freed. - With true asynchronous network I/O, no thread waits: the signals completion through I/O completion ports on Windows or epoll on Linux. Some filesystem or provider async may offload blocking work to a thread.
- When the operation finishes, the continuation runs on a ThreadPool thread, or the thread in desktop apps, and resumes at the same point with all local state.
- Exceptions are captured in the Task and rethrown at the
await.
public async Task<decimal> GetTotalAsync(Guid orderId, CancellationToken ct)
{
var order = await repo.GetAsync(orderId, ct); // may suspend here
var rate = await fx.GetRateAsync(order.Currency, ct); // and here
return order.Total * rate; // resumes with locals intact
}
#Thread vs Task vs ThreadPool
| Thread | Task | |
|---|---|---|
| What it is | A real thread with its own stack (about 1 MB) | A promise of a result from an operation that finishes later |
| Cost | Expensive to create and hold | Cheap; work runs on ThreadPool threads |
| Result | No direct return value | Task<T> returns a value |
| Exceptions | Unhandled ones crash the process | Stored and rethrown at await |
| Cancellation, composition | Manual | CancellationToken, WhenAll, WhenAny |
| While waiting on true network | Blocked | Often no thread at all |
The ThreadPool is a set of reusable worker threads. serves each request on one of them. Blocking those threads with .Result exhausts the pool, which is called thread pool starvation.
#Task.Run, WhenAll and WhenAny
Task.Run queues work to a pool thread. It's useful in and console apps for -heavy work. In you're already on a pool thread, so wrapping in it only adds overhead.
var order = await Task.Run(() => db.Orders.Find(id)); // still blocks a pool threadvar order = await db.Orders.FindAsync([id], ct);// WhenAll: start everything first, then wait for ALL → total time ≈ the slowest call
var stockTask = inventory.GetStockAsync(sku, ct);
var priceTask = pricing.GetPriceAsync(sku, ct);
var reviewsTask = reviews.GetAsync(sku, ct);
await Task.WhenAll(stockTask, priceTask, reviewsTask); // (1)
var page = new ProductPage( // (2) all complete
await stockTask, await priceTask, await reviewsTask);
// WhenAny: continue when the FIRST task finishes
var winner = await Task.WhenAny(primary.GetAsync(ct), replica.GetAsync(ct));
var data = await winner; // (3) observe result or exception
// WaitAsync (.NET 6+) times out the wait; the CTS times out the operation
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
var receipt = await gateway.ChargeAsync(order, timeoutCts.Token) // (4)
.WaitAsync(TimeSpan.FromSeconds(5), ct);
- (1) All three calls run concurrently. At 100 ms each, the total is about 100 ms, not 300. If one fails,
awaitrethrows the first exception; the rest are inException.InnerExceptions. - (2) The tasks are already complete, so these awaits return immediately.
- (3)
WhenAnyreturns the first finished task without throwing, so you must await the winner. - (4)
WaitAsyncstops waiting after five seconds; it doesn't cancel the operation. To time out the operation, pass it a timedCancellationTokenSource, as shown here. The provider may not honor cancellation.
WhenAll over queries on the same . DbContext isn't thread-safe and will throw. Use IDbContextFactory to create a context per task, or run them sequentially.#Cancellation
A user may close a report page, or an request may end before the database finishes. Instead of doing needless work, the caller passes a cancellation signal and each layer decides how to respond. The code shows passing and checking it; cancellation is cooperative and does not forcibly kill a thread.
[HttpGet("report")]
// (1) ct is cancelled when the client disconnects
public async Task<IActionResult> Report(CancellationToken ct)
{
// (2) pass it everywhere
var rows = await db.Sales.Where(s => s.Year == 2026).ToListAsync(ct);
foreach (var row in rows)
{
ct.ThrowIfCancellationRequested(); // (3) check inside long CPU loops
Process(row);
}
return Ok();
}
// combine the request token with your own timeout
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(10)); // (4)
await partnerApi.SyncAsync(cts.Token);
- (1) supplies a that fires when the client disconnects, so a heavy report can stop early.
- (2) Pass it to every async call. passes it to the database provider, which may not honor cancellation.
- (3) In long loops, check it yourself.
- (4) A linked token fires when the client leaves or ten seconds pass.
Cancellation is cooperative: the token doesn't kill code; the code checks it. Cancelling throws OperationCanceledException, which is not an error and shouldn't be logged as one. EF Core passes the token to the database provider, but providers may not honor it.
CancellationToken.None for steps that must complete, and reconcile afterwards.#Race conditions and synchronization primitives
- One item remains. A reads 1; B also reads 1 before A saves. Both decide to sell. The read, decision and write sequence was not protected.
- An appropriate lock can protect shared state within one process, but a lock in one server instance does not protect another instance.
- For database inventory, one solution is an atomic conditional update: decrement only if stock is positive, then inspect the affected-row count. Locate the shared state before choosing synchronization.
Two people withdraw from the same account at the same moment: both read a balance of 100, both withdraw 80, both write 20. 160 left an account holding 100! That's what happens when several threads read and modify shared data without coordination.
private int _count;
// NOT atomic: read → add → write, so concurrent updates get lost
public void Hit() => _count++;
// 1) lock: one thread at a time (synchronous code only)
// .NET 9+ Lock type (before: private readonly object _gate = new();)
private readonly Lock _gate = new();
public void Add(Item i) { lock (_gate) { _items.Add(i); _total += i.Price; } }
// 2) Interlocked: atomic operations on a single value
Interlocked.Increment(ref _count);
// 3) SemaphoreSlim: works with await, and can allow N at a time
private readonly SemaphoreSlim _refresh = new(1, 1);
public async Task RefreshTokenAsync(CancellationToken ct)
{
await _refresh.WaitAsync(ct);
try { _token = await auth.GetTokenAsync(ct); }
finally { _refresh.Release(); } // always release in finally
}
| Tool | When |
|---|---|
| lock / Lock | Short synchronous critical sections. You can't await inside a lock; the compiler forbids it. |
| Interlocked | A single counter or flag; the fastest option. |
| SemaphoreSlim | Protecting async code, or capping concurrency (throttling). |
| ConcurrentDictionary / ConcurrentQueue | Ready-made thread-safe collections. |
| Channel<T> | In-memory async producer/consumer queues. |
lock protects nothing between them. Shared data needs the database, through a concurrency , a conditional UPDATE ... WHERE Stock >= @qty or a row lock, or a distributed lock.#Deadlocks and common async mistakes
.Resultand.Wait()(sync over async): block the thread. Under a synchronization context, as in apps or classic ASP.NET, this : the thread waits for the task while the task waits to resume on that thread. has no synchronization context, so it doesn't deadlock, but it causes thread pool starvation.async void: returns nothing awaitable, and exceptions crash the process. Use it only for event handlers.- Forgetting
await: the operation runs fire-and-forget, exceptions vanish, and the may be disposed before it finishes. - Fire-and-forget inside a request:
_ = SendEmailAsync()outlives the scope. Use a queue, a background job or an . - Two concurrent operations on one DbContext.
lockaroundawait: not allowed; useSemaphoreSlim.- Not flowing the CancellationToken.
ConfigureAwait(false) tells await it doesn't need to resume on the captured context. It matters in libraries and UI apps; in ASP.NET Core application code it has no effect because there's no synchronization context.
Lock deadlocks: thread 1 holds A and waits for B while thread 2 holds B and waits for A, forever. The fix is a consistent lock order, short critical sections and timeouts. The same applies to database .
#Parallelism vs asynchrony, and Channels
| Async (concurrency) | Parallel (parallelism) | |
|---|---|---|
| Goal | Don't waste threads while waiting | Use several cores at once |
| Kind of work | -bound | -bound |
| Tools | async, await, WhenAll | Parallel.For, Parallel.ForEachAsync, |
// CPU-bound: spread across cores
Parallel.For(0, images.Length, i => images[i] = Resize(images[i]));
// I/O-bound with a concurrency limit (.NET 6+)
var options = new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct };
await Parallel.ForEachAsync(skus, options,
async (sku, token) => await SyncStockAsync(sku, token));
// Channel: in-memory async queue; bounded = back-pressure when consumers are slow
var channel = Channel.CreateBounded<OrderPlaced>(capacity: 1000);
await channel.Writer.WriteAsync(evt, ct); // producer
await foreach (var e in channel.Reader.ReadAllAsync(ct)) await HandleAsync(e); // consumer
A line worth remembering: "Concurrency is about dealing with many things at once; parallelism is about doing many things at once."
Interview questions
No results
Check your reasoning
Why does this choice fit? When would it fail? What changes under higher load or a dependency failure?
4.1What is the difference between synchronous and asynchronous programming?Junior
Synchronous code blocks the thread until an operation completes. Asynchronous code starts the operation, releases the thread while waiting, and continues when the result arrives. In a web that lets the same thread pool handle many more concurrent requests during , so it improves and scalability rather than the speed of a single request.
4.2How do async and await work under the hood?Mid
The compiler rewrites an async method into a state machine. At each await, if the task isn't complete, the method registers a continuation and returns an incomplete Task to its caller, freeing the thread. During no thread waits; the signals completion. The continuation then resumes the state machine at the next step, on a thread-pool thread in or on the captured context in apps. Exceptions are stored in the task and rethrown at the await.
4.3What is the difference between a Task and a Thread?Junior
A thread is an operating-system thread with its own stack, expensive to create. A Task is a higher-level representation of an asynchronous operation and its eventual result. work in tasks runs on pooled threads, and tasks don't hold a thread while waiting. Tasks also give you return values, exception propagation, cancellation and composition, so modern code uses tasks rather than raw threads.
4.4What is Task.Run and when should you use it?Mid
Task.Run queues a to the thread pool. I use it to offload -bound work from a thread, or to run independent CPU-heavy pieces in parallel. In I don't wrap synchronous in Task.Run to make it look async; that just moves the blocking to another pool thread. I use real async instead.
4.5What is the difference between Task.WhenAll and Task.WhenAny?Mid
Task.WhenAll completes when all tasks complete, so independent operations take about as long as the slowest one; awaiting it rethrows the first exception while the task's Exception holds all of them. Task.WhenAny completes when the first task completes and returns that task without throwing, so you await it to get its result. I use WhenAll to run independent calls concurrently, and WaitAsync or WhenAny for timeouts.
4.6What are the most common async/await mistakes?Mid
Blocking on tasks with .Result or .Wait, which under a synchronization context and starves the thread pool in ; async void outside event handlers; forgetting to await, which silently drops exceptions; fire-and-forget work that outlives the request's scope; concurrent operations on one ; trying to lock across an await; and not passing the CancellationToken through.
4.7Why is async void dangerous?Junior
An async void method returns nothing awaitable, so callers can't wait for it or observe failures. Its exceptions are raised directly on the synchronization context or thread pool and usually crash the process. It exists only for event handlers; everything else should return Task or ValueTask.
4.8What is the difference between parallel and asynchronous programming?Mid
Asynchronous programming is about not blocking while waiting, mostly on , and can run on very few threads. Parallel programming splits -bound work across cores to finish sooner, with Parallel.For, or multiple tasks. They combine well, for example Parallel.ForEachAsync processes many I/O calls with a concurrency limit.
4.9What is a CancellationToken and how do you use it?Mid
It's a cooperative cancellation signal created by a CancellationTokenSource. Code receiving it checks it or passes it to that support it, which throw OperationCanceledException when it fires. In , actions receive a that cancels when the client disconnects, and I flow it through and HttpClient calls. For timeouts I create a linked source with CancelAfter.
4.10What does ConfigureAwait(false) do, and do you need it in ASP.NET Core?Senior
It tells the awaiter not to resume on the captured synchronization context. In apps and classic ASP.NET that avoids and needless context switches, so library code uses it everywhere. has no synchronization context, so in application code it makes no difference, though libraries still use it because they don't know where they'll run.
4.11Task vs ValueTask: when would you use ValueTask?Senior
ValueTask is a struct that avoids allocating a Task when a method usually completes synchronously, such as returning a cached value. It has rules: await it only once and don't block on it before it completes. I default to Task and use ValueTask on hot paths where profiling shows the matter.
4.12What is a race condition and how do you prevent it?Mid
A is when the outcome depends on the timing of threads touching shared mutable state, such as two threads incrementing a counter and losing an update. I avoid shared mutable state where possible, use data, or synchronize with lock, Interlocked, SemaphoreSlim or concurrent collections. Across multiple service instances, I enforce correctness in the database with , atomic conditional updates or row locks.
4.13How does the lock keyword work?Mid
lock ensures only one thread at a time runs the block and always releases it, compiling to Monitor.Enter and Exit in try/finally, or to Lock.EnterScope when you lock on the System.Threading.Lock type introduced in 9. I lock on a private readonly object, never on this, a type or a string, keep the section short, and never await inside it; for async code I use SemaphoreSlim.
4.14What is a deadlock and how do you avoid it?Mid
A happens when two or more threads or each hold a resource and wait for one held by another, so none can proceed. I avoid it by acquiring locks in a consistent order, keeping critical sections and transactions short, using timeouts, and never blocking on async code. Databases detect deadlocks and kill one transaction as the victim, so those operations should be retried.
4.15What is thread pool starvation and how do you detect it?Senior
It happens when pool threads are blocked, typically by sync-over-async code, so queued work waits while the pool slowly injects new threads. explodes while stays low. dotnet-counters shows a growing thread-pool queue length and thread count. The fix is making the call chain async all the way down, not raising the minimum thread count.
4.16What are concurrent collections, and what is a Channel?Mid
Concurrent collections like ConcurrentDictionary and ConcurrentQueue are thread-safe without manual locking. A subtle point: GetOrAdd may run its factory more than once under contention, so for one-time creation I store Lazy values. Channel of T is an async producer-consumer queue; a bounded channel applies back-pressure when consumers fall behind, which makes it ideal for in-process pipelines and background workers.
4.17What is the difference between IEnumerable and IAsyncEnumerable?Mid
IAsyncEnumerable produces and consumes a sequence asynchronously, awaiting between items with await foreach and async iterators. It's useful for streaming large query results from or returning a streamed array from an endpoint without loading everything into memory or blocking threads.
Test yourself
Quiz answers and completed-topic marks are saved in this browser; they do not sync across devices.
What is the main benefit of async/await in an ASP.NET Core API?
Calling .Result on a Task inside ASP.NET Core mainly causes...
Three independent 100 ms calls awaited with Task.WhenAll take about...
You need to await inside a critical section. Which primitive?
A C# lock protects a stock counter, but the API runs on 4 instances. Is stock safe?
Two EF Core queries on the same DbContext are started with Task.WhenAll. What happens?
Where is async void acceptable?
Best tool for CPU-bound work across cores?