From 5ee695f3632bbffd2c8d508078c160e7450302ab Mon Sep 17 00:00:00 2001 From: Jakob Botsch Nielsen Date: Wed, 1 Jul 2026 15:24:27 +0200 Subject: [PATCH 1/8] Recognize Task<->ValueTask adaptions in async versions Support looking through `return new ValueTask(TaskReturningFunction())` and `return ValueTaskReturningFunction().AsTask()` in async versions. These can be transformed into async calls. --- .../CompilerServices/AsyncHelpers.CoreCLR.cs | 15 +- src/coreclr/inc/corinfo.h | 13 +- src/coreclr/jit/async.cpp | 5 + src/coreclr/jit/compiler.h | 4 + src/coreclr/jit/gentree.h | 5 + src/coreclr/jit/importer.cpp | 286 ++++++++++++++---- src/coreclr/jit/importercalls.cpp | 25 +- src/coreclr/jit/namedintrinsiclist.h | 3 + .../src/System/Threading/Tasks/ValueTask.cs | 4 + 9 files changed, 281 insertions(+), 79 deletions(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs index 3501630d114f6e..4774fd507629bb 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs @@ -22,28 +22,31 @@ internal enum ContinuationFlags ContinueOnThreadPool = 1 << 0, ContinueOnCapturedSynchronizationContext = 1 << 1, ContinueOnCapturedTaskScheduler = 1 << 2, + // This is an await of valueTask.AsTask() + // (e.g. valueTask.AsTask() returned from an async version) + ValueTaskAdaptedToTask = 1 << 3, - AllContinuationFlags = ContinueOnThreadPool | ContinueOnCapturedSynchronizationContext | ContinueOnCapturedTaskScheduler, + AllContinuationFlags = ContinueOnThreadPool | ContinueOnCapturedSynchronizationContext | ContinueOnCapturedTaskScheduler | ValueTaskAdaptedToTask, // The flags encode where in the continuation various members are stored. // If the encoded index is 0, it means no such member is present. // Otherwise the exact offset of the member is computed as // DataOffset + (index - 1) * PointerSize // - ExecutionContextIndexFirstBit = 3, + ExecutionContextIndexFirstBit = 4, ExecutionContextIndexNumBits = 2, - ContinuationContextIndexFirstBit = 5, + ContinuationContextIndexFirstBit = 6, ContinuationContextIndexNumBits = 2, - ExceptionIndexFirstBit = 7, + ExceptionIndexFirstBit = 8, ExceptionIndexNumBits = 3, // For JIT, the continuation stores space for every possible type of // async callee's result. We need to represent the offset to each of // these, so we allocate the rest of the bits for this. - ResultIndexFirstBit = 10, - ResultIndexNumBits = 22, + ResultIndexFirstBit = 11, + ResultIndexNumBits = 21, } // Keep in sync with CORINFO_AsyncResumeInfo in corinfo.h diff --git a/src/coreclr/inc/corinfo.h b/src/coreclr/inc/corinfo.h index 4dfa9d39bc10c7..cfd6aeefe02398 100644 --- a/src/coreclr/inc/corinfo.h +++ b/src/coreclr/inc/corinfo.h @@ -1786,26 +1786,29 @@ enum CorInfoContinuationFlags // If this bit is set the continuation context is a TaskScheduler that // we should continue on. CORINFO_CONTINUATION_CONTINUE_ON_CAPTURED_TASK_SCHEDULER = 1 << 2, + // If this bit is set this is an await of valueTask.AsTask() + // (common pattern when returning a value-task in an async version) + CORINFO_CONTINUATION_VALUETASK_ADAPTED_TO_TASK = 1 << 3, // The flags encode where in the continuation various members are stored. // If the encoded index is 0, it means no such member is present. // Otherwise the exact offset of the member is computed as // OFFSETOF__CORINFO_Continuation__data + (index - 1) * PointerSize - CORINFO_CONTINUATION_EXECUTION_CONTEXT_INDEX_FIRST_BIT = 3, + CORINFO_CONTINUATION_EXECUTION_CONTEXT_INDEX_FIRST_BIT = 4, CORINFO_CONTINUATION_EXECUTION_CONTEXT_INDEX_NUM_BITS = 2, - CORINFO_CONTINUATION_CONTEXT_INDEX_FIRST_BIT = 5, + CORINFO_CONTINUATION_CONTEXT_INDEX_FIRST_BIT = 6, CORINFO_CONTINUATION_CONTEXT_INDEX_NUM_BITS = 2, - CORINFO_CONTINUATION_EXCEPTION_INDEX_FIRST_BIT = 7, + CORINFO_CONTINUATION_EXCEPTION_INDEX_FIRST_BIT = 8, CORINFO_CONTINUATION_EXCEPTION_INDEX_NUM_BITS = 3, // For JIT, the continuation stores space for every possible type of // async callee's result. We need to represent the offset to each of // these, so we allocate the rest of the bits for this. - CORINFO_CONTINUATION_RESULT_INDEX_FIRST_BIT = 10, - CORINFO_CONTINUATION_RESULT_INDEX_NUM_BITS = 22, + CORINFO_CONTINUATION_RESULT_INDEX_FIRST_BIT = 11, + CORINFO_CONTINUATION_RESULT_INDEX_NUM_BITS = 21, }; struct CORINFO_ASYNC_INFO diff --git a/src/coreclr/jit/async.cpp b/src/coreclr/jit/async.cpp index 21d19968671854..499cef36e68103 100644 --- a/src/coreclr/jit/async.cpp +++ b/src/coreclr/jit/async.cpp @@ -2217,6 +2217,11 @@ void AsyncTransformation::CreateSuspension(BasicBlock* call continuationFlags |= CORINFO_CONTINUATION_CONTINUE_ON_THREAD_POOL; } + if (callInfo.IsValueTaskAsTask) + { + continuationFlags |= CORINFO_CONTINUATION_VALUETASK_ADAPTED_TO_TASK; + } + newContinuation = m_compiler->gtNewLclvNode(newContinuationVar, TYP_REF); unsigned flagsOffset = m_compiler->info.compCompHnd->getFieldOffset(m_asyncInfo->continuationFlagsFldHnd); GenTree* flagsNode = m_compiler->gtNewIconNode((ssize_t)continuationFlags, TYP_INT); diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index f11aa03e81c123..3d7c35fd64d37f 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -5001,6 +5001,7 @@ class Compiler PREFIX_IS_TASK_AWAIT = 0x00000080, PREFIX_TASK_AWAIT_CONTINUE_ON_CAPTURED_CONTEXT = 0x00000100, PREFIX_IS_ASYNC_VERSION_TAIL_AWAIT = 0x00000200, + PREFIX_IS_ADAPTED_FROM_VALUETASK = 0x00000400, }; static void impValidateMemoryAccessOpcode(const BYTE* codeAddr, const BYTE* codeEndp, bool volatilePrefix); @@ -5480,6 +5481,9 @@ class Compiler bool impMatchIsInstBooleanConversion(const BYTE* codeAddr, const BYTE* codeEndp, int* consumed); const BYTE* impMatchTaskAwaitPattern(const BYTE* codeAddr, const BYTE* codeEndp, int* configVal, IL_OFFSET* awaitOffset); + bool impMatchAsyncVersionTailCall(const BYTE* codeAddr, const BYTE* codeEndp, int* prefixFlags, int* numBytesMatched); + bool impMatchStlocLdloca(const BYTE** codeAddr, const BYTE* codeEndp, unsigned* lclNum); + bool impCheckOptimizeAwait(IL_OFFSET awaitOffset); GenTree* impCastClassOrIsInstToTree( diff --git a/src/coreclr/jit/gentree.h b/src/coreclr/jit/gentree.h index b66874618c106b..6b002b654b5b86 100644 --- a/src/coreclr/jit/gentree.h +++ b/src/coreclr/jit/gentree.h @@ -4535,6 +4535,11 @@ struct AsyncCallInfo // records that behavior. ::ContinuationContextHandling ContinuationContextHandling = ContinuationContextHandling::None; + // Is this 'await valueTask.AsTask()'? These come with special semantics as + // they no longer transparently forward continuation context handling to an + // underlying IValueTaskSource, if present. + bool IsValueTaskAsTask = false; + // Tail awaits do not generate suspension points and the JIT instead // directly returns the callee's continuation to the caller. bool IsTailAwait = false; diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index 1e1b25904f41c7..b796eb00004770 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -5861,70 +5861,12 @@ const BYTE* Compiler::impMatchTaskAwaitPattern(const BYTE* codeAddr, { // ConfigureAwait on a ValueTask will start with stloc/ldloca. // The longest encoding should fit in the length we asked for above. - uint8_t maybeStLoc = getU1LittleEndian(nextOpcode); - const BYTE* nextTmp = nextOpcode + 1; - int stlocNum = -1; - switch (maybeStLoc) + unsigned stlocNum = BAD_VAR_NUM; + if (impMatchStlocLdloca(&nextOpcode, codeEndp, &stlocNum)) { - case CEE_STLOC_0: - stlocNum = 0; - break; - case CEE_STLOC_1: - stlocNum = 1; - break; - case CEE_STLOC_2: - stlocNum = 2; - break; - case CEE_STLOC_3: - stlocNum = 3; - break; - case CEE_STLOC_S: - stlocNum = getU1LittleEndian(nextTmp); - nextTmp += 1; - break; - case CEE_PREFIX1: - uint16_t maybeStLocWide = (uint16_t)256 + getU1LittleEndian(nextTmp); - nextTmp += 1; - if (maybeStLocWide == CEE_STLOC) - { - stlocNum = getU2LittleEndian(nextTmp); - nextTmp += 2; - } - break; - } - - // if it was a stloc, check for matching ldloca - if (stlocNum != -1) - { - uint8_t maybeLdLoca = getU1LittleEndian(nextTmp); - nextTmp += 1; - int ldlocaNum = -1; - switch (maybeLdLoca) - { - case CEE_LDLOCA_S: - ldlocaNum = getU1LittleEndian(nextTmp); - nextTmp += 1; - break; - case CEE_PREFIX1: - uint16_t maybeLdLocaWide = (uint16_t)256 + getU1LittleEndian(nextTmp); - nextTmp += 1; - if (maybeLdLocaWide == CEE_LDLOCA) - { - ldlocaNum = getU2LittleEndian(nextTmp); - nextTmp += 2; - } - break; - } - - // no ldloca or locals did not match, this can't be await pattern - if (stlocNum != ldlocaNum) - return nullptr; - // locals match, but no space for ConfigureAwait call, this can't be await pattern - if (nextTmp + 2 * (1 + sizeof(mdToken)) >= codeEndp) + if (nextOpcode + 2 * (1 + sizeof(mdToken)) >= codeEndp) return nullptr; - - nextOpcode = nextTmp; } uint8_t nextOp = getU1LittleEndian(nextOpcode); @@ -5985,6 +5927,216 @@ const BYTE* Compiler::impMatchTaskAwaitPattern(const BYTE* codeAddr, return nullptr; } +bool Compiler::impMatchStlocLdloca(const BYTE** codeAddr, const BYTE* codeEndp, unsigned* lclNum) +{ + *lclNum = BAD_VAR_NUM; + const BYTE* code = *codeAddr; + if (code >= codeEndp) + { + return false; + } + + unsigned matchedLclNum = BAD_VAR_NUM; + BYTE opcode = *code; + code++; + if ((opcode >= CEE_STLOC_0) && (opcode <= CEE_STLOC_3)) + { + matchedLclNum = opcode - CEE_STLOC_0; + } + else if (opcode == CEE_STLOC_S) + { + if (code >= codeEndp) + { + return false; + } + + matchedLclNum = *code; + code++; + } + else if (opcode == CEE_PREFIX1) + { + if (code >= codeEndp) + { + return false; + } + + uint16_t maybeStLocWide = (uint16_t)256 + *code; + code++; + if ((maybeStLocWide != CEE_STLOC) || (code + 1 >= codeEndp)) + { + return false; + } + + matchedLclNum = getU2LittleEndian(code); + code += 2; + } + else + { + return false; + } + + if (code >= codeEndp) + { + return false; + } + + opcode = *code; + code++; + if (opcode == CEE_LDLOCA_S) + { + if (code >= codeEndp) + { + return false; + } + + if (*code != matchedLclNum) + { + return false; + } + + code++; + } + else if (opcode == CEE_PREFIX1) + { + if (code >= codeEndp) + { + return false; + } + + uint16_t maybeLdLocaWide = (uint16_t)256 + *code; + code++; + if ((maybeLdLocaWide != CEE_LDLOCA) || (code + 1 >= codeEndp)) + { + return false; + } + + if (getU2LittleEndian(code) != matchedLclNum) + { + return false; + } + code += 2; + } + else + { + return false; + } + + *lclNum = matchedLclNum; + *codeAddr = code; + return true; +} + +bool Compiler::impMatchAsyncVersionTailCall(const BYTE* codeAddr, + const BYTE* codeEndp, + int* prefixFlags, + int* numBytesMatched) +{ + const BYTE* nextOpcode = codeAddr; + + // Look for call; ret + if ((nextOpcode < codeEndp) && (*nextOpcode == CEE_RET)) + { + *numBytesMatched = 1; + return true; + } + + // Look for call; stloc X; ldloca X; call AsTask(); ret + unsigned vtLclNum; + if (impMatchStlocLdloca(&nextOpcode, codeEndp, &vtLclNum)) + { + if ((nextOpcode >= codeEndp) || (*nextOpcode != CEE_CALL)) + { + return false; + } + + nextOpcode++; + + // Quick check for ret before we resolve the token + if (nextOpcode + sizeof(mdToken) >= codeEndp || (*(nextOpcode + sizeof(mdToken)) != CEE_RET)) + { + return false; + } + + CORINFO_RESOLVED_TOKEN callTok; + impResolveToken(nextOpcode, &callTok, CORINFO_TOKENKIND_Method); + + if (!eeIsIntrinsic(callTok.hMethod)) + { + return false; + } + + NamedIntrinsic ni = lookupNamedIntrinsic(callTok.hMethod); + if ((ni != NI_System_Threading_Tasks_ValueTask_AsTask) && (ni != NI_System_Threading_Tasks_ValueTask_1_AsTask)) + { + return false; + } + + nextOpcode += sizeof(mdToken); + nextOpcode++; // matched CEE_RET already + + JITDUMP("Matched \"return ValueTaskReturn().AsTask()\"\n"); + *prefixFlags |= PREFIX_IS_ADAPTED_FROM_VALUETASK; + *numBytesMatched = (int)(nextOpcode - codeAddr); + return true; + } + + // Look for call; newobj ValueTask; ret + if ((nextOpcode < codeEndp) && (*nextOpcode == CEE_NEWOBJ)) + { + nextOpcode++; + + // Quick check for ret before we resolve the token + if (nextOpcode + sizeof(mdToken) >= codeEndp || (*(nextOpcode + sizeof(mdToken)) != CEE_RET)) + { + return false; + } + + CORINFO_RESOLVED_TOKEN ctorTok; + impResolveToken(nextOpcode, &ctorTok, CORINFO_TOKENKIND_NewObj); + + if (!eeIsIntrinsic(ctorTok.hMethod)) + { + return false; + } + + NamedIntrinsic ni = lookupNamedIntrinsic(ctorTok.hMethod); + if ((ni != NI_System_Threading_Tasks_ValueTask__ctor) && (ni != NI_System_Threading_Tasks_ValueTask_1__ctor)) + { + return false; + } + + CORINFO_SIG_INFO sig; + info.compCompHnd->getMethodSig(ctorTok.hMethod, &sig); + + if (sig.numArgs != 1) + { + return false; + } + + if (info.compRetType != TYP_VOID) + { + assert((sig.sigInst.classInstCount == 1) && (sig.sigInst.methInstCount == 0)); + CORINFO_CLASS_HANDLE paramClass = info.compCompHnd->getArgClass(&sig, sig.args); + if (paramClass == sig.sigInst.classInst[0]) + { + // This is "class ValueTask { ValueTask(T value) }" overload + // which is not what we are looking for. That one gets folded + // by impFoldAwaitedTopOfStack. + return false; + } + } + + nextOpcode += sizeof(mdToken); + nextOpcode++; // matched CEE_RET already + + JITDUMP("Matched \"return new ValueTask(TaskReturn())\"\n"); + *numBytesMatched = (int)(nextOpcode - codeAddr); + return true; + } + + return false; +} + /***************************************************************************** * Import the instr for the given basic block */ @@ -9052,17 +9204,17 @@ void Compiler::impImportBlockCode(BasicBlock* block) if (compIsAsyncVersion()) { - if ((codeAddr + sz < codeEndp) && (getU1LittleEndian(codeAddr + sz) == CEE_RET) && - ((info.compFlags & CORINFO_FLG_SYNCH) == 0)) + int numBytesMatched; + if (((info.compFlags & CORINFO_FLG_SYNCH) == 0) && + impMatchAsyncVersionTailCall(codeAddr + sz, codeEndp, &prefixFlags, &numBytesMatched)) { JITDUMP("\nRecognized tail-call in async version\n"); - awaitOffset = (IL_OFFSET)(codeAddr - 1 - info.compCode); isAwait = true; + awaitOffset = (IL_OFFSET)(codeAddr - 1 - info.compCode); prefixFlags |= PREFIX_IS_ASYNC_VERSION_TAIL_AWAIT; - - // Consume the ret opcode. Note `codeAddr` points at the unconsumed token; + // Consume number of bytes matched. Note `codeAddr` points at the unconsumed token; // the main loop will still do `codeAddr += sz` (token size) after this case. - codeAddrAfterMatch = codeAddr + 1; + codeAddrAfterMatch = codeAddr + numBytesMatched; } } else diff --git a/src/coreclr/jit/importercalls.cpp b/src/coreclr/jit/importercalls.cpp index a0340343c58c44..86055c8fe9cc8f 100644 --- a/src/coreclr/jit/importercalls.cpp +++ b/src/coreclr/jit/importercalls.cpp @@ -7268,6 +7268,15 @@ void Compiler::impSetupAsyncCall( return; } + // We cannot inline if the callee returns valueTask.AsTask() in an + // async version. We need to preserve the continuation in this case to + // be able to mark it with CORINFO_CONTINUATION_VALUETASK_ADAPTED_TO_TASK. + if ((prefixFlags & PREFIX_IS_ADAPTED_FROM_VALUETASK) != 0) + { + compInlineResult->NoteFatal(InlineObservation::CALLEE_AWAIT); + return; + } + // For async versions of synchronous methods all async calls are in // tail position. Inlining is simple for these cases: we can just // inherit all context handling from the inlining call. @@ -7288,8 +7297,10 @@ void Compiler::impSetupAsyncCall( } else { + asyncInfo.IsValueTaskAsTask = (prefixFlags & PREFIX_IS_ADAPTED_FROM_VALUETASK) != 0; + if (opts.OptimizationEnabled() && ((prefixFlags & PREFIX_IS_ASYNC_VERSION_TAIL_AWAIT) != 0) && - (call->gtReturnType == info.compRetType)) + (call->gtReturnType == info.compRetType) && !asyncInfo.IsValueTaskAsTask) { CORINFO_METHOD_HANDLE exactCalleeHnd = ((call->AsCall()->gtCallType != CT_USER_FUNC) || call->AsCall()->IsVirtual()) ? nullptr : methHnd; @@ -11783,6 +11794,14 @@ NamedIntrinsic Compiler::lookupNamedIntrinsic(CORINFO_METHOD_HANDLE method) { result = NI_System_Threading_Tasks_ValueTask_get_CompletedTask; } + else if (strcmp(methodName, ".ctor") == 0) + { + result = NI_System_Threading_Tasks_ValueTask__ctor; + } + else if (strcmp(methodName, "AsTask") == 0) + { + result = NI_System_Threading_Tasks_ValueTask_AsTask; + } } else if (strcmp(className, "ValueTask`1") == 0) { @@ -11790,6 +11809,10 @@ NamedIntrinsic Compiler::lookupNamedIntrinsic(CORINFO_METHOD_HANDLE method) { result = NI_System_Threading_Tasks_ValueTask_1__ctor; } + else if (strcmp(methodName, "AsTask") == 0) + { + result = NI_System_Threading_Tasks_ValueTask_1_AsTask; + } } } } diff --git a/src/coreclr/jit/namedintrinsiclist.h b/src/coreclr/jit/namedintrinsiclist.h index 5f96a30903d1d2..b19bf10f89a194 100644 --- a/src/coreclr/jit/namedintrinsiclist.h +++ b/src/coreclr/jit/namedintrinsiclist.h @@ -172,8 +172,11 @@ enum NamedIntrinsic : unsigned short NI_System_Threading_Tasks_ValueTask_FromResult, NI_System_Threading_Tasks_ValueTask_get_CompletedTask, + NI_System_Threading_Tasks_ValueTask__ctor, + NI_System_Threading_Tasks_ValueTask_AsTask, NI_System_Threading_Tasks_ValueTask_1__ctor, + NI_System_Threading_Tasks_ValueTask_1_AsTask, // These two are special marker IDs so that we still get the inlining profitability boost NI_System_Numerics_Intrinsic, diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/ValueTask.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/ValueTask.cs index dbb08c49559c07..3fea4a5dd7c345 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/ValueTask.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/ValueTask.cs @@ -71,6 +71,7 @@ namespace System.Threading.Tasks /// Initialize the with a that represents the operation. /// The task. [MethodImpl(MethodImplOptions.AggressiveInlining)] + [Intrinsic] public ValueTask(Task task) { if (task == null) @@ -174,6 +175,7 @@ obj is ValueTask && /// It will either return the wrapped task object if one exists, or it'll /// manufacture a new task object to represent the result. /// + [Intrinsic] public Task AsTask() { object? obj = _obj; @@ -497,6 +499,7 @@ public ValueTask(TResult result) /// Initialize the with a that represents the operation. /// The task. [MethodImpl(MethodImplOptions.AggressiveInlining)] + [Intrinsic] public ValueTask(Task task) { if (task == null) @@ -576,6 +579,7 @@ public bool Equals(ValueTask other) => /// It will either return the wrapped task object if one exists, or it'll /// manufacture a new task object to represent the result. /// + [Intrinsic] public Task AsTask() { object? obj = _obj; From f688392c5e8027f6efda2ad63933bdacae835cd2 Mon Sep 17 00:00:00 2001 From: Jakob Botsch Nielsen Date: Wed, 1 Jul 2026 16:01:12 +0200 Subject: [PATCH 2/8] Add tests --- .../async-versions-task-adapters.cs | 161 ++++++++++++++++++ .../async-versions-task-adapters.csproj | 5 + .../async-versions.il} | 2 +- .../async-versions.ilproj} | 0 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/tests/async/async-versions-task-adapters/async-versions-task-adapters.cs create mode 100644 src/tests/async/async-versions-task-adapters/async-versions-task-adapters.csproj rename src/tests/async/{asyncversions/asyncversions.il => async-versions/async-versions.il} (99%) rename src/tests/async/{asyncversions/asyncversions.ilproj => async-versions/async-versions.ilproj} (100%) diff --git a/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.cs b/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.cs new file mode 100644 index 00000000000000..6d87dac0f07c16 --- /dev/null +++ b/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.cs @@ -0,0 +1,161 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Sources; +using Xunit; + +public class Async2TaskAdapters +{ + // Wrapping a runtime-async Task in a ValueTask (ValueTask(Task) constructor) and + // awaiting the resulting ValueTask should suspend and resume correctly. + [Fact] + public static void TestTaskToValueTask() + { + SynchronizationContext prevContext = SynchronizationContext.Current; + try + { + SynchronizationContext.SetSynchronizationContext(new MySyncContext()); + WrapTaskInValueTask().GetAwaiter().GetResult(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(prevContext); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static ValueTask WrapTaskInValueTask() + { + return new ValueTask(YieldingTask()); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task YieldingTask() + { + await Task.Yield(); + } + + // Converting a ValueTask backed by an IValueTaskSource into a Task via AsTask() + // and awaiting it should complete correctly. + [Fact] + public static void TestValueTaskSourceToTask() + { + SynchronizationContext prevContext = SynchronizationContext.Current; + try + { + SynchronizationContext.SetSynchronizationContext(new MySyncContext()); + ConvertSourceToTask().GetAwaiter().GetResult(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(prevContext); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static Task ConvertSourceToTask() + { + return SourceValueTask().AsTask(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static ValueTask SourceValueTask() + { + return new ValueTask(new Source(), 1); + } + + // Verifies that the two ValueTask> constructor overloads are handled correctly: + // - return new ValueTask>(TaskOfIntReturningFunction()) passes a Task, which binds + // to the ValueTask(TResult result) constructor, wrapping the Task as a value. + // - return new ValueTask>(TaskOfTaskOfIntReturningFunction()) passes a Task>, + // which binds to the ValueTask(Task task) constructor, an actual async call. + [Fact] + public static void TestValueTaskOfTaskValueVersusAsync() + { + WrapValueVersusAsync().GetAwaiter().GetResult(); + } + + private static async Task WrapValueVersusAsync() + { + // Value case: a suspended Task is passed to the ValueTask>(TResult result) + // constructor, so the ValueTask holds it as an already-available value. The ValueTask is + // completed even while the inner Task is still suspended. + TaskCompletionSource valueGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + ValueTask> valueCase = WrapTaskValue(valueGate); + Assert.True(valueCase.IsCompletedSuccessfully); + Task innerFromValue = await valueCase; + Assert.False(innerFromValue.IsCompleted); + valueGate.SetResult(); + Assert.Equal(42, await innerFromValue); + + // Async case: a suspended Task> is passed to the ValueTask>(Task task) + // constructor, so the ValueTask is backed by that task and is not completed until the outer + // task completes. + TaskCompletionSource asyncGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + ValueTask> asyncCase = WrapTaskTask(asyncGate); + Assert.False(asyncCase.IsCompleted); + asyncGate.SetResult(); + Task innerFromAsync = await asyncCase; + Assert.Equal(123, await innerFromAsync); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static ValueTask> WrapTaskValue(TaskCompletionSource gate) + { + return new ValueTask>(TaskOfIntReturningFunction(gate)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static ValueTask> WrapTaskTask(TaskCompletionSource gate) + { + return new ValueTask>(TaskOfTaskOfIntReturningFunction(gate)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskOfIntReturningFunction(TaskCompletionSource gate) + { + await gate.Task; + return 42; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task> TaskOfTaskOfIntReturningFunction(TaskCompletionSource gate) + { + await gate.Task; + return Task.FromResult(123); + } + + private class Source : IValueTaskSource + { + public void GetResult(short token) + { + } + + public ValueTaskSourceStatus GetStatus(short token) + { + return ValueTaskSourceStatus.Pending; + } + + public void OnCompleted(Action continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags) + { + Assert.Equal(ValueTaskSourceOnCompletedFlags.None, flags); + continuation(state); + } + } + + private class MySyncContext : SynchronizationContext + { + public override void Post(SendOrPostCallback d, object state) + { + ThreadPool.UnsafeQueueUserWorkItem(_ => + { + SetSynchronizationContext(this); + d(state); + }, null); + } + } +} diff --git a/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.csproj b/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.csproj new file mode 100644 index 00000000000000..197767e2c4e249 --- /dev/null +++ b/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/tests/async/asyncversions/asyncversions.il b/src/tests/async/async-versions/async-versions.il similarity index 99% rename from src/tests/async/asyncversions/asyncversions.il rename to src/tests/async/async-versions/async-versions.il index 602bab480b851f..62ac6aa095cb73 100644 --- a/src/tests/async/asyncversions/asyncversions.il +++ b/src/tests/async/async-versions/async-versions.il @@ -10,7 +10,7 @@ .assembly extern System.Runtime { .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A) .ver 4:0:0:0 } .assembly extern xunit.core {} -.assembly asyncversions +.assembly 'async-versions' { } diff --git a/src/tests/async/asyncversions/asyncversions.ilproj b/src/tests/async/async-versions/async-versions.ilproj similarity index 100% rename from src/tests/async/asyncversions/asyncversions.ilproj rename to src/tests/async/async-versions/async-versions.ilproj From 3de295a0d6fbbfaaff94970ca9c3416f50a0ddc6 Mon Sep 17 00:00:00 2001 From: Jakob Botsch Nielsen Date: Wed, 1 Jul 2026 16:20:10 +0200 Subject: [PATCH 3/8] feedback --- src/coreclr/jit/importer.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index b796eb00004770..56b0423376d310 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -5874,7 +5874,7 @@ const BYTE* Compiler::impMatchTaskAwaitPattern(const BYTE* codeAddr, if ((nextOp != CEE_LDC_I4_0 && nextOp != CEE_LDC_I4_1) || (nextNextOp != CEE_CALL && nextNextOp != CEE_CALLVIRT)) { - if (stlocNum != -1) + if (stlocNum != BAD_VAR_NUM) { // we had stloc/ldloca, we must see ConfigAwait return nullptr; @@ -5890,7 +5890,7 @@ const BYTE* Compiler::impMatchTaskAwaitPattern(const BYTE* codeAddr, if (!eeIsIntrinsic(nextCallTok.hMethod) || lookupNamedIntrinsic(nextCallTok.hMethod) != NI_System_Threading_Tasks_Task_ConfigureAwait) { - if (stlocNum != -1) + if (stlocNum != BAD_VAR_NUM) { // we had stloc/ldloca, we must see ConfigAwait return nullptr; From 69b46f5afb276c21bd7cd2a3a781b6e3bd3ac103 Mon Sep 17 00:00:00 2001 From: Jakob Botsch Nielsen Date: Wed, 1 Jul 2026 16:24:35 +0200 Subject: [PATCH 4/8] Make usage more clear --- .../Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs index 4774fd507629bb..d4fc375b747482 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs @@ -22,11 +22,13 @@ internal enum ContinuationFlags ContinueOnThreadPool = 1 << 0, ContinueOnCapturedSynchronizationContext = 1 << 1, ContinueOnCapturedTaskScheduler = 1 << 2, - // This is an await of valueTask.AsTask() - // (e.g. valueTask.AsTask() returned from an async version) + // This is an await of valueTask.AsTask() (e.g. valueTask.AsTask() + // returned from an async version). This flag affects how + // ValueTaskSourceContinuation handling computes the flags to pass to + // IValueTaskSource.OnCompleted. ValueTaskAdaptedToTask = 1 << 3, - AllContinuationFlags = ContinueOnThreadPool | ContinueOnCapturedSynchronizationContext | ContinueOnCapturedTaskScheduler | ValueTaskAdaptedToTask, + AllContinuationFlags = ContinueOnThreadPool | ContinueOnCapturedSynchronizationContext | ContinueOnCapturedTaskScheduler, // The flags encode where in the continuation various members are stored. // If the encoded index is 0, it means no such member is present. @@ -828,7 +830,8 @@ internal unsafe bool HandleSuspended(ref RuntimeAsyncAwaitState state) // the direct AsyncHelpers.Await(ValueTask/ValueTask) path. // In either case, that can only happen in nontransparent/user code. Continuation contWithContinueFlags = valueTaskSourceCont; - while ((contWithContinueFlags.Flags & ContinuationFlags.AllContinuationFlags) == 0 && contWithContinueFlags.Next != null) + while ((contWithContinueFlags.Flags & (ContinuationFlags.AllContinuationFlags | ContinuationFlags.ValueTaskAdaptedToTask)) == 0 && + contWithContinueFlags.Next != null) { contWithContinueFlags = contWithContinueFlags.Next; } From cacdec0b4b27842b849d4b24d31060adec28bd48 Mon Sep 17 00:00:00 2001 From: Jakob Botsch Nielsen Date: Wed, 1 Jul 2026 16:25:46 +0200 Subject: [PATCH 5/8] More feedback --- .../async-versions-task-adapters.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.cs b/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.cs index 6d87dac0f07c16..f79fd2c01fe910 100644 --- a/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.cs +++ b/src/tests/async/async-versions-task-adapters/async-versions-task-adapters.cs @@ -153,8 +153,16 @@ public override void Post(SendOrPostCallback d, object state) { ThreadPool.UnsafeQueueUserWorkItem(_ => { - SetSynchronizationContext(this); - d(state); + SynchronizationContext prevContext = Current; + try + { + SetSynchronizationContext(this); + d(state); + } + finally + { + SetSynchronizationContext(prevContext); + } }, null); } } From ebc9b259b2a59956f8a83dfa98b731d3d1b0701a Mon Sep 17 00:00:00 2001 From: Jakob Botsch Nielsen Date: Wed, 1 Jul 2026 20:18:14 +0200 Subject: [PATCH 6/8] ILScanner part --- .../IL/ILImporter.Scanner.cs | 183 ++++++++++++++---- 1 file changed, 150 insertions(+), 33 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs b/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs index ab198087de1b90..581ebefd3688f0 100644 --- a/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs +++ b/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs @@ -355,34 +355,10 @@ private bool MatchTaskAwaitPattern() // so check for that. if (reader.Size > 2 * (1 + sizeof(int))) { - opcode = reader.ReadILOpcode(); - - // ConfigureAwait on a ValueTask will start with stloc/ldloca. - int stlocNum = opcode switch - { - >= ILOpcode.stloc_0 and <= ILOpcode.stloc_3 => opcode - ILOpcode.stloc_0, - ILOpcode.stloc => reader.ReadILUInt16(), - ILOpcode.stloc_s => reader.ReadILByte(), - _ => -1, - }; - - // if it was a stloc, check for matching ldloca - if (stlocNum != -1) - { - opcode = reader.ReadILOpcode(); - int ldlocaNum = opcode switch - { - ILOpcode.ldloca_s => reader.ReadILByte(), - ILOpcode.ldloca => reader.ReadILUInt16(), - _ => -1, - }; - - if (stlocNum != ldlocaNum) - return false; - - opcode = reader.ReadILOpcode(); - } + if (!MatchStlocLdloca(ref reader, out int stlocNum)) + stlocNum = -1; + opcode = reader.ReadILOpcode(); if (opcode is (not ILOpcode.ldc_i4_0) and (not ILOpcode.ldc_i4_1)) { if (stlocNum != -1) @@ -411,6 +387,42 @@ private bool MatchTaskAwaitPattern() && IsAsyncHelpersAwait((MethodDesc)_methodIL.GetObject(reader.ReadILToken())); } + private static bool MatchStlocLdloca(ref ILReader reader, out int stlocNum) + { + stlocNum = -1; + ILReader tempReader = reader; + + ILOpcode opcode = tempReader.ReadILOpcode(); + + // ConfigureAwait on a ValueTask will start with stloc/ldloca. + stlocNum = opcode switch + { + >= ILOpcode.stloc_0 and <= ILOpcode.stloc_3 => opcode - ILOpcode.stloc_0, + ILOpcode.stloc => tempReader.ReadILUInt16(), + ILOpcode.stloc_s => tempReader.ReadILByte(), + _ => -1, + }; + + if (stlocNum == -1) + { + return false; + } + + opcode = tempReader.ReadILOpcode(); + int ldlocaNum = opcode switch + { + ILOpcode.ldloca_s => tempReader.ReadILByte(), + ILOpcode.ldloca => tempReader.ReadILUInt16(), + _ => -1, + }; + + if (stlocNum != ldlocaNum) + return false; + + reader = tempReader; + return true; + } + private ILReader GetRemainingBlockIL() { int nextBBOffset = _currentOffset; @@ -421,21 +433,80 @@ private ILReader GetRemainingBlockIL() return new ILReader(new ReadOnlySpan(_ilBytes, _currentOffset, nextBBOffset - _currentOffset)); } - private bool MatchTailCallAwait(MethodDesc method) + private bool MatchTailCallAwait() { // Create ILReader for what's left in the basic block - var reader = GetRemainingBlockIL(); + ILReader remainingReader = GetRemainingBlockIL(); - if (!reader.HasNext) + if (!remainingReader.HasNext) return false; - ILOpcode opcode = reader.ReadILOpcode(); - if (opcode == ILOpcode.ret && method.Signature.ReturnsTaskOrValueTask()) + ILReader reader = remainingReader; + // Look for call; ret + if (reader.ReadILOpcode() == ILOpcode.ret) { _prevMatchedAwaitTailCallRetPostOffset = _currentOffset + reader.Offset; return true; } + reader = remainingReader; + // Look for call; stloc X; ldloca X; call AsTask(); ret + if (MatchStlocLdloca(ref reader, out _)) + { + if (reader.ReadILOpcode() != ILOpcode.call) + { + return false; + } + + int callToken = reader.ReadILToken(); + + // Verify ret first before we go resolving metadata + if (reader.ReadILOpcode() != ILOpcode.ret) + { + return false; + } + + if (!IsValueTaskAsTask((MethodDesc)_methodIL.GetObject(callToken))) + { + return false; + } + + _prevMatchedAwaitTailCallRetPostOffset = _currentOffset + reader.Offset; + return true; + } + + // Look for call; newobj ValueTask; ret + reader = remainingReader; + if (reader.ReadILOpcode() == ILOpcode.newobj) + { + int ctorToken = reader.ReadILToken(); + + if (reader.ReadILOpcode() != ILOpcode.ret) + { + return false; + } + + MethodDesc ctorMethod = (MethodDesc)_methodIL.GetObject(ctorToken); + if (!IsValueTaskCtor(ctorMethod)) + { + return false; + } + + MethodSignature sig = ctorMethod.GetTypicalMethodDefinition().Signature; + if (sig.Length != 1) + { + return false; + } + + if (sig[0] is not MetadataType mt || !IsTaskType(mt)) + { + return false; + } + + _prevMatchedAwaitTailCallRetPostOffset = _currentOffset + reader.Offset; + return true; + } + return false; } @@ -473,7 +544,7 @@ private void ImportCall(ILOpcode opcode, int token) // Don't get async variant of Delegate.Invoke method; the pointed to method is not an async variant either. allowAsyncVariant = allowAsyncVariant && !method.OwningType.IsDelegate; - if (allowAsyncVariant && (_canonMethod.SupportsAsyncVersionCodegen() ? MatchTailCallAwait(method) : MatchTaskAwaitPattern())) + if (allowAsyncVariant && (_canonMethod.SupportsAsyncVersionCodegen() ? MatchTailCallAwait() : MatchTaskAwaitPattern())) { MethodDesc asyncVariantMethod = _factory.TypeSystemContext.GetAsyncVariantMethod(method); MethodDesc asyncVariantRuntimeDeterminedMethod = _factory.TypeSystemContext.GetAsyncVariantMethod(runtimeDeterminedMethod); @@ -1779,6 +1850,52 @@ private static bool IsTaskConfigureAwait(MethodDesc method) return false; } + private static bool IsValueTaskAsTask(MethodDesc method) + { + if (method.IsIntrinsic && method.Name == "AsTask"u8) + { + MetadataType owningType = method.OwningType as MetadataType; + if (owningType != null) + { + Utf8Span typeName = owningType.Name; + return owningType.Module == method.Context.SystemModule + && owningType.Namespace == "System.Threading.Tasks"u8 + && (typeName == "ValueTask"u8 || typeName == "ValueTask`1"u8); + } + } + + return false; + } + + private static bool IsValueTaskCtor(MethodDesc method) + { + if (method.IsIntrinsic && method.Name == ".ctor"u8) + { + MetadataType owningType = method.OwningType as MetadataType; + if (owningType != null) + { + Utf8Span typeName = owningType.Name; + return owningType.Module == method.Context.SystemModule + && owningType.Namespace == "System.Threading.Tasks"u8 + && (typeName == "ValueTask"u8 || typeName == "ValueTask`1"u8); + } + } + + return false; + } + + private static bool IsTaskType(MetadataType type) + { + if (type.Module != type.Context.SystemModule + || type.Namespace != "System.Threading.Tasks"u8) + { + return false; + } + + Utf8Span name = type.Name; + return name == "Task"u8 || name == "Task`1"u8; + } + private DefType GetWellKnownType(WellKnownType wellKnownType) { return _compilation.TypeSystemContext.GetWellKnownType(wellKnownType); From 7fb6ba7af949fc207f35e7d1fe2145f0dd06fe32 Mon Sep 17 00:00:00 2001 From: Jakob Botsch Nielsen Date: Tue, 7 Jul 2026 15:02:25 +0200 Subject: [PATCH 7/8] Add function headers --- src/coreclr/jit/importer.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index e0c0af6f7efadb..ade404bbfe8808 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -5928,6 +5928,18 @@ const BYTE* Compiler::impMatchTaskAwaitPattern(const BYTE* codeAddr, return nullptr; } +//------------------------------------------------------------------------ +// impMatchStlocLdloca: +// Match stloc followed by ldloca, and returne the local number if matched. +// +// Arguments: +// codeAddr - IL pointer to first opcode +// codeEndp - End of IL code stream +// lclNum - [out] local variable number if matched +// +// Returns: +// True if the IL is a stloc followed by a ldloca; otherwise false. +// bool Compiler::impMatchStlocLdloca(const BYTE** codeAddr, const BYTE* codeEndp, unsigned* lclNum) { *lclNum = BAD_VAR_NUM; @@ -6027,6 +6039,19 @@ bool Compiler::impMatchStlocLdloca(const BYTE** codeAddr, const BYTE* codeEndp, return true; } +//------------------------------------------------------------------------ +// impMatchAsyncVersionTailCall: +// See if a call can be matched to be a tailcall in an async version. +// +// Arguments: +// codeAddr - IL pointer to first opcode +// codeEndp - End of IL code stream +// prefixFlags - [out] flags indicating the presence of specific prefixes +// numBytesMatched - [out] number of bytes matched in the pattern +// +// Returns: +// True if the IL is a tailcall in an async version; otherwise false. +// bool Compiler::impMatchAsyncVersionTailCall(const BYTE* codeAddr, const BYTE* codeEndp, int* prefixFlags, From 9cfdf7a7ffa9e8687130433f8165ba0331929760 Mon Sep 17 00:00:00 2001 From: Jakob Botsch Nielsen Date: Tue, 7 Jul 2026 15:21:20 +0200 Subject: [PATCH 8/8] Nit --- src/coreclr/jit/importer.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index ade404bbfe8808..a16f2922b20ec6 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -5930,12 +5930,12 @@ const BYTE* Compiler::impMatchTaskAwaitPattern(const BYTE* codeAddr, //------------------------------------------------------------------------ // impMatchStlocLdloca: -// Match stloc followed by ldloca, and returne the local number if matched. +// Match stloc followed by ldloca, and return the local number if matched. // // Arguments: -// codeAddr - IL pointer to first opcode -// codeEndp - End of IL code stream -// lclNum - [out] local variable number if matched +// codeAddr - IL pointer to first opcode +// codeEndp - End of IL code stream +// lclNum - [out] local variable number if matched // // Returns: // True if the IL is a stloc followed by a ldloca; otherwise false. @@ -6141,6 +6141,8 @@ bool Compiler::impMatchAsyncVersionTailCall(const BYTE* codeAddr, if (info.compRetType != TYP_VOID) { + // Since we validated above that this instance is being returned + // this can only be ValueTask at this point. assert((sig.sigInst.classInstCount == 1) && (sig.sigInst.methInstCount == 0)); CORINFO_CLASS_HANDLE paramClass = info.compCompHnd->getArgClass(&sig, sig.args); if (paramClass == sig.sigInst.classInst[0])