Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Sources;

namespace System.Runtime.CompilerServices
{
Expand Down Expand Up @@ -491,6 +492,49 @@ public static void HandleSuspended<T, TOps>(T task) where T : Task, ITaskComplet
}
else if (calledTask != null)
{
if (calledTask is IValueTaskAsTask vtTask)
Copy link
Member Author

@VSadov VSadov Oct 14, 2025

Choose a reason for hiding this comment

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

We may have a more efficient solution that skips allocation of ValueTaskSourceAsTask entirely by stashing an unwrapped ValueTaskSource and calling ValueTaskSourceAsTask equivalent code from the continuation dispatcher.

At this point we are looking for correctness, so I went with a smaller change. Optimizations can come later, if this scenario is common/interesting enough.

Copy link
Member

Choose a reason for hiding this comment

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

IMO if we are going to end up with another path here, then we should just make that change now and get the correctness as a side effect. It will also look more obviously correct since it will match exactly what ValueTaskAwaiter.OnCompleted does. That means less work for me trying to understand what the issue here was :-)

Copy link
Member Author

@VSadov VSadov Oct 14, 2025

Choose a reason for hiding this comment

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

I am not sure optimizing this will make it easier to follow. The key of the change is unsetting the continuation flags of the awaiting continuation and passing equivalent flag to the ValueTaskSourceAsTask that we are waiting on. That part of the fix will need to stay even if we optimize away allocating a ValueTaskSourceAsTask.

Skipping allocation of ValueTaskSourceAsTask would result in calledTask becoming an object or adding yet another field in the awaitState.
And for non-source ValueTask, we would still want .AsTask.

Copy link
Member Author

@VSadov VSadov Oct 14, 2025

Choose a reason for hiding this comment

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

I think we need to agree that this is a correct fix before optimizing further.

I think it is a correct fix, but maybe I miss some nuance or perhaps there is a better way to achieve the same effect.
Like - the change defers configuring the source until we are in HandleSuspended. That bothers be a bit, but I think that is ok and there is no way around that. At the time of AsTask we do not know if caller/awaiter up the stack is configured or not.

(NOTE: A source-wrapping ValueTask cannot come from an async method, so .AsTask for it will be always called from a context-transparent thunk. It would be the one level up frame that did the actual await)

Copy link
Member

Choose a reason for hiding this comment

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

I think I understand. Unlike Task there is no way to do a transparent await for ValueTaskSource since the configuration gets passed to 3rd party code. So we truly do need to get the configuration value from the caller.
I'll look more deeply tomorrow at this.

Copy link
Member Author

Choose a reason for hiding this comment

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

We might want a very low level compatibility test for this -- something that just verifies that a custom IValueTaskSource sees the expected flags passed to its OnCompleted.

Yes, it would be useful. I'll add such test.

Copy link
Member Author

@VSadov VSadov Oct 14, 2025

Choose a reason for hiding this comment

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

I think I understand. Unlike Task there is no way to do a transparent await for ValueTaskSource since the configuration gets passed to 3rd party code.

Not only that. Also:

  • if the 3rd party wants to run continuations on a default context, then the awaiting continuation should run on that (even though itself has captured nondefault context).

  • There is no opposite case though. If the await had ConfigureAwait(false) the continuation runs on default context.
    That is, at least, expected in this test:

    public async Task DefaultReaderSchedulerIgnoresSyncContextIfConfigureAwaitFalse()

  • an opposite scenario is also possible, although uncommon (Pipelines do not do that).
    That is when await says false but the source still runs continuations on captured context.

  • An additional nuance - if the task has completed by the time we try to add a continuation, then it has no say in how continuation runs and continuation runs on what await has captured.
    (it seems there are possibilities for races here, but it might not be a big deal in real scenarios)

Copy link
Member

@jakobbotsch jakobbotsch Oct 14, 2025

Choose a reason for hiding this comment

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

The runtime async infrastructures tries to implement the same semantics as Task.UnsafeSetContinuationForAwait does, in terms of resumption behavior. But these are not necessarily the behaviors matched by custom implementations of IValueTaskSource. They can be as broken as they like since everything is left up to user controlled code for them.

I guess there is a question of how far we need to go with replicating asyncv1 behavior for "broken" implementations of IValueTaskSource that do not conform to the standard resumption behavior of Task. If we go for full compatibility it is not clear to me yet how this affects our ability to optimize calls to ValueTask returning methods, for example.

Copy link
Member Author

