
Let's get this straight from the start:
"async in .net, is NOT about multithreading, it's about not blocking a thread"
When you await something, you're not creating a new thread. You're essentially suspending the current method at that point, returning control to the caller, and later resuming when the awaited task completes
If the compiler could speak, it would say:
Cool, I'll just pause your method here, let the runtime know you're waiting, and come back when there's data
What the Compiler actually generates
Let's say you write this very small async method:
public async Task<int> GetAsync()
{
var data = await GetDataAsync();
return data.Length;
}
The compiler looks at this and it creates a state machine behind the scenes. If you decompile it, you'll see something like this:
[AsyncStateMachine(typeof(<GetAsync>d__0))]
public Task<int> GetAsync()
{
var stateMachine = new <GetAsync>d__0();
stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.state = -1;
stateMachine.outer = this;
stateMachine.builder.Start(ref stateMachine);
return stateMachine.builder.Task;
}
And that <GetAsync>d__0 type?
It’s a struct implementing IAsyncStateMachine. Inside it, you’ll find an some fields like:
private int _state;
private AsyncTaskMethodBuilder<int> _builder;
private TaskAwaiter<string> _awaiter;
When you await, the compiler stores the awaiter, updates the _state and returns control.
When the awaited task finishes, the runtime calls MoveNext() which jumps back to the right place in your method and continues execution
SynchronizationContext and Deadlocks
var result = GetAsync().Result;
Seems very harmless, doens't it? But under the hood, you've just asked the async method to complete synchronously while the UI thread (or old ASP.NET request thread) is sitting there waiting, and the async method itself is trying to resume on that same thread Boom. Deadlock :D
How to fix it? You can just opt out the context capture mechanism when you don't need it:
await GetAsync().ConfigureAwait(false);
This basically tells the runtime: "I don't care where (in which context) you resume me, just do it asap"
It's a must use for Libraries
Funny enough, this is also why async void doesn’t immediately explode in UI apps.. exceptions bubble up to the current SynchronizationContext (like WPF or WinForms), where the framework can handle them for you. But in .NET Core or ASP.NET Core, there’s no such context by default, so you must return a Task, otherwise, the exception is lost into the void (pun intended) instead of being captured in the task
ValueTask
Task is absolutely great, BUT, every time you return a Task, you allocate an object on the heap
If your async method often completes synchronously (e.g. cache), that's just wasted overhead.
That's why we have ValueTask, a struct that can wrap either:
- a result value
- a real
Task
public async ValueTask<int> GetCachedDataAsync()
{
if (_cache.TryGetValue("key", out var result))
{
return result; // synchronous fast path, no allocation
}
return await LoadFromDatabaseAsync(); // async path
}
Just be aware, if you await a ValueTask multiple times or call .Result more than once, you'll get a runtime exception
They're one-shot. Use them when you really care about allocations, otherwise stick with Task
Task vs Thread
"But I Thought Async Was Parallel!"
Nope
await Task.Delay(1000);
Console.WriteLine("done");
This doesn't create a new thread, it just sets a timer. The current thread is released back to the thread pool until the delay completes
If you really want parallel CPU-bound work, you need to explicilty offload:
await Task.Run(() => compute());
Task.Run schedules that delegate on the thread pool, so yes, that's where multithreading enters the picture
Best Practies
1. Never block on async code
Avoid .Result, .Wait(), .GetAwaiter().GetResult()
Use await all the way up!
2. Prefer ValueTask only for hot paths If your method completes synchronously 90% of the time, ValueTask saves you allocations. Otherwise, it’s complexity for nothing
3. Use ConfigureAwait(false) in libraries
You don’t want your async library logic tied to some caller’s context
4. Watch out for fire-and-forget
If you just GetAsync(); without await, you lose exception handling and control
If you must fire and forget, wrap it:
_ = Task.Run(async () =>
{
try {
await GetAsync();
}
catch (Exception ex) {
Log(ex);
}
});
5. Async all the way down
Don’t mix sync and async layers. That’s how deadlocks and thread starvation happen
What Happens at Runtime (Bonus)
- You call an async method -> returns immediately with a
Task(a promise of future result) - Your method runs until the first await
- It returns to the caller, the continuation is registered with the awaited task
- When the awaited task completes, the runtime invokes the continuation
- The state machine resumes, executes the rest, sets the task's result and completes