Skip to content

Runtime async: reused continuations keeps references to locals that are dead at the current await #136822

Open

Description

@asylkhan-azat

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

  1. dotnet-policy-service commented on Nov 2, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-system-threading-tasks
    See info in area-owners.md if you want to be subscribed.

  2. jakobbotsch commented on Nov 2, 2026

    @jakobbotsch
    Member

    #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?

    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 NoInlining inner 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.

  3. added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    questionAnswer questions and provide assistance, not an issue with source code or documentation.
    and removed
    untriagedNew issue has not been triaged by the area owner
    on Nov 2, 2026
  4. dotnet-policy-service commented on Nov 2, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
    See info in area-owners.md if you want to be subscribed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIquestionAnswer questions and provide assistance, not an issue with source code or documentation.tenet-performancePerformance related issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions