What happens if you await something that never finishes? Does the method sit in memory until the heat death of the universe (or all the other possible scenarios)? Does it leak? Let's find out, and then do some things you should definitely 100% legit do in production.
## Setup
We need a method that waits forever, and a way to see whether it's still alive. A TaskCompletionSource that nobody ever completes handles the first part:
static async Task AwaitForever(Tracker tracker)
{
var tcs = new TaskCompletionSource();
try
{
await tcs.Task; // spoiler: nope
Console.WriteLine($"Resumed {tracker.Name}");
}
finally
{
Console.WriteLine($"finally for {tracker.Name}");
}
}
For the second part, Tracker is a small class with a finalizer that prints when the GC eats it:
sealed class Tracker(string name)
{
public string Name { get; } = name;
~Tracker() => Console.WriteLine($"Tracker '{Name}' was collected");
}
I am aware I wrote a blog post demonizing finalizers - but here it is 100% justified and we are in that 0.1% case I was talking about!
Now we fire and forget, and force the GC to collect:
_ = AwaitForever(new Tracker("fire-and-forget"));
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Again: Siper justified stuff
This gives ous:
Tracker 'fire-and-forget' was collected
Done.
Hmm- The method was collected, but no finally was called! Also using would not have helped here, as it is just some sprinkled magic around calling finally. But finally comes after the return statement, which would never occurred here! (Not as in literal return statement, more the stackframe pointer moves).
## Who holds whom?
A method that awaits isn't "waiting" anywhere. There's no thread blocked on it. When the compiler builds the async state machine, await means: "Hey task, here's my continuation. Call me when you're done." Then the method returns.
So the reference graph looks like this:
tcs -> tcs.Task -> continuation -> state machine -> hoisted locals (tracker)
The arrows point one way only. The state machine knows about the task, and the task knows about the continuation. But nothing outside points into this little island: no stack frame, no static, no thread. To the GC, that's garbage like any other. The whole chain gets collected, and since collecting something isn't the same as returning from it, the finally never runs.
Do proper engineering now - partially#
Let's keep the returned Task this time:
var task = AwaitForever(new Tracker("kept task"));
Collect();
Console.WriteLine(task.Status); // WaitingForActivation
GC.KeepAlive(task);
Nothing gets collected. In modern .NET the returned Task is the box that holds the state machine. Hold the task and you hold the whole method, including every local that's still needed after the await.
Another fun one, if you wanna leak a bit memory in the same style:
static class Gate
{
public static readonly SemaphoreSlim Semaphore = new(0); // nobody ever releases
}
static async Task ForeverSemaphore()
{
var buffer = new byte[100];
await Gate.Semaphore.WaitAsync();
GC.KeepAlive(buffer);
}
The semaphore keeps a list of waiters, the waiters point to the continuations, and the continuations point to the state machines. If you call that a few times (as in million), you can compare the same with tcs:
tcs: 0 MB still alive after 1,000,000 calls
semaphore: 305 MB still alive after 1,000,000 calls
Same "idea" but a little different outcome.
Let's wait forever or 49.7 days!#
I wrote some time ago "Task.Delay fails if you wait longer than 49.7 days". So let's just wait "reasonable":
await Task.Delay(Timeout.InfiniteTimeSpan); // A
await Task.Delay(TimeSpan.FromDays(1)); // B
Guess the output
A: Tracker 'infinite' was collected
B: (silence)
While A gets collected B sits there for a while. If you wait Timeout.InfiniteTimeSpan then .NET is a bit sneaky! Task.Delay is using a Timer (as in one shared timer across all your calls), but if you are unreasonable and wanna wait forever, .NET doesn't care and doesn't "enqueue" anything to be awaited for. So it kind of behaves like a sync noop with a little bit overhead 😉
## Top N things you should do in production: Necromancer stuff
We know the state machine is only reachable through the TaskCompletionSource. We also know finalizers run after something has become unreachable. What if a finalizer completes the TCS?
sealed class Necromancer(TaskCompletionSource tcs)
{
~Necromancer() => tcs.SetResult();
}
static async Task Zombie()
{
var tcs = new TaskCompletionSource();
var necromancer = new Necromancer(tcs);
try
{
await tcs.Task;
Console.WriteLine("Braaaains!");
}
finally
{
GC.KeepAlive(necromancer);
}
}
Now we do complete the task eventually and therefore we will run into the a) the Console WriteLine and b) the finally block!
.NET 11 runtime async!#
While I was talking about state machine, that is only true mainly until .NET 10. With .NET 11 you can opt-in into runtime async.
<Features>$(Features);runtime-async=on</Features>
So the runtime does the suspension and recapturing of the context into the continuation. If you try to same stuff as above. Well nothing changes at all. From an abstract point of view, the behaviour is the same. It did not change so much. More: The implementation on how to achieve that is different (dare I say better/more clever?).