Choose a reason for hiding this comment

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

There is no opposite case though.

That is not true. The opposite is also possible. That is when the source captures the scheduling context and posts to it even when await was configured to false.

It is just not common behavior to do so, but if source does that, it wins over what await wants.

It is also possible for the source to pick whatever random context and run continuations on that. I think we can ignore such possibility as a broken implementation. The only choice should be between the scheduling context and the default.

Copy link
Member Author

@VSadov VSadov Oct 15, 2025

Choose a reason for hiding this comment

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

They can be as broken as they like since everything is left up to user controlled code for them.

I think we just need to match the part where if we see incomplete ValueTask that wraps a source we should let the source to run the continuation callback in whatever way it wants (basically ignore whether the await was configured), but we also need to tell the source what we had on the await as there is a way to tell and the source might consider that.

I see only two uses of this part of API in the Libraries:

  • the source is used for other purposes than dealing with the context - i.e. for pooling and reusing awaitable resources. In such case the source cannot be configured and it just goes with what it gets passed based on the await config. This is a Parallel.ForEach over AsyncEnumerable scenario.
  • the source is used specifically for the purposes of overriding callback calling strategy (like the one in IO.Pipe). In such case it picks the most relaxed way of invoking between its own config and what comes from the await config.

I could imagine a case where source only considers its own config.

Supporting these three cases should be sufficiently compatible.

{
if (!vtTask.IsConfigured)
{
// ValueTaskSource can be configured to use scheduling context or to ignore it.
// The awaiter must inform the source whether it wants to continue on a context,
// but the source may decide to ignore the suggestion. Since the behavior of
// the source takes precedence, we clear the context flags from the awaiting
// continuation (so it will run transparently on what source decides) and tell
// the source that awaiting frame prefers to continue on a context.
// The reason why we do it here is because the continuation chain builds from the
// innermost frame out and when the leaf thunk links the head continuation,
// it does not know if the caller wants to continue in a context. Thus the thunk
// creates an "unconfigured" IValueTaskAsTask and we configure it here.
ValueTaskSourceOnCompletedFlags configFlags = ValueTaskSourceOnCompletedFlags.None;
CorInfoContinuationFlags continuationFlags = headContinuation.Next!.Flags;

const CorInfoContinuationFlags continueOnContextFlags =
CorInfoContinuationFlags.CORINFO_CONTINUATION_CONTINUE_ON_CAPTURED_SYNCHRONIZATION_CONTEXT |
CorInfoContinuationFlags.CORINFO_CONTINUATION_CONTINUE_ON_CAPTURED_TASK_SCHEDULER;

if ((continuationFlags & continueOnContextFlags) != 0)
{
// effectively move context flags from headContinuation to the source config.
configFlags |= ValueTaskSourceOnCompletedFlags.UseSchedulingContext;
headContinuation.Next!.Flags &= ~continueOnContextFlags;
}

vtTask.Configure(configFlags);

if (!calledTask.TryAddCompletionAction(task))
{
// calledTask has already completed and we need to schedule
// our code for execution ourselves.
// Restore the continuation flags before doing that.
headContinuation.Next!.Flags = continuationFlags;
Copy link
Member Author

Choose a reason for hiding this comment

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

This is subtle. If the ValueTask has already completed, it missed its chance to decide on how continuations run. In such case it is up to the awaiter.

There are tests sensitive to this.

Copy link
Member Author

@VSadov VSadov Oct 15, 2025

Choose a reason for hiding this comment

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

If we do not do .AsTask on the ValueTaskSource path and stash the actual IValueTaskSource+token, and call OnCompleted on the source directly, we might not need to deal with the case of awaitee completing before adding a continuation callback. And it might be closer to what happens in async1 in terms of continuation inlining vs. running on threadpool.

The complications with using IValueTaskSource directly is that there is also IValueTaskSource<TResult>, which is not derived from IValueTaskSource.
OnCompleted does not use TResult, but I might still need to know TResult at dispatch time in order to call it.

ThreadPool.UnsafeQueueUserWorkItemInternal(task, preferLocal: true);
}

return;
}
}

