Repository navigation
Description
Description
With runtime async, an object can stay reachable through a reused continuation after the local that held it is dead. Roslyn-generated code releases it in the same cases: it clears the hoisted field at the end of the scope, and a non-inlined helper's state goes away when the helper returns.
using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
class Program
{
static WeakReference s_buffer;
static void Main()
{
Console.WriteLine(Run(ScopedAsync));
Console.WriteLine(Run(CallerAsync));
}
static bool Run(Func<Task, Task, Task> method)
{
var first = new TaskCompletionSource();
var second = new TaskCompletionSource();
Task task = method(first.Task, second.Task);
first.SetResult();
GC.Collect();
bool alive = s_buffer.IsAlive;
second.SetResult();
task.Wait();
return alive;
}
static async Task ScopedAsync(Task first, Task second)
{
{
var buffer = new byte[1024];
s_buffer = new WeakReference(buffer);
await first;
GC.KeepAlive(buffer);
}
await second;
}
static async Task CallerAsync(Task first, Task second)
{
await HelperAsync(first);
await second;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static async ValueTask HelperAsync(Task wait)
{
var buffer = new byte[1024];
s_buffer = new WeakReference(buffer);
await wait;
GC.KeepAlive(buffer);
}
}Configuration
dotnet/runtime 8dd9158, clr+libs Release, Windows x64, DOTNET_TieredCompilation=0. Compiler 5.14.0-1.26528.120.
Regression?
Not within runtime async. With runtime-async=off, both buffers are collected.
Data
Is the buffer still reachable during the second wait:
| Method | runtime-async=on |
runtime-async=off |
|---|---|---|
ScopedAsync |
True | False |
CallerAsync |
True | False |
buffer = null; instead of the inner scope gives the same result. AggressiveInlining only makes the inlining deterministic; the JIT inlines such helpers on its own too. If first is already completed, nothing is saved and the buffer is collected.
Analysis
At a suspension, the JIT doesn't store locals that are dead there (async.cpp). Since #125556 reuses the continuation, those slots still hold the values saved at an earlier suspension.
#129157 found Roslyn-style clearing at the end of a scope too costly. Would a narrower fix be acceptable: at each suspension, store null to the GC-ref slots of locals that are saved at another suspension but dead at this one? Or is NoInlining the expected workaround for helpers?
More detail: Reusing a continuation across awaits, Async helpers and inlining.
Activity
dotnet-policy-service commentedon Nov 2, 2026
Tagging subscribers to this area: @dotnet/area-system-threading-tasks
See info in area-owners.md if you want to be subscribed.
jakobbotsch commentedon Nov 2, 2026
This is not going to give any strong guarantees. The precision of the liveness analysis depends on a lot of factors, like whether we are optimizing and the shape of the code that the user wrote. If we were to try to give guarantees about object lifetimes then we would want to first and foremost specify those guarantees in a way that they can be relied on.
Instead, the current model for async methods is the same as what it has always been for non-async methods: the JIT may or may not extend the lifetime of objects, and introducing a
NoInlininginner method is the expected approach to explicitly control the lifetime of objects.With all that said, if eager clearing could be shown to not be costly (in terms of execution cost and code size) and if we start to see significant object retention issues that are hindering migrations, then we may reconsider whether something could be done. However, even so it will be very difficult to match Roslyn's behavior for async1 since lexical scopes do not exist at the IL level.
dotnet-policy-service commentedon Nov 2, 2026
Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
See info in area-owners.md if you want to be subscribed.