Logo F# Compiler Guide

Runtime async

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:

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 contract

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:

The compiler emits the adjacent IL sequence the runtime specification expects:

call Task<int32> SomeAsyncMethod(...)
call int32 AsyncHelpers::Await<int32>(Task<int32>)

Known runtime restrictions (currently not diagnosed by the F# compiler):

Byref, byref-like, and pinned locals that are used after a suspension are rejected with diagnostic FS3917.

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.

F# surface

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.ValueTask

Their FSharp.Core implementations throw; the compiler consumes every occurrence before code generation, so those bodies are never executed. They are marked NoInlining so a missed consumption does not silently fold into a caller.

The feature is gated on langversion:preview (LanguageFeature.RuntimeAsync) and 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 42

There 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.

Type checking

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:

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.

Optimization

Optimizer.fs preserves the marker application as-is, optimizing its argument and rewriting any suspending exception handlers in that argument. The marked expression is forced to HasEffect = true and UnknownValue, so the optimizer never inlines, duplicates, or discards it. 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 return 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. The optimizer follows nested inline calls and does not create a generated helper method for the specialized suspension fragment, keeping every suspension in the eventual runtime-async method.

After specialization, lambda arguments are substituted and their applications are beta-reduced before and after runtime-async reoptimization. This includes debug-point-wrapped lambdas, compiler-generated let wrappers, curried applications, and multi-argument lambdas. That step is required for computation-expression shapes where Bind returns a closure containing Await, and later Combine/Delay calls apply that closure.

When runtime-async specialization is forced in a debug build, the builder combinator is copied with its definition-site debug ranges remarked before arguments are substituted. User continuation arguments keep their own ranges, so let!, do!, yield, and other computation-expression statements remain associated with the source that authored them without exposing the implementation ranges of Run, Bind, Combine, or Yield.

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 uses 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.

Code generation

IlxGen.fs recognises the return-marker family in three placements through the shared runtime-async boundary contract, which strips DebugPoint wrappers:

  1. Method body (GenMethodForBinding): the marker is unwrapped from the top of the method lambda body; the generated ILMethodDef gets .WithAsync(true), which sets impl attribute bit 0x2000 (MethodImplOptions.Async, written as a literal because older reference assemblies do not define the enum member).
  2. Closure body (GenClosureAsLocalTypeFunction and GenClosureAsFirstClassFunction): the same unwrapping marks the closure Invoke method's IL body (ILMethodBody.IsRuntimeAsync). EraseClosures.convIlxClosureDef copies that flag onto the emitted method.
  3. Any other expression position (GenRuntimeAsyncReturnAsStartedTask), e.g. a let-bound value initializer: the marker application is wrapped in a fresh fun () -> ... lambda that is immediately applied to unit and regenerated. The lambda flows through the closure path (2), producing a generated runtime-async helper method whose call starts the task. This relies on GenApp never beta-reducing a lambda application (it always emits a closure plus an indirect call); see the comment at GenRuntimeAsyncReturnAsStartedTask.

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.

Debug stepping and call stacks

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. Fragment substitution and beta reduction happen before closure erasure; if a suspending fragment survives until an indirect InvokeFast call, it is still outside a runtime-async method and is rejected by code generation.

Runtime capability check

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.

Computation-expression usage

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)

Bind, ReturnFrom, and MergeSources can use Await for known Task/ValueTask types and SRTP awaiter operations for arbitrary task-like values. MergeSources awaits its already-started sources sequentially. Async<'T> can be adapted with Async.StartImmediateAsTask.

An async-sequence builder can use the same pattern to produce IAsyncEnumerable<'T>. Its Run creates a producer that is started when GetAsyncEnumerator is called. A ManualResetValueTaskSourceCore handshake makes enumeration pull-driven: yield publishes one item and waits for the next MoveNextAsync request. yield! and for can consume synchronous or asynchronous enumerables, and nested async enumerables receive the caller's cancellation token. A single active MoveNextAsync is enforced. A builder may also hand off directly between compatible producers for YieldFromFinal, avoiding a second enumeration handshake.

These builders are examples rather than FSharp.Core APIs. Applications can define their own inline builders over the same intrinsics, subject to the runtime-async restrictions and inline-fragment rules described above.

Direct async-sequence proposal

