Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

FSharp.Control.Async2

A reimplementation of F# async on .NET runtime async. It is named Async2 / async2 so it can live next to FSharp.Core's Async / async, and it follows their API and semantics unless listed below.

  • FSharp.Control.Async2: async2, Async2 and MailboxProcessor2.
  • FSharp.Control.AsyncSeq2: asyncSeq2 and the AsyncSeq2 operations.

Experimental: it needs a preview F# compiler with runtime-async support (see Building).

Differences from async

  • async2 binds Task, ValueTask and any awaitable directly, as well as F# Async. No Async.AwaitTask needed.
  • let! ct = CurrentCancellationToken reads the token without starting a computation. Async2.CancellationToken also works but costs a small start.
  • A tail-position return! hands over to its callee and the caller's frame finishes, so the caller does not appear in stack traces. The callee still starts in the caller's ExecutionContext and sees its AsyncLocal values.
  • Deep non-tail recursion (let! of nested async2) runs inline while the stack has room. When the stack runs low before the computation first suspends, it continues from a trampoline at the bottom of the calling thread's stack, as in async, so a computation that never suspends completes on the calling thread. After it has suspended, it continues on a fresh thread-pool thread instead. Under a SynchronizationContext the trampoline works the same way, but after a suspension nested starts run inline, since the thread pool would leave the context; very deep non-tail recursion there can overflow the stack. Under a non-default TaskScheduler nested starts always run inline.
  • A failure that propagates through many frames keeps the stack trace of its throw instead of growing at each frame.
  • Async2.SwitchToContext is not implemented.
  • Extra operations: Async2.RunSynchronouslyImmediate, Async2.Await and Async2.StartTaskImmediate for tasks and awaitables, and the module functions Async2.map, bind, catch, catchWith, parallelLimit and friends.

MailboxProcessor2<'Msg> is FSharp.Core's MailboxProcessor with an async2 body and AsyncReplyChannel2<'Reply> replies.

AsyncSeq2

asyncSeq2 { ... } builds an AsyncSeq2<'T>, which is IAsyncEnumerable<'T>. The token passed to GetAsyncEnumerator flows into every Async2 and nested sequence it binds. Terminal operations return Async2.

let numbers =
    asyncSeq2 {
        for i in 1 .. 3 do
            do! Async2.Sleep 10
            yield i
    }

let result = AsyncSeq2.toArray numbers |> Async2.RunSynchronously

AsyncSeq2 combines the operations of FSharp.Control.AsyncSeq and FSharp.Control.TaskSeq. Coming from AsyncSeq:

  • Async<'T> becomes Async2<'T>.
  • Signatures follow TaskSeq: counts and indexes are int, not int64, and groupBy, chunkBy and similar return arrays.
  • Behaviour follows AsyncSeq: take and skip stop quietly at the end, tail of an empty sequence is empty, and head, last, reduce, min, max and friends raise InvalidOperationException on an empty sequence.
  • prefetch takes no count; it reads one element ahead.

The asyncSeq2 builder is adapted from TaskSeq. See ASYNCSEQ-LICENSE.txt and TASKSEQ-LICENSE.txt in src/FSharp.Control.AsyncSeq2.

Building

global.json pins a .NET 11 preview SDK. Directory.Build.props takes the F# compiler and FSharp.Core from RuntimeAsyncBin, which defaults to c:\dev\repos\fsharp\artifacts\bin. Set RuntimeAsyncBin (or RuntimeAsyncBitsPath) to use another F# checkout.

dotnet build
dotnet test

The tests include ports of FSharp.Core's async tests, side-by-side checks against FSharp.Core's MailboxProcessor, AsyncSeq's 494 tests and TaskSeq's 74 test files, each adjusted for the differences above.

Benchmarks

benchmarks/FSharp.Control.Async2.Benchmarks has three kinds of benchmark.

RecursiveComputationBenchmarks measures async2 alone on eight workloads (recursion, cancellation, exceptions, try/with and try/finally at every level) at depths 16, 256 and 2048, awaiting Task.Yield() every 16 levels.

TopLevelStartBenchmarks measures many short starts that have no parent computation: let! of an Async2 in asyncSeq2, StartImmediateAsTask, short tail-call chains that suspend, and deep synchronous recursion.

The scenario benchmarks write one application workload three times, with async (the baseline), async2 and task, each as it is usually written in that flavour. Remote calls are Tasks that resume on the thread pool, and cache hits are synchronously completed ValueTasks.

Benchmark Workload
RequestPipeline A Giraffe-style middleware stack (try/with, try/finally, auth) over a read-through cache, at 100%, 90% and 0% cache hits
ScatterGather One page calling 4 or 32 services at once (Parallel / Task.WhenAll)
ThrottledUpload 256 uploads, at most 8 in flight (maxDegreeOfParallelism / Parallel.ForEachAsync)
TreeCrawl Recursive parallel crawl of a folder tree of fan-out 4
ChannelPipeline Three concurrent stages over bounded System.Threading.Channels of capacity 1 and 100
StreamParsing Counting lines of a 1 MB log read from a socket-like stream in 4 KB reads
Mailbox A metrics agent: a burst of posts, and sequential request/reply (MailboxProcessor, MailboxProcessor2, a channel-based task agent)
Retry A retry policy around an upstream that times out 0 or 2 times per call
Pagination A paginated API as an async sequence: filter, map through a cache, fold (AsyncSeq, AsyncSeq2, TaskSeq)
dotnet run -c Release --project benchmarks/FSharp.Control.Async2.Benchmarks/FSharp.Control.Async2.Benchmarks.fsproj

Pass BenchmarkDotNet options after --, such as -- --filter *DeepException*. -- --filter "*" --job Dry runs every case once as a quick smoke test.

benchmarks/OldBenchmarks keeps the earlier async2-only micro-benchmarks (tail-call and mutual recursion loops, among others). It takes the same BenchmarkDotNet command line.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages