blogs

[02]

Understanding async/await in C#: The Control Flow Behind the Keyword

I used to think I understood async/await because I knew the syntax.

var result = await DoSomethingAsync();

You call the async method. You put await in front of it. The code waits. Then the next line runs. Simple enough.

But the more I looked at it, the more this felt weird.

A synchronous call waits for its result:

var water = BoilWater();
Pour(water);

Most async code also seems to wait for its result:

var water = await BoilWaterAsync();
Pour(water);

In both examples, Pour(water) cannot run until water exists.

So what did async actually change?

The answer is not that async means "fire-and-forget." It does not.

The better model is:

calling an async method, awaiting its returned task, and the task completing are three different events.

Most of the confusion comes from collapsing those into one.

Once I separated them, a lot of the weird behavior started to make sense.


the naive way we use async

Most of us learn async by doing this:

var user = await GetUserAsync();
var orders = await GetOrdersAsync();
var recommendations = await GetRecommendationsAsync();

This looks normal. It is also often fine. But it hides an important choice.

This code does not just say:

i need these three things

It says:

start getting the user
wait for the user
then start getting the orders
wait for the orders
then start getting recommendations
wait for recommendations

If each line depends on the previous one, that is correct. But if they are independent, we accidentally serialized the work.

A better shape is:

Task<User> userTask = GetUserAsync();
Task<Order[]> ordersTask = GetOrdersAsync();
Task<Recommendation[]> recommendationTask = GetRecommendationsAsync();

await Task.WhenAll(userTask, ordersTask, recommendationTask);

User user = await userTask;
Order[] orders = await ordersTask;
Recommendation[] recommendations = await recommendationTask;

Now the code says:

start everything
wait at the point where the results are needed
then use them

That was the first async/await habit I had to unlearn:

await is not just syntax you put in front of every async call. it marks a dependency point.

Immediate await is not wrong. Sometimes the next line genuinely cannot do anything useful without the result.

But immediate await has a meaning:

this method has no useful independent work to do before this result arrives.

If that is not true, you may be giving up concurrency without noticing.


the kettle example

The classic way to think about this is making tea.

A synchronous version:

Water water = BoilWater();
TakeCupsOut();
PutTeaInCups();
Pour(water);

This works, but the order is bad.

Once the kettle starts boiling, we do not need to stare at it. The kettle can do its work while we do ours.

A naive async version might still do this:

Water water = await BoilWaterAsync();
TakeCupsOut();
PutTeaInCups();
Pour(water);

This is async, but the control flow is still:

boil water
then take cups out
then put tea in cups
then pour

A better version is:

Task<Water> boilingWater = BoilWaterAsync();

TakeCupsOut();
PutTeaInCups();

Water water = await boilingWater;
Pour(water);

This matches the real dependency. Start the kettle. Do the work that does not need the water. Await only when the water is actually needed.

That is the practical shape of good async code:

start the operation
do independent work
await at the dependency point

The important split is this:

Task<Water> boilingWater = BoilWaterAsync();

starts the operation.

Water water = await boilingWater;

connects the current method to the result.

Those are not the same moment.


calling starts the async method

This is the quirk that surprised me most.

static async Task DemoAsync()
{
    Console.WriteLine("A");
    await Task.Delay(1000);
    Console.WriteLine("B");
}

Task task = DemoAsync();
Console.WriteLine("C");
await task;

The output is:

A
C
B

DemoAsync() starts running immediately. It does not wait until await task.

An async method runs synchronously until it either finishes or reaches an incomplete await.

So the flow is:

call DemoAsync
print A
hit incomplete await
return Task to caller
caller prints C
delay completes
DemoAsync resumes
print B

This is why "await starts async work" is the wrong model.

The call starts the method. The returned Task represents its eventual completion. The await tells the caller to wait for that completion.


inner await vs outer await

This one also looks weird at first:

static async Task WriteLineAsync(string text)
{
    Console.WriteLine("started");
    await Task.Delay(500);
    Console.WriteLine($"completed: {text}");
}

WriteLineAsync("hello");

This might print:

started

and then the program exits before:

completed: hello

The method did start. It did hit its own await. It did arrange to continue later.

But the caller ignored the returned Task.

This:

WriteLineAsync("hello");

is basically:

Task task = WriteLineAsync("hello");
// ignored

The internal await belongs to WriteLineAsync. It controls where WriteLineAsync pauses and resumes. It does not force the outer caller to wait for WriteLineAsync to finish.

For that, the caller needs:

await WriteLineAsync("hello");

So these two awaits are doing different jobs.

Inside the method:

await Task.Delay(500);

means:

this method cannot continue until the delay completes

At the call site:

await WriteLineAsync("hello");

means:

this caller cannot continue until WriteLineAsync completes

A useful way to phrase it:

an internal await structures the callee. an external await connects the caller to the callee's completion.

