Interview Handbook by Sayed Elmahdy العربية

Topic 04 · C# & .NET

Async & multithreading

Thread vs Task, async/await internals, WhenAll, cancellation, locks, race conditions, deadlocks.

17 questionsJunior · 3Mid · 11Senior · 38 quiz

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.

❌ Sync: the thread is blocked while 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);
}
✅ Async: the thread returns to the pool while waiting
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 worked example
  1. 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.
  2. 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.
  3. 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.

  1. The method runs synchronously until the first await.
  2. If the awaited task is already complete, for example a cache hit, execution simply continues on the same thread.
  3. Otherwise it registers a continuation, returns an incomplete Task to its caller, and the thread is freed.
  4. 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.
  5. 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.
  6. 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

ThreadTask
What it isA real thread with its own stack (about 1 MB)A promise of a result from an operation that finishes later
CostExpensive to create and holdCheap; work runs on ThreadPool threads
ResultNo direct return valueTask<T> returns a value
ExceptionsUnhandled ones crash the processStored and rethrown at await
Cancellation, compositionManualCancellationToken, WhenAll, WhenAny
While waiting on true network BlockedOften 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.

❌ Task.Run around I/O in an
var order = await Task.Run(() => db.Orders.Find(id));   // still blocks a pool thread
✅ Use the real async API
var 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);
🧾 Code walkthrough
  1. (1) All three calls run concurrently. At 100 ms each, the total is about 100 ms, not 300. If one fails, await rethrows the first exception; the rest are in Exception.InnerExceptions.
  2. (2) The tasks are already complete, so these awaits return immediately.
  3. (3) WhenAny returns the first finished task without throwing, so you must await the winner.
  4. (4) WaitAsync stops waiting after five seconds; it doesn't cancel the operation. To time out the operation, pass it a timed CancellationTokenSource , as shown here. The provider may not honor cancellation.
Classic trap: never run 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);
🧾 Code walkthrough
  1. (1) supplies a that fires when the client disconnects, so a heavy report can stop early.
  2. (2) Pass it to every async call. passes it to the database provider, which may not honor cancellation.
  3. (3) In long loops, check it yourself.
  4. (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.

For critical steps: after sending a charge to a payment gateway, don't abort because the user closed the page; the payment may have succeeded. Use CancellationToken.None for steps that must complete, and reconcile afterwards.

#Race conditions and synchronization primitives

A worked example
  1. 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.
  2. An appropriate lock can protect shared state within one process, but a lock in one server instance does not protect another instance.
  3. 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
}
ToolWhen
lock / LockShort synchronous critical sections. You can't await inside a lock; the compiler forbids it.
InterlockedA single counter or flag; the fastest option.
SemaphoreSlimProtecting async code, or capping concurrency (throttling).
ConcurrentDictionary / ConcurrentQueueReady-made thread-safe collections.
Channel<T>In-memory async producer/consumer queues.
What interviewers look for: all of these work inside one process. With four instances, a 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

  1. .Result and .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.
  2. async void: returns nothing awaitable, and exceptions crash the process. Use it only for event handlers.
  3. Forgetting await: the operation runs fire-and-forget, exceptions vanish, and the may be disposed before it finishes.
  4. Fire-and-forget inside a request: _ = SendEmailAsync() outlives the scope. Use a queue, a background job or an .
  5. Two concurrent operations on one DbContext.
  6. lock around await: not allowed; use SemaphoreSlim.
  7. 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)
GoalDon't waste threads while waitingUse several cores at once
Kind of work-bound-bound
Toolsasync, await, WhenAllParallel.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

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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
Model answer

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.

Score: 0 / 0

What is the main benefit of async/await in an ASP.NET Core API?

The benefit is scalability: the same threads serve more requests.

Calling .Result on a Task inside ASP.NET Core mainly causes...

There's no synchronization context, so no classic , but threads are held hostage.

Three independent 100 ms calls awaited with Task.WhenAll take about...

They run concurrently, so the time is roughly the slowest call.

You need to await inside a critical section. Which primitive?

You can't await inside lock; SemaphoreSlim supports WaitAsync.

A C# lock protects a stock counter, but the API runs on 4 instances. Is stock safe?

Real protection belongs in the database, such as a conditional UPDATE.

Two EF Core queries on the same DbContext are started with Task.WhenAll. What happens?

isn't thread-safe; use IDbContextFactory.

Where is async void acceptable?

Everywhere else, return Task.

Best tool for CPU-bound work across cores?

Async is for ; parallelism is for .