// Runtime async callable wrapper for task returning
// method. This implements the context transparent
// forwarding and makes these wrappers minimal cost.
Expand Down
2 changes: 1 addition & 1 deletion src/coreclr/inc/clrconfigvalues.h
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ RETAIL_CONFIG_DWORD_INFO(EXTERNAL_EnableRiscV64Zbb, W("EnableRiscV64
#endif

// Runtime-async
RETAIL_CONFIG_DWORD_INFO(UNSUPPORTED_RuntimeAsync, W("RuntimeAsync"), 0, "Enables runtime async method support")
RETAIL_CONFIG_DWORD_INFO(UNSUPPORTED_RuntimeAsync, W("RuntimeAsync"), 1, "Enables runtime async method support")

///
/// Uncategorized
Expand Down
4 changes: 2 additions & 2 deletions src/coreclr/vm/asyncthunks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ void MethodDesc::EmitAsyncMethodThunk(MethodDesc* pAsyncOtherVariant, MetaSig& m

MethodDesc* pMDValueTaskIsCompleted = CoreLibBinder::GetMethod(METHOD__VALUETASK__GET_ISCOMPLETED);
MethodDesc* pMDCompletionResult = CoreLibBinder::GetMethod(METHOD__VALUETASK__THROW_IF_COMPLETED_UNSUCCESSFULLY);
MethodDesc* pMDAsTask = CoreLibBinder::GetMethod(METHOD__VALUETASK__AS_TASK);
MethodDesc* pMDAsTask = CoreLibBinder::GetMethod(METHOD__VALUETASK__AS_UNCONFIGURED_TASK);

isCompletedToken = pCode->GetToken(pMDValueTaskIsCompleted);
completionResultToken = pCode->GetToken(pMDCompletionResult);
Expand All @@ -604,7 +604,7 @@ void MethodDesc::EmitAsyncMethodThunk(MethodDesc* pAsyncOtherVariant, MetaSig& m

MethodDesc* pMDValueTaskIsCompleted = CoreLibBinder::GetMethod(METHOD__VALUETASK_1__GET_ISCOMPLETED);
MethodDesc* pMDCompletionResult = CoreLibBinder::GetMethod(METHOD__VALUETASK_1__GET_RESULT);
MethodDesc* pMDAsTask = CoreLibBinder::GetMethod(METHOD__VALUETASK_1__AS_TASK);
MethodDesc* pMDAsTask = CoreLibBinder::GetMethod(METHOD__VALUETASK_1__AS_UNCONFIGURED_TASK);

pMDValueTaskIsCompleted = FindOrCreateAssociatedMethodDesc(pMDValueTaskIsCompleted, pMTValueTask, FALSE, Instantiation(), FALSE);
pMDCompletionResult = FindOrCreateAssociatedMethodDesc(pMDCompletionResult, pMTValueTask, FALSE, Instantiation(), FALSE);
Expand Down
4 changes: 2 additions & 2 deletions src/coreclr/vm/corelib.h
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ DEFINE_METHOD(THREAD_START_EXCEPTION,EX_CTOR, .ctor,
DEFINE_CLASS(VALUETASK_1, Tasks, ValueTask`1)
DEFINE_METHOD(VALUETASK_1, GET_ISCOMPLETED, get_IsCompleted, NoSig)
DEFINE_METHOD(VALUETASK_1, GET_RESULT, get_Result, NoSig)
DEFINE_METHOD(VALUETASK_1, AS_TASK, AsTask, IM_RetTaskOfT)
DEFINE_METHOD(VALUETASK_1, AS_UNCONFIGURED_TASK, AsUnconfiguredTask, IM_RetTaskOfT)

DEFINE_CLASS(VALUETASK, Tasks, ValueTask)
DEFINE_METHOD(VALUETASK, FROM_EXCEPTION, FromException, SM_Exception_RetValueTask)
Expand All @@ -355,7 +355,7 @@ DEFINE_METHOD(VALUETASK, FROM_RESULT_T, FromResult, GM_T_RetValueTaskOfT)
DEFINE_METHOD(VALUETASK, GET_COMPLETED_TASK, get_CompletedTask, SM_RetValueTask)
DEFINE_METHOD(VALUETASK, GET_ISCOMPLETED, get_IsCompleted, NoSig)
DEFINE_METHOD(VALUETASK, THROW_IF_COMPLETED_UNSUCCESSFULLY, ThrowIfCompletedUnsuccessfully, NoSig)
DEFINE_METHOD(VALUETASK, AS_TASK, AsTask, IM_RetTask)
DEFINE_METHOD(VALUETASK, AS_UNCONFIGURED_TASK, AsUnconfiguredTask, IM_RetTask)

DEFINE_CLASS(TASK_1, Tasks, Task`1)
DEFINE_METHOD(TASK_1, GET_RESULTONSUCCESS, get_ResultOnSuccess, NoSig)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,17 @@ public Task AsTask()
return
obj == null ? Task.CompletedTask :
obj as Task ??
GetTaskForValueTaskSource(Unsafe.As<IValueTaskSource>(obj));
GetTaskForValueTaskSource(Unsafe.As<IValueTaskSource>(obj), ValueTaskSourceOnCompletedFlags.None);
}

internal Task AsUnconfiguredTask()
{
object? obj = _obj;
Debug.Assert(obj == null || obj is Task || obj is IValueTaskSource);
return
obj == null ? Task.CompletedTask :
obj as Task ??
GetTaskForValueTaskSource(Unsafe.As<IValueTaskSource>(obj), IValueTaskAsTask._unconfigured);
}

/// <summary>Gets a <see cref="ValueTask"/> that may be used at any point in the future.</summary>
Expand All @@ -187,7 +197,7 @@ obj as Task ??
/// The <see cref="IValueTaskSource"/> is passed in rather than reading and casting <see cref="_obj"/>
/// so that the caller can pass in an object it's already validated.
/// </remarks>
private Task GetTaskForValueTaskSource(IValueTaskSource t)
private Task GetTaskForValueTaskSource(IValueTaskSource t, ValueTaskSourceOnCompletedFlags flags)
{
ValueTaskSourceStatus status = t.GetStatus(_token);
if (status != ValueTaskSourceStatus.Pending)
Expand Down Expand Up @@ -225,11 +235,11 @@ private Task GetTaskForValueTaskSource(IValueTaskSource t)
}
}

return new ValueTaskSourceAsTask(t, _token);
return new ValueTaskSourceAsTask(t, _token, flags);
}

/// <summary>Type used to create a <see cref="Task"/> to represent a <see cref="IValueTaskSource"/>.</summary>
private sealed class ValueTaskSourceAsTask : Task
private sealed class ValueTaskSourceAsTask : Task, IValueTaskAsTask
{
private static readonly Action<object?> s_completionAction = static state =>
{
Expand Down Expand Up @@ -283,11 +293,28 @@ state is ValueTaskSourceAsTask vsts ?
/// <summary>The token to pass through to operations on <see cref="_source"/></summary>
private readonly short _token;

internal ValueTaskSourceAsTask(IValueTaskSource source, short token)
private bool _configured;

bool IValueTaskAsTask.IsConfigured => _configured;
void IValueTaskAsTask.Configure(ValueTaskSourceOnCompletedFlags flags) => Configure(flags);

private void Configure(ValueTaskSourceOnCompletedFlags flags)
{
Debug.Assert(!_configured);

_configured = true;
_source!.OnCompleted(s_completionAction, this, _token, flags);
}

internal ValueTaskSourceAsTask(IValueTaskSource source, short token, ValueTaskSourceOnCompletedFlags flags)
{
_token = token;
_source = source;
source.OnCompleted(s_completionAction, this, token, ValueTaskSourceOnCompletedFlags.None);

if (flags != IValueTaskAsTask._unconfigured)
{
Configure(flags);
}
}
}

Expand Down Expand Up @@ -585,7 +612,25 @@ public Task<TResult> AsTask()
return t;
}

return GetTaskForValueTaskSource(Unsafe.As<IValueTaskSource<TResult>>(obj));
return GetTaskForValueTaskSource(Unsafe.As<IValueTaskSource<TResult>>(obj), ValueTaskSourceOnCompletedFlags.None);
}

internal Task<TResult> AsUnconfiguredTask()
{
object? obj = _obj;
Debug.Assert(obj == null || obj is Task<TResult> || obj is IValueTaskSource<TResult>);

if (obj == null)
{
return Task.FromResult(_result!);
}

if (obj is Task<TResult> t)
{
return t;
}

return GetTaskForValueTaskSource(Unsafe.As<IValueTaskSource<TResult>>(obj), IValueTaskAsTask._unconfigured);
}

/// <summary>Gets a <see cref="ValueTask{TResult}"/> that may be used at any point in the future.</summary>
Expand All @@ -596,7 +641,7 @@ public Task<TResult> AsTask()
/// The <see cref="IValueTaskSource{TResult}"/> is passed in rather than reading and casting <see cref="_obj"/>
/// so that the caller can pass in an object it's already validated.
/// </remarks>
private Task<TResult> GetTaskForValueTaskSource(IValueTaskSource<TResult> t)
private Task<TResult> GetTaskForValueTaskSource(IValueTaskSource<TResult> t, ValueTaskSourceOnCompletedFlags flags)
{
ValueTaskSourceStatus status = t.GetStatus(_token);
if (status != ValueTaskSourceStatus.Pending)
Expand Down Expand Up @@ -633,11 +678,11 @@ private Task<TResult> GetTaskForValueTaskSource(IValueTaskSource<TResult> t)
}
}

