pkey
.Net

Async/Await in C#: How It Works Under the Hood, ThreadPool Starvation, and Real-World Pitfalls

Introduction

Async and await in C# are foundational for modern backend systems, yet they remain one of the most misunderstood features in the language. Many developers treat async as a performance tool, expecting it to make code faster. In reality, async is about something far more subtle and far more important: efficient use of threads.

In high-load systems such as ASP.NET Core APIs, microservices, industrial integrations, and cloud-based backends, threads are a limited resource. Blocking threads unnecessarily leads to cascading failures. Systems that appear idle in terms of CPU usage may still suffer from severe latency, request queuing, and timeouts.

The root cause is often incorrect async usage.

This article explains:

  • how async/await works under the hood
  • what the compiler actually generates
  • why await does not create threads
  • how ThreadPool starvation occurs
  • what “stolen threads” are
  • how to write production-grade async code in C#

How async/await works internally

https://images.openai.com/static-rsc-4/QK3A5ixu1z9mqwqzPK1Zji1kMgsOX7_Y689TI4IfLCnSIiZq4WgLny2mWsxrggk34ws-EXAb3LWDvj5IY-VOo1hkGRE57_NOqVMHfl4n3hol2aLHLFpJm7wxT8p4sE7gEAJf5jor1viF0tPwCFaNUH7zubGkt47etCT9oPMa3Cn5UQ6NkEE34Q855K_vFyUz?purpose=fullsize
https://images.openai.com/static-rsc-4/ztttxi725hivETINXF3UdOiuILxMS1W_JT5X1uocT-LSbIxp-3qRlfD27V2t8S-s3zzOGppwSmL0IPOjc4h3UMBVKNexdIhzWaoE6Gg9t1T79nBeJsti036BfxAal2zNokKJG42zty3xDRwswVrAUT6ePlH8zRRFHg8qXYLaigiSlpeIcObuKHrpued-MMqH?purpose=fullsize
https://images.openai.com/static-rsc-4/3rNrs_XP7smaXa2dG7rMz9UUVs642HPm99sd3jrYAc45gLTGt3wy3wY30abumKIWTOW5Etr8MAdoF6uJgqTcy5B7HhYikR78noNrlxvbMWSW_KeuAukjB3wOQt8ddL_TC594snlHaMIEJnkBoXJWt-Iz22U32jdshO_dnBEgX9lAWUQIdXAIv6WDnbPo1e1A?purpose=fullsize

6

When you write an async method:

C#
public async Task<OrderDto> GetOrderAsync(int id)
{
    var order = await _repository.GetAsync(id);
    return order;
}

The C# compiler rewrites this method into a state machine. This is not a conceptual metaphor — it is actual generated code.

The method is transformed into a struct or class that contains:

  • a state field (int state)
  • an async method builder (AsyncTaskMethodBuilder)
  • local variables stored as fields
  • a MoveNext() method controlling execution

Conceptually, execution looks like this:

C#
state = -1

MoveNext()
{
    if (state == -1)
    {
        var task = repository.GetAsync(id);

        if (!task.IsCompleted)
        {
            state = 0;
            register continuation;
            return;
        }

        result = task.Result;
    }

    return result;
}

Key takeaway:

👉 async methods are split into steps
👉 execution is paused and resumed
👉 state is stored between awaits


Await does NOT create a thread

https://images.openai.com/static-rsc-4/y8dnbdFA-Wrnxlz1MbPCqpYgVWY4YxEq9T7dVPdpCRJKg78aFQVpgqfN3vDHlZl2WJSn5mX2NU816jiJUYHG_uvXDGaNYF8SiLVxXlzRVI9YeV1Bg3VcqFViYcfoeXxqAh5i-fN7mKmggLTdwbYq6nrBiULNbc9BUOFxamC8X7aG4GcHryEfr411xWKgev_U?purpose=fullsize
https://images.openai.com/static-rsc-4/YET7C_2q8rgDxlCB4SvaZm3YpVOfFAgXQUI-34OqWVxnf6ZQ6xZcHxpJcJL3mOhFisHAWdOg4gNWUjGN_NcEOPhv1tXbd2riWmAmjwe4SJjY9nxF869YQ734IYOCsuzvhXKKPzvYZxLRIZA7mlBRch-RQzJDQ1MiV-rRvydhg6uYbUIUD8aDsAdWmeA1VNxm?purpose=fullsize
https://images.openai.com/static-rsc-4/2dtZkTuFGpd-PnATo1pl7qGajOlWN17bWynGXLPZnOCAHMzSwsYzwhtv8iJS0_LdYlvZiGFiu33GjZrt6IQoDloRKa4dZ61M2m_nxbJoULS5UTOBKHevI-kdcZZziKn064pKHCZK-PxmSQ_pOht2mzTi6Vf4tti55gW7pPycwOWGb-2mN0pm2dhuQLHGLXvB?purpose=fullsize

