| title | Runtime async |
|---|---|
| category | Compiler Internals |
| categoryindex | 200 |
| index | 375 |
This document describes the current proof-of-concept implementation of F# support for the .NET runtime-async feature. It describes the code as implemented, not an aspirational design. The .NET design is still evolving:
- Runtime-async specification
- Runtime-async code-generation contract
- Roslyn runtime async design —
how C# lowers
await(including the exception-handling hoisting described below)
The implementation targets functions, lambdas, and members returning
System.Threading.Tasks.Task<'T>, Task, ValueTask<'T>, or ValueTask.
Inline computation-expression builders can use the feature, but no such
builder is currently part of FSharp.Core.
Runtime-async methods are CIL methods marked with
MethodImplOptions.Async (0x2000). The runtime, rather than a compiler
generated state machine and method builder, owns suspension and resumption.
The compiler provides a return intrinsic for each of these carrier shapes:
generic and non-generic Task, and generic and non-generic ValueTask.
Suspension is explicit, via System.Runtime.CompilerServices.AsyncHelpers:
AwaitforTask,ValueTask, and configured awaitablesAwaitAwaiterandUnsafeAwaitAwaiterfor awaiters (used by SRTP awaitable bindings)
The compiler emits the adjacent IL sequence the runtime specification expects:
call Task<int32> SomeAsyncMethod(...)
call int32 AsyncHelpers::Await<int32>(Task<int32>)In runtime-async bodies, the compiler emits Task upcasts as castclass
instructions, including before ConfigureAwait. These casts prevent the runtime
from incorrectly optimizing a generic task-producing call followed by a
non-generic await.
Runtime restrictions and their compiler handling:
-
tail.is forbidden; runtime-async methods suppress tail-call prefixes. -
locallocis forbidden and reported as FS3920, even without a suspension. -
MethodImplOptions.Synchronizedis rejected with FS3921. -
Suspension cannot occur inside exception handlers, filters, or
finallyblocks. Awaiting in a protectedtrybody is supported. Return-marker bodies rewrite suspendingtry/withhandlers and filters, andtry/finallycompensations, so the suspension runs outside the EH region.C# avoids this by rewriting EH-region awaits at lowering time (see the Roslyn design doc):
try B finally { await x }becomestry B catch-all { pend e }, thenawait xoutside the region, then rethrow the pending exception. The compiler applies the same transformation to a suspending compensation: it captures the body result or exception, runsDisposeAsync(possibly suspending) outside thetry, then restores the pending exception. This makesuseon anIAsyncDisposablework under runtime async.
Byref, byref-like, and pinned locals that are used after a suspension are rejected with diagnostic FS3919 when their non-preservable provenance is tracked. Pinned provenance is not currently propagated through ref cells or captured delegates, so those uses are not reliably diagnosed.
Calls to AsyncHelpers suspension methods emitted outside a runtime-async
method are rejected during code generation. Explicitly inline method bodies
are treated as templates and checked at their eventual use site.
The source-level markers are compiler intrinsics on
Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers, available from
the net10.0 FSharp.Core target and declared in resumable.fsi alongside the
other compiler intrinsics:
val __runtimeAsyncReturn<'T> : 'T -> System.Threading.Tasks.Task<'T>
val __runtimeAsyncReturnValueTask<'T> : 'T -> System.Threading.Tasks.ValueTask<'T>
val __runtimeAsyncReturnUnit : unit -> System.Threading.Tasks.Task
val __runtimeAsyncReturnValueTaskUnit : unit -> System.Threading.Tasks.ValueTaskTheir FSharp.Core implementations throw; code generation consumes recognized
marker applications instead of emitting calls to those stubs. They are marked
NoInlining so a missed consumption does not silently fold into a caller.
The feature is available from F# 11.2, including default, latest, and
preview (LanguageFeature.RuntimeAsync), and gated on the target reference
assemblies exposing MethodImplOptions.Async (see "Runtime capability check"
below).
Without the language version the checker reports error 3350; without runtime
support it reports 3351.
Typical forms:
let add (x: int) (y: int) : Task<int> =
__runtimeAsyncReturn (
let first = AsyncHelpers.Await (getTask x)
first + y)
type C() =
member _.Add(x: int, y: int) : Task<int> =
__runtimeAsyncReturn (
AsyncHelpers.Await (getTask x) + y)
// Let-bound value (not a function): also supported.
let answer : Task<int> = __runtimeAsyncReturn 42There is no implicit awaiting: the argument of a generic return marker is
checked as the logical 'T result, and flattening requires an explicit
AsyncHelpers.Await.
The return intrinsics are ordinary values in the typed tree; no new expression
node or Val flag is added. Type checking special-cases their applications in
two places in CheckExpressions.fs:
Propagateskips function-type propagation for the intrinsic so the argument is not checked against a function domain.TcApplicationThen(tryTcRuntimeAsyncApplication) recognises the intrinsic (possibly type-applied), gates the language feature and runtime capability, extracts the result carrier and argument type from the intrinsic's instantiated signature, and checks the argument withTcExprFlex2. The result carrier then unifies with the declared return type of the enclosing binding in the usual way.
User code that defines its own same-named marker is unaffected: the intrinsic is
only recognised when the ValRef resolves (via valRefEq) to the FSharp.Core
declaration.
Optimizer.fs preserves the marker application as-is, optimizing its
argument. The marked expression is forced to HasEffect = true and
UnknownValue, so ordinary value propagation cannot treat it as a pure result.
The marker therefore survives optimization as an ordinary Expr.App node;
nothing else in the typed tree records that a method is runtime-async.
Inline values whose bodies contain a marker or an AsyncHelpers
suspension are recursively specialized at their call sites, including when
optimization is disabled. The analysis follows inline and local values with a
cycle guard, and InlineIfLambda arguments are forced through when the caller
is already in a runtime-async context.
Method splitting never moves an expression containing a suspension into a separate method, since that method would not be runtime-async. A method whose body is a return marker is never exported as an inline definition: its suspensions are valid only in the generated runtime-async method.
The terms used here are defined in Runtime-async optimizations. These runtime-async-specific rewrites supplement forced ordinary optimizations such as inlining and eta-expansion. Some are needed to keep suspensions in the enclosing runtime-async method.
| Rewrite | Behavior and implementation |
|---|---|
| Await-input local elimination | Optimizer.fs (inlineAwaitInput) folds a single-use local into a suspension argument through calls, coercions, and constructions, moving only past safe expressions. This preserves evaluation order and exposes adjacent task-producing calls and awaits for the runtime to fuse. |
| Callback-capture floating | Optimizer.fs (FloatInlineIfLambdaCaptures) moves capture bindings and effect prefixes outside an InlineIfLambda lambda or delegate binding, exposing the callback to ordinary inlining without changing when captures are evaluated. |
| Returned-closure reduction | RuntimeAsyncAnalysis.fs (ReduceRuntimeAsyncReturnedClosureApplications) pushes applications through bindings, sequencing, and match targets to beta-reduce returned lambdas, subject to argument-movement safety. This exposes suspensions in closures returned by builder combinators. |
| Single-use delegate inlining | LowerRuntimeAsync.fs (tryPrepareDelegateInvocation / inlineDelegate) substitutes a residual InlineIfLambda delegate body at its sole direct invocation when its wrapped or conditional construction is safe to move. |
| Callback defunctionalization | LowerRuntimeAsync.fs (tryDefunctionalizeCallback / mkCallbackDispatch) replaces branch-selected suspending callbacks with a tag and hoisted captures. Construction runs once; each invocation binds its argument once and dispatches to copied branch bodies with shared mutable state. Copies across all invocations, including those nested in arguments, have a total 2000-expression-node budget. |
There is also a runtime-async-specific reoptimization step in
Optimizer.fs (OptimizeDebugPipeRights): after rebuilding a debug pipe chain,
it reoptimizes with synthetic pipe-input bindings in scope. This enables
existing optimizations on callbacks that capture those inputs; it is not a
separate rewrite.
Returned-closure reduction and callback lowering share construction-prefix
recognition and rebuilding (RuntimeAsyncConstructionPrefix in RuntimeAsync.fs).
Each rewrite retains its own movement checks, branch handling, and lambda reduction.
LowerRuntimeAsync.fs runs once per file after the first optimization loop,
so it sees the final inlined shape rather than an intermediate optimizer
state, and before LowerLocalMutables, so mutable locals it introduces that
closures capture are promoted to reference cells. It has two steps.
First, it applies the callback rewrites listed above to compiler-owned,
single-argument InlineIfLambda functions and delegates within runtime-async
bodies or sequence recipes, including callbacks bound immediately before a
runtime-async body and those captured by their construction. The outer pass
normalizes children bottom-up, then selects and applies one callback rewrite.
Successful replacements are revisited because substitution can make enclosing
callbacks eligible; rejected candidates are left unchanged. A suspending
construction used directly as an Invoke receiver is bound to a compiler-owned
callback before lowering; receiver evaluation still precedes argument evaluation.
Eligibility, captures, branch sizes, and use counts are checked before
rewriting. Escaping callbacks, opaque consumers, quotations retaining the
callback, unsupported tails such as try/with, and unsafe byref or pinned
captures are rejected. Effectful delegate precomputations stay at construction.
Only specialized copies change; exported inline definitions and signatures
remain intact. Keeping suspensions in the enclosing method preserves
ExecutionContext effects such as AsyncLocal writes in source order.
Rejected callbacks remain closures: IlxGen diagnoses a suspension in an
unmarked closure or delegate Invoke with FS3918, even when its enclosing
method is runtime-async.
Second, every return-marker body is prepared once after callbacks are inlined,
innermost first: locals that cannot be preserved across a suspension are
reported (once per range across the compilation), and suspending exception
handlers are rewritten. Preparing afterwards means an Await that callback
or delegate inlining moves into a handler is still rewritten.
__runtimeAsyncSequence recipes are not prepared here; LowerAsyncSeq
prepares their MoveNextAsync body after state-machine conversion, which is
where sequence try/with is lowered.
The later optimization loops (ExtraLoop, FinalSimplify) do not repeat the
preparation. They rely on every suspending body having been inlined by the
first loop, so they cannot move a suspension into a handler or a closure.
Hoisted mutable captures keep their source name through TOp.DebugLocalScope.
IlxGen relies on LowerLocalMutables having boxed every such capture that a
closure reads, so the scope names either the local slot itself or a copy of
the shared reference cell.
When runtime-async specialization is forced in a debug build, the builder combinator is copied with its definition-site debug ranges remarked at the call site. User continuation arguments keep their own ranges, and the marked method retains a call-site sequence point even when forced inlining reduces its intermediate closures.
Callback lowering shares recognition and rebuilding of transparent debug points and lexical-scope annotations. Rebuilding retains the original range, scope name, and storage reference; a lexical scope is not discarded when reducing an application through its wrapper.
Dead branches eliminated by optimization do not reach code generation and do not produce a suspension-outside-runtime-async diagnostic.
Runtime-async boundary recognition is centralized in
TypedTree/RuntimeAsync.fs. IsRuntimeAsyncBoundary (with the more specific
TryGetRuntimeAsyncReturn and IsRuntimeAsyncSuspensionExpr) lets consumers
use the shared recognizers rather than matching typed-tree shapes
independently.
The optimizer and the lowering pass use a context-local RuntimeAsyncAnalyzer. It memoizes
completed expression results by reference identity and inline-value results by
value stamp, with a visiting set for recursive inline-value graphs. The cache
is not global: optimizer environments can provide different inline bodies, and
optimization creates new expression trees. Context-dependent decisions such as
runtimeAsyncContext remain outside the cached facts.
An optimizer analyzer lives for one application-inlining attempt; callback lowering shares an analyzer for one implementation file, and exception rewriting creates one per body. Flow-summary caches are also per body. Custom recursive analysis and callback reconstruction use stack guards, including traversals between inline-value bodies and through decision trees. Callback capture analysis uses the guarded free-variable collector.
IlxGen.fs recognises the return-marker family in three placements through the
shared runtime-async boundary contract, which strips DebugPoint wrappers:
- Method body (
GenMethodForBinding): the marker is unwrapped from the top of the method lambda body; the generatedILMethodDefgets.WithAsync(true), which sets impl attribute bit0x2000(MethodImplOptions.Async, written as a literal because older reference assemblies do not define the enum member). - Closure body (
GenClosureAsLocalTypeFunctionandGenClosureAsFirstClassFunction): the same unwrapping marks the closure method's IL body (ILMethodBody.IsRuntimeAsync). Local type functions markDirectInvokeexplicitly; for first-class closures,EraseClosures.convIlxClosureDefcopies the flag onto the emittedInvokemethod. - Any other expression position (
GenRuntimeAsyncReturnAsStartedTask), e.g. alet-bound value initializer: the marker application is wrapped in a freshfun () -> ...lambda that is immediately applied tounitand regenerated. The lambda flows through the closure path (2), producing a generated runtime-async helper method whose call starts the task. This relies onGenAppnever beta-reducing a lambda application (it always emits a closure plus an indirect call); see the comment atGenRuntimeAsyncReturnAsStartedTask.
Named methods, closures, and sequence methods share marker unwrapping and unit-return classification. Both closure emission paths also share captured value validation, runtime-async environment setup, and IL-body marking.
A marker that ends up wrapped in anything other than DebugPoint at the top
of a method or closure body is not detected there, but still reaches the
catch-all case (3), so compilation stays correct — the cost is an extra
nested runtime-async helper method rather than marking the enclosing method
directly.
The compiler emits ordinary Portable PDB sequence points for runtime-async
methods. It does not emit StateMachineMethod or async state-machine stepping
records because runtime-async methods have no compiler-generated MoveNext
method. Forced inlining therefore preserves user computation-expression
sequence points in the generated runtime-async method while remapping the
inlined builder implementation ranges.
Suspension, continuation mapping, and reconstruction of logical async call
stacks are owned by the runtime and debugger through the Async method
implementation flag and the runtime-async debug information contract. Missing
logical frames after a continuation cannot be repaired by inventing F# state
machine metadata; such cases must be validated against the target runtime and
tracked with the runtime/debugger implementation.
Case (3) re-homes the marker argument into a compiler-synthesized closure
during code generation, after LowerLocalMutables has run. Without special
handling, mutable locals used both in that body and in the enclosing scope
would be copied into the closure by value, silently disconnecting the two
copies. LowerLocalMutables therefore treats the marker argument as a lambda
body (DecideExpr), promoting its free mutable locals to reference cells so
the synthesized closure and the enclosing scope share them.
InvokeFast is not a separate runtime-async path. It is the closure-erasure
shape for an indirect call with multiple arguments. Forced inlining and
eligible callback inlining happen before closure erasure; a suspension left
behind an opaque indirect invocation is rejected by code generation.
InfoReader gates LanguageFeature.RuntimeAsync on the target reference
assemblies: it looks up the Async field on
System.Runtime.CompilerServices.MethodImplOptions. This is a metadata-only
probe of the reference assemblies; it does not prove the executing host
JIT supports runtime-async. Compiling against new reference assemblies and
running on an older runtime is not a supported configuration.
The feature is usable from an inline computation-expression builder. A
task-like builder can keep Delay and its other combinators synchronous and
inline; Run introduces the return marker:
type RuntimeTaskBuilder() =
member inline _.Delay([<InlineIfLambda>] generator: unit -> 'T) = generator
member inline _.Run([<InlineIfLambda>] code: unit -> 'T) =
__runtimeAsyncReturn (code ())
member inline _.Bind(task: Task<'T>, [<InlineIfLambda>] continuation: 'T -> 'U) =
continuation (AsyncHelpers.Await task)Sample builders for tasks (runtimeTask), cancellable computations (async2) and async sequences (asyncSeq2) live with the component tests. They are examples, not FSharp.Core APIs.
Inline runtime-async code that escapes as a first-class value, is passed to a non-inline function, or is dynamically dispatched cannot be inlined into the enclosing runtime-async method. If a suspension remains in the generated non-runtime-async method, code generation reports FS3918 rather than emitting an unsafe closure. Code in statically eliminated branches does not trigger this diagnostic.
__runtimeAsyncSequence turns a statically known unit -> seq<'T> recipe into an IAsyncEnumerable<'T>. It reuses sequence lowering, but emits a single runtime-async MoveNextAsync(): ValueTask<bool> state machine. Awaits in the recipe stay in this method, and yield positions persist between calls. Ordinary nested sequences remain synchronous.
let values (work: Task<int>) =
__runtimeAsyncSequence (fun () -> seq {
yield AsyncHelpers.Await work
try
yield AsyncHelpers.Await work + 1
with _ ->
AsyncHelpers.Await(Task.Delay 10)
yield -1
})__runtimeAsyncSequenceCancellationToken() returns the token passed to GetAsyncEnumerator. Each resume checks this token before running recipe code; awaiting a non-cancellable operation does not make it cancellable.
Generated types derive from GeneratedRuntimeAsyncSequenceBase<'T>, which implements the enumerator interfaces:
- The first
GetAsyncEnumeratorreuses the sequence instance; later calls return independent clones. - A second
MoveNextAsync, or aDisposeAsync, while a move is pending throwsInvalidOperationException. The generatedMoveNextAsyncreleases this guard throughCompleteMoveNextbefore its result completes.
try/with and try/finally blocks are part of the MoveNextAsync state machine, so bodies, guards, handlers and finally blocks can all await:
- A fault runs the pending
finallyblocks, then jumps to the innermost handler. Cleanup completes before guards run, as in ordinarytry/with; a cleanup failure replaces the original exception. DisposeAsyncmarks the enumerator as disposing and runsMoveNextAsynconce more. That run executes the pendingfinallyblocks and skips handlers.
Other behavior:
- Recipes are normalized even with
--optimize-. This can remove intermediate recipe locals but does not optimize surrounding code. - Unhandled faults rethrow with their original dispatch information. Successful moves allocate no exception-transport object.
- Early disposal keeps ordinary sequence precedence: an outer cleanup failure replaces an inner one.
- Runtime-async inputs that are consumed immediately stay adjacent to their awaits, so the runtime can fuse the calls.
- Opaque recipes are rejected.
- The compiler does not hand off tail
yield!calls; a builder can, as the sampleasyncSeq2does. - Builder-generated
MoveNextAsyncmethods can lose sequence points, so source stepping is not guaranteed.
Run the async-sequence tests with:
./build.sh -c Release
dotnet test --project tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj \
-c Release --no-build -- --filter-class 'Language.RuntimeAsyncSequenceTests'- Complete diagnostics for runtime restrictions. Runtime-async lowering rewrites
suspending
try/withhandlers and filters, andtry/finallycompensations, so they execute outside exception-handling regions. There is no general diagnostic for runtime-contract violations in other generated or imported shapes. Stack allocation has a dedicated diagnostic (FS3920), and runtime-async methods suppresstail.emission rather than reporting it. - A builder in FSharp.Core; builders using the feature are currently application/library code.
- Compile-time enforcement that every marker use was consumed during code generation (a missed marker throws only when its FSharp.Core stub is reached at run time).