return new ValueTaskSourceAsTask(t, _token);
return new ValueTaskSourceAsTask(t, _token, flags);
}

/// <summary>Type used to create a <see cref="Task{TResult}"/> to represent a <see cref="IValueTaskSource{TResult}"/>.</summary>
private sealed class ValueTaskSourceAsTask : Task<TResult>
private sealed class ValueTaskSourceAsTask : Task<TResult>, IValueTaskAsTask
{
private static readonly Action<object?> s_completionAction = static state =>
{
Expand Down Expand Up @@ -690,11 +735,28 @@ state is ValueTaskSourceAsTask vsts ?
/// <summary>The token to pass through to operations on <see cref="_source"/></summary>
private readonly short _token;

public ValueTaskSourceAsTask(IValueTaskSource<TResult> source, short token)
private bool _configured;

bool IValueTaskAsTask.IsConfigured => _configured;
void IValueTaskAsTask.Configure(ValueTaskSourceOnCompletedFlags flags) => Configure(flags);

private void Configure(ValueTaskSourceOnCompletedFlags flags)
{
Debug.Assert(!_configured);

_configured = true;
_source!.OnCompleted(s_completionAction, this, _token, flags);
}

internal ValueTaskSourceAsTask(IValueTaskSource<TResult> source, short token, ValueTaskSourceOnCompletedFlags flags)
{
_source = source;
_token = token;
source.OnCompleted(s_completionAction, this, token, ValueTaskSourceOnCompletedFlags.None);
_source = source;

if (flags != IValueTaskAsTask._unconfigured)
{
Configure(flags);
}
}
}

Expand Down Expand Up @@ -848,4 +910,12 @@ public ConfiguredValueTaskAwaitable<TResult> ConfigureAwait(bool continueOnCaptu
return string.Empty;
}
}