8

One of the most dangerous misconceptions is that await creates a new thread.

It does not.

Consider this:

C#
await httpClient.GetAsync(url);

What really happens:

  1. HTTP request is initiated
  2. thread returns to ThreadPool
  3. OS handles network I/O
  4. when response arrives → continuation is scheduled

No thread is blocked while waiting.

This is why async scales.


I/O-bound vs CPU-bound work

https://images.openai.com/static-rsc-4/aACxDcq2iEwX7_PmuLU7fLvNBdSF9vrX4_AaVtVyzVH7KkHGNapQG9AareBVmw-wZD5NhUmT7nC8BjZYQVcdeoggZLOFTEfrUAldbcDjfloulKnkcfuji_lVmJHlTT9F3smW257oIbuskCV-wAFUiweXAw5IkRaTu09D2rxkicOFrt0mZRbtS3KR--dyNM77?purpose=fullsize
https://images.openai.com/static-rsc-4/11lWy_BGB45wlGCKOlBWDLqf5zIiJN-y7EgtdqfXCJ1j3DAdvyTXfvOiqxjB9D5SlJWjjJ9LfT8UK-6CAgQvCSjE2KGLqibGWGDDu-gDdVXoiTG3NAC0Yz2rTxWU91nAMvSomcYIb6Esumcq1KO0wOBeHmPtCYIOKAlR9KFiVgKs8GVgpNIRKUlafddsbcyz?purpose=fullsize
https://images.openai.com/static-rsc-4/UdqVCYjmCsLLPuykImCTd_BDZ1ojoMSuet88bdNLKdNaQRFoEGbPqJuSHJfL8lopuAv-xfRRRALYnQKlPcoFEuyY5xfOPRdFIt7QdxvZmBTBmQcEvetbRJnHN6j5R4RBZqnls4vkVacaJ6oXIcYZGP2oOAT4MxEy196UqpMqh-qfi8_iDZ_C7WZzCa-icYls?purpose=fullsize

7

I/O-bound (ideal for async)

C#
await dbContext.Orders.ToListAsync();
await httpClient.GetAsync();
await fileStream.ReadAsync();

✔ thread is released
✔ highly scalable


CPU-bound (not async)

C#
await Task.Run(() => CalculateReport());

✔ uses real thread
✔ consumes CPU

Important:

👉 wrapping I/O in Task.Run is a mistake

C#
await Task.Run(() => db.Orders.ToList());

This blocks a thread instead of freeing it.


Enterprise example – ASP.NET Core + CQRS

Controller

C#
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> Get(int id, CancellationToken ct)
{
    var result = await _mediator.Send(new GetOrderQuery(id), ct);

    if (result == null)
        return NotFound();

    return Ok(result);
}

Handler

C#
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
    private readonly AppDbContext _db;

    public async Task<OrderDto> Handle(GetOrderQuery request, CancellationToken ct)
    {
        return await _db.Orders
            .Where(x => x.Id == request.Id)
            .Select(x => new OrderDto
            {
                Id = x.Id,
                Total = x.Total
            })
            .FirstOrDefaultAsync(ct);
    }
}

✔ no blocking
✔ async all the way
✔ production-ready


Parallel async (latency optimization)

C#
var orderTask = _repo.GetAsync(id, ct);
var customerTask = _client.GetAsync(id, ct);
var stockTask = _stock.GetAsync(id, ct);

await Task.WhenAll(orderTask, customerTask, stockTask);

This reduces latency by executing independent I/O in parallel.


The biggest trap: sync-over-async

C#
var result = GetAsync().Result;

or:

C#
GetAsync().Wait();
https://images.openai.com/static-rsc-4/YD7RXn8k2D-V-1FTdzSYw26OWD3Kpl7pEPSocyYQ0YTdz-5vwPqRkHslXCmgxdNR839NJWheaf6SbMQhLY5q71_P0lSkvlCwsmkfSy6T_mUAaJNDY4H_5hNhvfFim9fpulBY0OC0NuVUUQ03OBjMBC_CUpVFSe5Onx2TQ01Fm8WKJkgd7X4qiBMI3oTqclHn?purpose=fullsize
https://images.openai.com/static-rsc-4/NtTH4P20gRwuNoJwMGicK4vXGfGgptj_J_Bh3JiEZuXiM0H3TYA6aR8NmUSJOJgQch5x9vXQxIq1g4D0n1khSHdwtIpNQU2QNPgxT-0PvvCVi7EtEqBJ_4PjwuYM0IIQFPY64YjZWMBbhpd8Em8ykfna1P8dqiKb4QVrp7tyF7-sRZHO13aZZMykYfeKVQjN?purpose=fullsize
https://images.openai.com/static-rsc-4/rX7T25ywyxylehqPdkJWk6ktsHgqmoxU30bsG-Y4Pi00BblHcBLJMHrR_Ntt6e7MSqOpx95eSwFqXICsreOVa988ibTvb0tsLcnSP3WGmura07vRKbT5Hgg5VnuIxwxCHiiI_kaNYCI6J46hA4deoxuwWyyjNzhDusA8BSYLJq729SN1lC1XOc_qrCkstbu0?purpose=fullsize

