Skip to content

Conversation

jakobbotsch
Copy link
Member

@jakobbotsch jakobbotsch commented Oct 3, 2025

Runtime async callable thunks were using TaskAwaiter directly, but that has the normal await semantics which will either post continuations to the captured synchronization context or to the thread pool. This introduces an observable behavior change with async1 where sometimes even configured awaits will end up posting back to a captured synchronization context.

For example, consider an example like:

private static async Task Foo()
{
    SynchronizationContext.SetSynchronizationContext(new TrackingSynchronizationContext());
    await Task.Delay(1000).ConfigureAwait(false);
}

Before this change the runtime async call to Task.Delay creates a runtime async callback thunk that roughly looks like

Task DelayThunk(int time)
{
    TaskAwaiter awaiter = Task.Delay(time).GetAwaiter();
    if (!awaiter.IsCompleted)
        AsyncHelpers.UnsafeAwaitAwaiter(awaiter);
    awaiter.GetResult();
}

however, when this thunk is called we end up capturing the TrackingSynchronizationContext inside the thunk. Eventually that results in the DelayThunk continuation being posted back to that synchronization context, followed by Foo's continuation being moved back to the thread pool by the runtime async infrastructure.

This PR fixes this and makes the thunks transparent in context behavior. At the same time it also optimizes the runtime async -> async1 path to be more efficient in the suspension case: the async1 task now directly invokes the runtime async infrastructure as its continuation, instead of going through multiple layers of indirection.

I also fixed a bug where we could end up invoking the wrong variant of OnCompleted for non-unsafe notifiers if they implemented both INotifyCompletion and ICriticalNotifyCompletion.

I have also renamed ThunkTask -> RuntimeAsyncTask.

Fix #119621 (likely...)

Runtime async callable thunks were using `TaskAwaiter` directly, but
that has the normal await semantics which will either post continuations
to the captured synchronization context or to the thread pool. This
introduces an observable behavior change with async1 where sometimes
even configured awaits will end up posting back to a captured
synchronization context.

For example, consider an example like:
```csharp
private static async Task Foo()
{
    SynchronizationContext.SetSynchronizationContext(new TrackingSynchronizationContext());
    await Task.Delay(1000).ConfigureAwait(false);
}
```csharp

Before this change the runtime async call to `Task.Delay` creates a
runtime async callback thunk that roughly looks like
```csharp
Task DelayThunk(int time)
{
    TaskAwaiter awaiter = Task.Delay(time).GetAwaiter();
    if (!await.IsCompleted)
        AsyncHelpers.UnsafeAwaiterAwaiter(awaiter);
    awaiter.GetResult();
}
```
however, when this thunk is called we end up posting back to
`TrackingSynchronizationContext`, before the continuation for `Foo` then
must move its continuation back to the thread pool.

This PR fixes this and makes the thunks transparent in context behavior.
At the same time it also optimizes the runtime async -> async1 path to
be more efficient in the suspension case: the async1 task now directly
invokes the runtime async infrastructure as its continuion, instead of
going through multiple layers of indirection.
@github-actions github-actions bot added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Oct 3, 2025
@VSadov
Copy link
Member

VSadov commented Oct 6, 2025

I think this approach will work.

I'd like to see ValueTask support. There could be some trickiness there because of IValueTaskSource and stuff related to that.
The docs recommend using .AsTask to do anything complex with ValueTask.

I think we might want to special case "IsCompleted" scenario for valuetask - to not make a Task when none is needed, but the rest can be done via .AsTask

if (valuetask.IsCompleted)
    return valuetask.Result;

Task t = valuetask.AsTask;

< .. same code as for handling Task .. > 

The most common case is ValueTask wraps a value.
Then it may wrap a Task (and AsTask for that is trivial).
Then it may wrap an IValueTaskSource, and that is more complex. Perhaps .AsTask is less likely to run into some quirk or incompatible behavior.

@jakobbotsch
Copy link
Member Author

I think this approach will work.

I'd like to see ValueTask support. There could be some trickiness there because of IValueTaskSource and stuff related to that. The docs recommend using .AsTask to do anything complex with ValueTask.

I think we might want to special case "IsCompleted" scenario for valuetask - to not make a Task when none is needed, but the rest can be done via .AsTask

if (valuetask.IsCompleted)
    return valuetask.Result;

Task t = valuetask.AsTask;

< .. same code as for handling Task .. > 

The most common case is ValueTask wraps a value. Then it may wrap a Task (and AsTask for that is trivial). Then it may wrap an IValueTaskSource, and that is more complex. Perhaps .AsTask is less likely to run into some quirk or incompatible behavior.

We can definitely do it via AsTask, though I do worry that we defeat (some of) the purpose of the ValueTask optimization then. However I think it will be good enough for now -- optimizing that is similar in spirit to #119842, in that the Continuation being created inside the thunk already has all the necessary data. We just need to find a way to generically access it and call OnCompleted. But that would probably need another stub or generic instantiation.

@jakobbotsch
Copy link
Member Author

I implemented the ValueTask part now @VSadov. Do you mind testing this on your libraries tests PR?

@VSadov
Copy link
Member

VSadov commented Oct 7, 2025

I implemented the ValueTask part now @VSadov. Do you mind testing this on your libraries tests PR?

i've pushed these changes into that PR
#119432

note that there could be still some failures in IO.Pipelines tests. I think that is a separate issue.

@VSadov
Copy link
Member

VSadov commented Oct 8, 2025

I see the results as expected in the Libraries PR + these changes. Nearly everything passes except timeouts in IO.Pipelines, which is a separate issue.

Copy link
Member

@VSadov VSadov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@jakobbotsch jakobbotsch marked this pull request as ready for review October 9, 2025 10:40
@Copilot Copilot AI review requested due to automatic review settings October 9, 2025 10:40
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR fixes runtime async callable thunks to be transparent with respect to synchronization context behavior, addressing an issue where async1 methods using ConfigureAwait(false) would still post continuations back to captured synchronization contexts.

Key changes include:

  • Replacing TaskAwaiter with direct Task operations to avoid capturing synchronization context
  • Optimizing the runtime async to async1 path for better suspension handling
  • Fixing a bug with INotifyCompletion vs ICriticalNotifyCompletion variant selection

Reviewed Changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/tests/async/valuetask/valuetask.cs Adds tests for runtime async callable thunks with ValueTask
src/tests/async/synchronization-context/synchronization-context.cs Adds test to verify no sync context capture in runtime callable thunks
src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs Adds TryAddCompletionAction method for optimized continuation handling
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.cs Updates to use CriticalNotifier instead of Notifier for proper handling
src/coreclr/vm/method.hpp Removes unused method declaration
src/coreclr/vm/metasig.h Simplifies metasig definitions by removing awaiter-specific signatures
src/coreclr/vm/corelib.h Updates method definitions to use direct Task/ValueTask operations instead of awaiters
src/coreclr/vm/asyncthunks.cpp Major refactoring to generate IL that uses transparent Task operations
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs Renames ThunkTask to RuntimeAsyncTask and implements direct task completion handling

@jakobbotsch jakobbotsch merged commit adb1091 into dotnet:main Oct 9, 2025
147 checks passed
@jakobbotsch jakobbotsch deleted the transparent-runtime-async-callable-thunks branch October 9, 2025 11:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners runtime-async

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RuntimeAsync] A few tests fail with runtime async due to unexpected use of sync context

2 participants