__runtimeAsyncSequence consumes a statically known unit -> seq<'T> recipe. It reuses sequence lowering, but emits runtime-async MoveNextAsync(): ValueTask<bool> and DisposeAsync(): ValueTask methods on a reference type. Each resume checks the enumeration's cancellation token before running recipe code. User awaits remain in these methods. Yield positions persist between calls. Ordinary nested sequences remain synchronous.

Generated types implement the enumerator interfaces directly, without base-class forwarding. The first acquisition reuses the factory instance. Later acquisitions return independent, already-acquired clones. Immediately consumed runtime-async inputs remain adjacent to their awaits so the runtime can fuse the calls.

The example builder uses existing sequence combinators. Its Bind supports tasks, value tasks, and typed custom/configured awaiters. For and YieldFrom select synchronous or asynchronous enumeration through library overloads, not compiler syntax cases.

let values (work: Task<int>) (resource: IAsyncDisposable) =
    runtimeAsyncSeq {
        use cleanup = resource
        let! value = work
        for offset in [1; 2] do
            yield value + offset
        yield! [10]
        for value in runtimeAsyncSeq { yield 11 } do
            yield value
        yield! runtimeAsyncSeq { yield 12 }
    }

The reference builder uses the compiler-recognized cancellationToken() helper to pass the current enumeration token to nested IAsyncEnumerable sources. Recipes can call the same helper for explicit cancellation checks without taking a token parameter. The withCancellation (fun token -> runtimeAsyncSeq { ... }) adapter remains available when the recipe needs to capture the enumeration token explicitly.

let tokens =
    runtimeAsyncSeq {
        let token = cancellationToken()
        token.ThrowIfCancellationRequested()
        yield token
    }

Recipes undergo mandatory local normalization even with --optimize-. This does not enable optimization for surrounding code. It can remove intermediate recipe locals. Body faults await active cleanup before rethrowing with their original dispatch information. Successful moves do not allocate an exception-transport object.

When cleanup can suspend, the saved exception lives on the iterator instead of enlarging every runtime continuation. The failure path clears it even if cleanup throws.

Builder-generated MoveNextAsync can lose visible sequence points. The optimized control has no visible points, and its nonoptimized form omits the terminal yield's range. Direct-intrinsic and ordinary-sequence controls retain their ranges. This proposal does not guarantee complete source stepping.

This is a proposal, not a drop-in TaskSeq replacement. Producer try/with is lowered through an ordinary nested sequence, while runtime-async suspensions inside that handler remain unsupported. Opaque recipes and optimized tail handoff are rejected. Early disposal retains ordinary sequence exception precedence: an outer cleanup failure replaces an inner cleanup failure. Concurrent move/dispose calls are unsupported. Awaiting a non-cancellable operation does not make it cancellable. Performance qualification must include genuinely pending operations, not only completed awaits.

Build and run the small usage and lifecycle example with the matching preview SDK:

./build.sh -c Release
dotnet test --project tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj \
  -c Release --no-build --filter-class 'Language.RuntimeAsyncSequenceTests'

The tests compile the library and consumer together and separately, with optimization enabled and disabled.

Unsupported inline-fragment positions

An inline fragment that escapes as a first-class value, is passed to a non-inline function, or is dynamically dispatched cannot be preserved as a runtime-async suspension fragment. If the suspension remains in the generated non-runtime-async method, code generation reports FS3916 rather than emitting an unsafe closure. Fragments in statically eliminated branches do not trigger this diagnostic.

Not yet implemented

namespace System
namespace System.Threading
namespace System.Threading.Tasks
Multiple items
type Task<'TResult> = inherit Task new: ``function`` : Func<obj,'TResult> * state: obj -> unit + 7 overloads member ConfigureAwait: continueOnCapturedContext: bool -> ConfiguredTaskAwaitable<'TResult> + 1 overload member ContinueWith: continuationAction: Action<Task<'TResult>,obj> * state: obj -> Task + 19 overloads member GetAwaiter: unit -> TaskAwaiter<'TResult> member WaitAsync: cancellationToken: CancellationToken -> Task<'TResult> + 4 overloads member Result: 'TResult static member Factory: TaskFactory<'TResult>
<summary>Represents an asynchronous operation that can return a value.</summary>
<typeparam name="TResult">The type of the result produced by this <see cref="T:System.Threading.Tasks.Task`1" />.</typeparam>