If you drop the returned task, you drop that connection. You also lose the normal way to observe completion, exceptions, and cancellation.


await may not actually pause

Another quirk: await does not always suspend the method.

Consider:

Task<string> download = httpClient.GetStringAsync("https://example.com");

DoIndependentWork();

string page = await download;

There are two possible paths.

If the download is still incomplete when we reach the await, the method suspends and resumes later.

But if the download already completed while DoIndependentWork() was running, the method just continues. There is nothing to pause for.

So this is not quite right:

await always yields control

The better model is:

if the task is complete: continue synchronously
if the task is incomplete: suspend and resume later

This is also why code after await may sometimes resume on another thread, and sometimes not. If there was no suspension, there was no continuation to schedule somewhere else.


the state machine underneath

At some point the obvious question is:

how can a method stop halfway through and later continue from the same line?

Normal methods do not work like that. A normal method lives on the stack. Its local variables live in its stack frame. When the method returns, that stack frame is gone.

Async methods need a different shape. C# gives them one by rewriting them into a state machine.

A simplified version of await looks like this:

if (!awaiter.IsCompleted)
{
    state = nextState;
    storedAwaiter = awaiter;
    RegisterContinuation(MoveNext);
    return;
}

result = awaiter.GetResult();

That is the basic idea. If the task is complete, get the result and keep going. If it is incomplete, store enough state to continue later, register the continuation, and return control to the caller.

A useful mental model:

async creates the state machine
Task is the bridge to the caller
await is a checkpoint in the state machine

The source code still reads top-to-bottom, but under the hood the method has been split into resumable pieces.

This:

var a = await AAsync();
var b = await BAsync();

return Combine(a, b);

has multiple possible resume points:

start
resume after AAsync
resume after BAsync
complete

That is the control-flow trick behind the keyword.


where the thread went

When an async method suspends, the current thread does not need to sit there waiting.

For I/O operations like HTTP requests, database calls, file reads, or timers, most of the waiting happens outside active C# execution. The operating system, network stack, database server, or timer infrastructure is doing the waiting.

When the operation completes, the continuation gets scheduled. In many .NET contexts, a ThreadPool worker eventually resumes the method by running the next part of the state machine.

This is the useful version of:

async frees the thread

It does not mean synchronous waiting was burning CPU the whole time. A blocked thread is not necessarily using CPU.

The cost is that a .NET worker thread remains occupied from the runtime's perspective. It cannot handle another request, run another continuation, or process another work item.

Async I/O lets the runtime represent the wait as a continuation instead of a blocked worker.

That is why async is especially useful in UI apps and servers.

In a UI app, it helps keep the main thread responsive.

In a server, it helps a smaller number of worker threads handle many operations that spend most of their time waiting on external systems.


async i/o is not cpu parallelism

Another thing that confused me: both of these use Task.

await httpClient.GetAsync(url);

and:

await Task.Run(() => CalculatePrimes());

But they are not the same kind of work.

The first is async I/O. Most of the time is spent waiting on the network, a socket, a remote server, or some external system.

The second is CPU work scheduled onto a ThreadPool worker.

Both return tasks. Both can be awaited. But the mechanics are different.

async I/O: represent external waiting without holding a worker thread
Task.Run: use a worker thread to do CPU work

So async/await does not magically make CPU-heavy code faster. It mostly helps when the program spends time waiting on I/O.


small rules that make more sense now

Once the control flow is clearer, some common async rules stop feeling arbitrary.

Avoid casually using:

task.Result
task.Wait()

Those block the current thread. In UI apps, this can freeze the interface or even deadlock if the continuation needs to get back onto the same thread.

Avoid async void except for event handlers.

An async Task gives the caller something to await. It carries completion, exceptions, and cancellation.

An async void gives the caller no task. The caller cannot compose with it or observe it normally.

Also, do not add async/await when all you are doing is returning another task.

This:

public async Task<string> GetAsync()
{
    return await client.GetStringAsync(url);
}

can often just be:

public Task<string> GetAsync()
{
    return client.GetStringAsync(url);
}

The first version creates an async state machine. The second returns the existing task.

There are exceptions, especially if you need try/catch/finally behavior around the awaited operation.

But the general point is:

await has meaning. Use it when the current method has work to do after the result arrives.


the model i use now

The cleanest model I have now:

calling an async method starts it
the returned Task represents eventual completion
awaiting connects the caller to that completion
await may suspend only if the task is incomplete
if it suspends, the state machine stores the method state
when the operation completes, the continuation resumes
for I/O, the wait usually does not need a dedicated .NET worker thread
for CPU work, you still need actual CPU time on an actual thread

So async is not "code that does not wait."

Async code often waits logically. The difference is where the wait lives.

Instead of being represented only as a blocked thread, the wait can be represented as a task, a continuation, and a resumable state machine.

That distinction is where most of the feature's power comes from.