internal interface IValueTaskAsTask
{
internal const ValueTaskSourceOnCompletedFlags _unconfigured = (ValueTaskSourceOnCompletedFlags)(-1);

bool IsConfigured { get; }
void Configure(ValueTaskSourceOnCompletedFlags flags);
}
}
2 changes: 1 addition & 1 deletion src/tests/async/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<PropertyGroup>
<RunAnalyzers>true</RunAnalyzers>
<NoWarn>$(NoWarn);xUnit1013;CS1998</NoWarn>
<NoWarn>$(NoWarn);xUnit1013;CS1998;SYSLIB5007</NoWarn>
Copy link
Member Author

Choose a reason for hiding this comment

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

TODO: revert before merging this and the following changes that enable runtime async in the PR for testing purposes.

<EnableNETAnalyzers>false</EnableNETAnalyzers>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
Expand Down
5 changes: 0 additions & 5 deletions src/tests/async/Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,5 @@
<DisableProjectBuild Condition="'$(RuntimeFlavor)' == 'mono' or '$(TestBuildMode)' == 'nativeaot' or '$(TargetArchitecture)' == 'wasm'">true</DisableProjectBuild>
</PropertyGroup>

<PropertyGroup>
<!-- runtime async testing in main repo NYI -->
<DisableProjectBuild>true</DisableProjectBuild>
</PropertyGroup>

<Import Project="$([MSBuild]::GetPathOfFileAbove(Directory.Build.targets, $(MSBuildThisFileDirectory)..))" />
</Project>
4 changes: 4 additions & 0 deletions src/tests/async/struct/struct.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ private struct S

public S(int value) => Value = value;

// Roslyn NYI - async in structs. Remove opt-out once supported.
[System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(false)]
public async Task Test()
{
// TODO: C# compiler is expected to do this, but not in the prototype.
Expand All @@ -59,6 +61,8 @@ public async Task Test()
AssertEqual(102, @this.Value);
}

// Roslyn NYI - async in structs. Remove opt-out once supported.
[System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(false)]
private async Task InstanceCall()
{
// TODO: C# compiler is expected to do this, but not in the prototype.
Expand Down
Loading