--------------------
type Task = interface IAsyncResult interface IDisposable new: action: Action -> unit + 7 overloads member ConfigureAwait: continueOnCapturedContext: bool -> ConfiguredTaskAwaitable + 1 overload member ContinueWith: continuationAction: Action<Task,obj> * state: obj -> Task + 19 overloads member Dispose: unit -> unit member GetAwaiter: unit -> TaskAwaiter member RunSynchronously: unit -> unit + 1 overload member Start: unit -> unit + 1 overload member Wait: unit -> unit + 5 overloads ...
<summary>Represents an asynchronous operation.</summary>

--------------------
System.Threading.Tasks.Task(``function`` : System.Func<'TResult>) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<obj,'TResult>, state: obj) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<'TResult>, cancellationToken: System.Threading.CancellationToken) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<'TResult>, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<obj,'TResult>, state: obj, cancellationToken: System.Threading.CancellationToken) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<obj,'TResult>, state: obj, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<'TResult>, cancellationToken: System.Threading.CancellationToken, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<obj,'TResult>, state: obj, cancellationToken: System.Threading.CancellationToken, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task<'TResult>

--------------------
System.Threading.Tasks.Task(action: System.Action) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action, cancellationToken: System.Threading.CancellationToken) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action<obj>, state: obj) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action, cancellationToken: System.Threading.CancellationToken, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action<obj>, state: obj, cancellationToken: System.Threading.CancellationToken) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action<obj>, state: obj, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action<obj>, state: obj, cancellationToken: System.Threading.CancellationToken, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task
Multiple items
type ValueTask<'TResult> = new: source: IValueTaskSource<'TResult> * token: int16 -> unit + 2 overloads member AsTask: unit -> Task<'TResult> member ConfigureAwait: continueOnCapturedContext: bool -> ConfiguredValueTaskAwaitable<'TResult> member Equals: obj: obj -> bool + 1 overload member GetAwaiter: unit -> ValueTaskAwaiter<'TResult> member GetHashCode: unit -> int member Preserve: unit -> ValueTask<'TResult> member ToString: unit -> string static member (<>) : left: ValueTask<'TResult> * right: ValueTask<'TResult> -> bool static member (=) : left: ValueTask<'TResult> * right: ValueTask<'TResult> -> bool ...
<summary>Provides a value type that wraps a <see cref="T:System.Threading.Tasks.Task`1" /> and a <typeparamref name="TResult" />, only one of which is used.</summary>
<typeparam name="TResult">The result.</typeparam>


--------------------
type ValueTask = new: source: IValueTaskSource * token: int16 -> unit + 1 overload member AsTask: unit -> Task member ConfigureAwait: continueOnCapturedContext: bool -> ConfiguredValueTaskAwaitable member Equals: obj: obj -> bool + 1 overload member GetAwaiter: unit -> ValueTaskAwaiter member GetHashCode: unit -> int member Preserve: unit -> ValueTask static member (<>) : left: ValueTask * right: ValueTask -> bool static member (=) : left: ValueTask * right: ValueTask -> bool static member FromCanceled: cancellationToken: CancellationToken -> ValueTask + 1 overload ...
<summary>Provides an awaitable result of an asynchronous operation.</summary>

--------------------
System.Threading.Tasks.ValueTask ()
System.Threading.Tasks.ValueTask(task: System.Threading.Tasks.Task<'TResult>) : System.Threading.Tasks.ValueTask<'TResult>
System.Threading.Tasks.ValueTask(result: 'TResult) : System.Threading.Tasks.ValueTask<'TResult>
System.Threading.Tasks.ValueTask(source: System.Threading.Tasks.Sources.IValueTaskSource<'TResult>, token: int16) : System.Threading.Tasks.ValueTask<'TResult>

--------------------
System.Threading.Tasks.ValueTask ()
System.Threading.Tasks.ValueTask(task: System.Threading.Tasks.Task) : System.Threading.Tasks.ValueTask
System.Threading.Tasks.ValueTask(source: System.Threading.Tasks.Sources.IValueTaskSource, token: int16) : System.Threading.Tasks.ValueTask
type unit = Unit
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
Multiple items
type InlineIfLambdaAttribute = inherit Attribute new: unit -> InlineIfLambdaAttribute

--------------------
new: unit -> InlineIfLambdaAttribute
val task: TaskBuilder

Type something to start searching.