C# Async & Multithreading

C# Async & Multithreading

Explore c# async & multithreading code snippets and tutorials

C# Async & Multithreading

C# race conditions explained: lost updates, check-then-act, Interlocked and concurrent collections

Common forms: lost update (++), check-then-act, and a Dictionary modified from multiple threads (which can get corrupted internally).

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// ======================================================================
// Q: What is a race condition? Show an example.
//
// Common forms: lost update (++), check-then-act, and a Dictionary modified from
// multiple threads (which can get corrupted internally). Fixes: immutable data,
// Interlocked, lock, concurrent collections, or no shared state at all (scoped
// services). In the database the same problem is solved with an atomic UPDATE or
// a concurrency token (Q60-Q61).
// ======================================================================

public class Stats                        // singleton: shared by all requests
{
    private int _orders;
    public void OnOrder() => _orders++;   // ✗ read-modify-write: with 2 threads one increment is lost
}                                         // ✓ Interlocked.Increment(ref _orders);

// ✗ check-then-act: two threads both see "not there" and both load it
if (!_cache.ContainsKey(sku)) _cache[sku] = await LoadAsync(sku);
C# Async & Multithreading

Thread pool starvation in .NET: detecting it with dotnet-counters and dotnet-stack, and fixing it

Symptoms: latency rises sharply with load while CPU stays low, timeouts appear, and health checks fail.

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# ======================================================================
# Q: What is thread pool starvation? How do you detect and fix it?
#
# Symptoms: latency rises sharply with load while CPU stays low, timeouts
# appear, and health checks fail. Cause: threads that block (.Result,
# Thread.Sleep, sync I/O, a lock with long waits). Above the minimum, the pool
# adds new threads slowly (about 1-2 per second), so the queue keeps growing.
# Fix: async all the way. ThreadPool.SetMinThreads is a patch, not a solution.
# If a library only has a sync API, isolate it (a separate worker, a limit on
# concurrent calls).
# ======================================================================

dotnet-counters monitor -n Shop.Api --counters System.Runtime
#  ThreadPool Queue Length rising, ThreadPool Thread Count rising slowly  → starvation
dotnet-stack report -p <pid>     # where the threads are "stuck"
C# Async & Multithreading

Should you elide async/await in C# and return the Task directly? The using + DbContext pitfall

Returning the Task without async saves the state machine, but the gain is minimal.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ======================================================================
// Q: Should you elide async/await and just return the Task?
//
// Returning the Task without async saves the state machine, but the gain is
// minimal. It breaks with using and try/catch, though, and validation exceptions
// are thrown synchronously instead of inside the Task. Rule: await by default.
// Elide only in simple pass-through overloads.
// ======================================================================

// ✗ Bug: the using disposes the DbContext BEFORE the query completes
public Task<List<Order>> GetOrdersAsync(CancellationToken ct)
{
    using var db = dbFactory.CreateDbContext();
    return db.Orders.ToListAsync(ct);   // ObjectDisposedException: the using runs here, not at the end of the query
}

// ✓ With await, Dispose happens after the query finishes
public async Task<List<Order>> GetOrdersAsync(CancellationToken ct)
{
    await using var db = await dbFactory.CreateDbContextAsync(ct);
    return await db.Orders.ToListAsync(ct);
}
C# Async & Multithreading

C# async exception handling: await, Task.WhenAll AggregateException and fire-and-forget tasks

await rethrows the first exception with its original stack trace, not an AggregateException.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ======================================================================
// Q: How are exceptions handled with await, Task.WhenAll and fire-and-forget?
//
// - await rethrows the first exception with its original stack trace, not an
//   AggregateException. .Result / .Wait() throw AggregateException.
// - Task.WhenAll: if several tasks fail, await throws only the first one. All of
//   them are in the task's Exception.InnerExceptions, as shown in the code.
// - A Task that fails and is never awaited: the exception is silently lost
//   (TaskScheduler.UnobservedTaskException). That is why you don't leave
//   "orphan" tasks.
// - An exception escaping BackgroundService.ExecuteAsync: since .NET 6 it stops
//   the whole host (BackgroundServiceExceptionBehavior.StopHost). That is why
//   you put a try/catch inside the loop (as in the demo's OutboxProcessor).
// ======================================================================

// Log every failure from Task.WhenAll, then rethrow
var all = Task.WhenAll(tasks);
try { await all; }
catch
{
    foreach (var ex in all.Exception!.InnerExceptions)
        logger.LogError(ex, "Price sync failed");
    throw;
}
C# Async & Multithreading

What happens to the thread during await in C#? Continuations, ExecutionContext and ASP.NET Core

While you wait for I/O, no thread is waiting.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// ======================================================================
// Q: What happens to the thread during await? Where does the code continue?
//
// - While you wait for I/O, no thread is waiting. The operating system (IOCP /
//   epoll) signals when the operation completes.
// - If the Task has already completed (e.g. a cache hit), await continues
//   synchronously, without switching threads.
// - The code before the first await runs synchronously on the caller's thread.
//   An async Task Foo() { HeavyCpu(); await ...; } blocks the caller while
//   HeavyCpu runs.
// - In ASP.NET Core the continuation runs on any thread-pool thread. The
//   ExecutionContext flows with it (AsyncLocal, culture, Activity for tracing).
//   ThreadLocal / [ThreadStatic] do not, so you don't use them in async code.
// ======================================================================

Console.WriteLine(Environment.CurrentManagedThreadId);   // e.g. 12
await httpClient.GetAsync(url, ct);
Console.WriteLine(Environment.CurrentManagedThreadId);   // e.g. 27: different thread, same logical flow