7

This causes:

  • thread blocking
  • lost scalability
  • starvation under load

ThreadPool starvation explained

ThreadPool is a shared pool of worker threads used by:

  • ASP.NET requests
  • async continuations
  • background services

When threads are blocked:

  1. new work cannot execute
  2. continuations cannot run
  3. queue grows
  4. latency increases dramatically

Critical insight:

👉 CPU may be low
👉 system still appears “down”


“Stolen threads” problem

C#
await _repo.GetAsync(id);
Thread.Sleep(3000);

or:

C#
await _repo.GetAsync(id);
DoHeavyCpuWork();

Continuation runs on ThreadPool thread.

If you block or do heavy work:

👉 you steal the thread
👉 async becomes useless


Deadlocks in async code

https://images.openai.com/static-rsc-4/mJlYYZGJACITl-tmo8isL3qEmvGpP7D05vZwPsCfcTdKp3zBF3viOsL5SrHFEr9XCbyhDEASvqmluuRLIyAPbGExjG_CAbYhz1xwOg1fE__1WpATIzZ4F7L39f9n3OdQG4i3dSRV4q4ou-pSL8pPHHQkSMvjipWIq9XgyPkxVCMaoEsHSwAJ3kG16eYswX1Z?purpose=fullsize
https://images.openai.com/static-rsc-4/NtTH4P20gRwuNoJwMGicK4vXGfGgptj_J_Bh3JiEZuXiM0H3TYA6aR8NmUSJOJgQch5x9vXQxIq1g4D0n1khSHdwtIpNQU2QNPgxT-0PvvCVi7EtEqBJ_4PjwuYM0IIQFPY64YjZWMBbhpd8Em8ykfna1P8dqiKb4QVrp7tyF7-sRZHO13aZZMykYfeKVQjN?purpose=fullsize
https://images.openai.com/static-rsc-4/wz3NQiD7Na6cL1qh2fYUGz7f2Wr1A9Pnj_2vR8oRSBgjSrJba9yAILH-EPoLlUCws--s-mONfTcNySM4WfVmzsOtt44dalQymue5JhLG3Rjyz9-XLNJxI9TnqVEDToj8ojEZs1IN5c18kEjmCIKZ1mJFM1LrPei5MCGtlmXNXVHNJDUORuk30fBK7UBuEVx0?purpose=fullsize

5

C#
var result = SomeAsync().Result;

Flow:

  1. thread blocks
  2. async captures context
  3. continuation waits for thread
  4. deadlock

Async with HttpClient

C#
public async Task<CustomerDto> GetCustomerAsync(int id, CancellationToken ct)
{
    var response = await _http.GetAsync($"/api/customers/{id}", ct);

    response.EnsureSuccessStatusCode();

    return await response.Content.ReadFromJsonAsync<CustomerDto>(cancellationToken: ct);
}

Async with RabbitMQ

C#
public async Task HandleAsync(Message msg, CancellationToken ct)
{
    await ProcessAsync(msg, ct);
    _channel.BasicAck(msg.DeliveryTag, false);
}

Never acknowledge before async work finishes.


Background services

C#
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await ProcessQueue(stoppingToken);
        await Task.Delay(500, stoppingToken);
    }
}

CancellationToken

C#
await dbContext.Orders.ToListAsync(ct);

Without it:

  • wasted work
  • poor scalability

ConfigureAwait(false)

C#
await SomeAsync().ConfigureAwait(false);

Useful in libraries to avoid context capture.


Async pitfalls checklist

  • ❌ .Result / .Wait
  • ❌ Task.Run for I/O
  • ❌ async void
  • ❌ fire-and-forget
  • ❌ missing cancellation
  • ❌ blocking calls

Conclusion

Async in C# is not about speed.
It is about not blocking threads.

If used incorrectly, it creates:

  • hidden latency
  • deadlocks
  • ThreadPool starvation

If used correctly, it enables:

  • high scalability
  • better resource usage
  • stable systems under load

Source

https://github.com/rafalkukuczka/AsyncAwaitDeepDiveDemo

https://github.com/rafalkukuczka/AsyncAwaitRulesDemo

https://github.com/rafalkukuczka/AsyncAwait

References

https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming

https://www.linkedin.com/posts/aramt87_async-bugs-dont-show-up-in-testing-they-activity-7459914623773839361-3iEE?utm_source=share&utm_medium=member_desktop&rcm=ACoAAABO4P4B1TQKtdVStvHjxgI8amSeFiSleBg (Credits to Aram Tschekrekjian)

More Info

We implement this in real production systems.
If you need help → contact us

Contact