Skip to content

Commit

Permalink
feat/async_v2: revamping Asynchronous Programming section (#36)
Browse files Browse the repository at this point in the history
* feat/async_v2: revamping Asynchronous Programming section

Co-authored-by: ⚙︎ Greg <gwj.holden@gmail.com>
Co-authored-by: Ruben Bartelink <ruben@bartelink.com>
  • Loading branch information
3 people authored Jul 10, 2024
1 parent 3fe7b80 commit 51e0f11
Showing 1 changed file with 148 additions and 94 deletions.
242 changes: 148 additions & 94 deletions docs/fsharp-cheatsheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ See [Records (MS Learn)](https://learn.microsoft.com/en-us/dotnet/fsharp/languag

// Create
let anonRecord1 = {| Name = "Don Syme"; Language = "F#"; Age = 999 |}

// Copy and Update
let anonRecord2 = {| anonRecord1 with Name = "Mads Torgersen"; Language = "C#" |}

Expand Down Expand Up @@ -553,7 +553,7 @@ The `let..match..with` statement can be simplified using just the `function` sta
match num with
| 1 | 2 | 3 -> printfn "Found 1, 2, or 3!"
| a -> printfn "%d" a

let filterNumbers' = // the paramater and `match num with` are combined
function | 1 | 2 | 3 -> printfn "Found 1, 2, or 3!"
| a -> printfn "%d" a
Expand Down Expand Up @@ -746,113 +746,167 @@ A *Partial active pattern* must return an `Option<'T>`.
| DivisibleBy 5 -> "Buzz"
| i -> string i

*Partial active patterns* share the syntax of parameterized patterns but their active recognizers accept only one argument.

<div id="asynchronous-programming"></div>

## Asynchronous Programming

F# asynchronous programming is centered around two core concepts: async computations and tasks.
F# asynchronous programming support consists of two complementary mechanisms::
- .NET's Tasks (via `task { }` expressions). This provides semantics very close to that of C#'s `async`/`await` mechanism, requiring explicit direct management of `CancellationToken`s.
- F# native `Async` computations (via `async { }` expressions). Predates `Task`. Provides intrinsic `CancellationToken` propagation.

async {
do! Async.Sleep (waitInSeconds * 1000)
let! asyncResult = asyncComputation
use! disposableResult = iDisposableAsyncComputation
### .NET Tasks

In F#, .NET Tasks can be constructed using the `task { }` computational expression.
.NET Tasks are "hot" - they immediately start running. At the first `let!` or `do!`, the `Task<'T>` is returned and execution continues on the ThreadPool.

open System
open System.Threading
open System.Threading.Tasks
open System.IO

let readFile filename ct = task {
printfn "Started Reading Task"
do! Task.Delay((TimeSpan.FromSeconds 5), cancellationToken = ct) // use do! when awaiting a Task
let! text = File.ReadAllTextAsync(filename, ct) // use let! when awaiting a Task<'T>, and unwrap 'T from Task<'T>.
return text
}

### Async vs Tasks

An async computation is a unit of work, and a task is a promise of a result. A subtle but important distinction.
Async computations are composable and are not started until explicitly requested; Tasks (when created using the `task` expression) are _hot_:

let runAsync waitInSeconds =
async {
printfn "Created Async"
do! Async.Sleep (waitInSeconds * 1000)
printfn $"Completed Async"
}
let runTask waitInSeconds =
task {
printfn "Started Task"
do! System.Threading.Tasks.Task.Delay (waitInSeconds * 1000)
printfn $"Completed Task"
}

let asyncComputation = runAsync 5 // returns Async<unit> and does not print anything
let newTask = runTask 3 // returns System.Threading.Tasks.Task<unit> and outputs: "Started Task" <3 second delay> "Completed Task"

asyncComputation |> Async.RunSynchronously // this now runs the async computation
newTask.Wait() // this will have already completed by this point
let readFileTask: Task<string> = readFile "myfile.txt" CancellationToken.None // (before return) Output: Started Reading Task

// Output:
// Started Task
// Created Async
// Completed Task
// Completed Async
// (readFileTask continues execution on the ThreadPool)

### Async and Task Interop
let fileContent = readFileTask.Result // Blocks thread and waits for content. (1)
let fileContent' = readFileTask.Result // Task is already completed, returns same value immediately; no output

As F# sits in .NET, and a lot of the codebase uses the C# async/await, the majority of actions are going to be executed and tracked using `System.Threading.Tasks.Task<'T>`s.
(1) `.Result` used for demonstration only. Read about [async/await Best Practices](https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming#async-all-the-way)

#### Async.AwaitTask
### Async Computations

This converts a Task into an async computation. It has the [signature](#functions-signatures): `Task<'T>` -> `Async<'T>`
Async computations were invented before .NET Tasks existed, which is why F# has two core methods for asynchronous programming. However, async computations did not become obsolete. They offer another, but different, approach: dataflow.
Async computations are constructed using `async { }` expressions, and the [`Async` module](https://fsharp.github.io/fsharp-core-docs/reference/fsharp-control-fsharpasync.html#section3) is used to compose and execute them.
In contrast to .NET Tasks, async expressions are "cold" (need to be explicitly started) and every execution [propagates a CancellationToken implicitly](#asynchronous-programming-cancellation-async).

async {
let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
let fileName = Path.GetFileName(path)
printfn $"File {fileName} has %d{bytes.Length} bytes"
open System
open System.Threading
open System.IO

let readFile filename = async {
do! Async.Sleep(TimeSpan.FromSeconds 5) // use do! when awaiting an Async
let! text = File.ReadAllTextAsync(filename) |> Async.AwaitTask // (1)
printfn "Finished Reading File"
return text
}

#### Async.StartAsTask

This converts an async computation into a Task. It has the [signature](#functions-signatures): `Async<'T>` -> `Task<'T>`

async {
do! Async.Sleep 5000
} |> Async.StartAsTask

### Async and the ThreadPool

Below is a demonstration of the different ways to start an async computation and where it ends up in the dotnet runtime.
In the comments; `M`, `X`, `Y`, and `Z` are used to represent differences in values.

#r "nuget:Nito.AsyncEx, 5.1.2"

let asyncTask from =
async {
printfn $"{from} Thread Id = {System.Threading.Thread.CurrentThread.ManagedThreadId}"
}

let run () =
printfn $"run() Thread Id = {System.Threading.Thread.CurrentThread.ManagedThreadId}"
asyncTask "StartImmediate" |> Async.StartImmediate // run and wait on same thread
asyncTask "Start" |> Async.Start // queue in ThreadPool and do not wait
asyncTask "RunSynchronously" |> Async.RunSynchronously // run and wait; depends
printfn ""

run ()
// run() Thread Id = M - Main, non-ThreadPool thread
// StartImmediate Thread Id = M - started, waited, and completed on run() thread
// Start Thread Id = X - queued and completed in ThreadPool
// RunSynchronously Thread Id = Y - started, waited, and completed in ThreadPool
// Important: As `Start` is queued in the ThreadPool, it might finish before `RunSynchronously` and they will share the same number

// Run in ThreadPool
async { run () } |> Async.RunSynchronously
// run() Thread Id = X - ThreadPool thread
// StartImmediate Thread Id = X - started, waited, and completed on run() thread
// Start Thread Id = Y - queued and completed in new ThreadPool thread
// RunSynchronously Thread Id = X - started, waited, and completed on run() thread
// Important: `RunSynchronously` behaves like `StartImmediate` when on a ThreadPool thread without a SynchronizationContext

// Run in ThreadPool with a SynchronizationContext
async { Nito.AsyncEx.AsyncContext.Run run } |> Async.RunSynchronously
// run() Thread Id = X - ThreadPool thread
// StartImmediate Thread Id = X - started, waited, and completed on run() thread
// Start Thread Id = Y - queued and completed in new ThreadPool thread
// RunSynchronously Thread Id = Z - started, waited, and completed in new ThreadPool thread
// compose a new async computation from exising async computations
let readFiles = [ readFile "A"; readFile "B" ] |> Async.Parallel

// execute async computation
let textOfFiles: string[] = readFiles |> Async.RunSynchronously
// Out: Finished Reading File
// Out: Finished Reading File

// re-execute async computation again
let textOfFiles': string[] = readFiles |> Async.RunSynchronously
// Out: Finished Reading File
// Out: Finished Reading File

(1) As .NET Tasks became the central component of task-based asynchronous programming after F# Async were introduced, F#'s Async has `Async.AwaitTask` to map from `Task<'T>` to `Async<'T>`. Note that cancellation and exception handling require [special considerations](https://github.com/fsprojects/FSharp.Control.TaskSeq/issues/141).

#### Creation / Composition

The `Async` module has a number of functions to compose and start computations. The full list with explanations can be found in the [Async Type Reference](https://fsharp.github.io/fsharp-core-docs/reference/fsharp-control-fsharpasync.html#section0).

| Function | Description |
|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Async.Ignore | Creates an `Async<unit>` computation from an `Async<'T>` |
| Async.Parallel | Composes a new computation from multiple computations, `Async<'T> seq`, and runs them in parallel; it returns all the results in an array `Async<'T[]>` |
| Async.Sequential | Composes a new computation from multiple computations, `Async<'T> seq`, and runs them in series; it returns all the results in an array `Async<'T[]>` |
| Async.Choice | Composes a new computation from multiple computations, `Async<'T option> seq`, and returns the first where `'T'` is `Some value` (all others running are canceled). If all computations return `None` then the result is `None` |

For all functions that compose a new computation from children, if any child computations raise an exception, then the overall computation will trigger an exception. The `CancellationToken` passed to the child computations will be triggered, and execution continues when all running children have cancelled execution.

#### Executing

| Function | Description |
|------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| Async.RunSynchronously | Runs an async computation and awaits its result. |
| Async.StartAsTask | Runs an async computation on the ThreadPool and wraps the result in a `Task<'T>`. |
| Async.StartImmediateAsTask | Runs an async computation, starting immediately on the current operating system thread, and wraps the result in a `Task<'T>` |
| Async.Start | Runs an `Async<unit>` computation on the ThreadPool (without observing any exceptions). |
| Async.StartImmediate | Runs a computation, starting immediately on the current thread and continuations completing in the ThreadPool. |

### Cancellation

#### .NET Tasks

.NET Tasks do not have any intrinsic handling of `CancellationToken`s; you are responsible for passing `CancellationToken`s down the call hierarchy to all sub-Tasks.

open System
open System.Threading
open System.Threading.Tasks

let loop (token: CancellationToken) = task {
for cnt in [ 0 .. 9 ] do
printf $"{cnt}: And..."
do! Task.Delay((TimeSpan.FromSeconds 2), token) // token is required for Task.Delay to be interruptible
printfn "Done"
}

let cts = new CancellationTokenSource (TimeSpan.FromSeconds 5)
let runningLoop = loop cts.Token
try
runningLoop.GetAwaiter().GetResult() // (1)
with :? OperationCanceledException -> printfn "Canceled"

Output:

0: And...Done
1: And...Done
2: And...Canceled

(1) `.GetAwaiter().GetResult()` used for demonstration only. Read about [async/await Best Practices](https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming#async-all-the-way)

<div id="asynchronous-programming-cancellation-async"></div>

#### Async

Asynchronous computations have the benefit of implicit `CancellationToken` passing and checking.

open System
open System.Threading
open System.Threading.Tasks

let loop = async {
for cnt in [ 0 .. 9 ] do
printf $"{cnt}: And..."
do! Async.Sleep(TimeSpan.FromSeconds 1) // Async.Sleep implicitly receives and checks `cts.Token`

let! ct = Async.CancellationToken // when interoperating with Tasks, cancellationTokens need to be passed explicitly
do! Task.Delay((TimeSpan.FromSeconds 1), cancellationToken = ct) |> Async.AwaitTask

printfn "Done"
}

let cts = new CancellationTokenSource(TimeSpan.FromSeconds 5)
try
Async.RunSynchronously (loop, Timeout.Infinite, cts.Token)
with :? OperationCanceledException -> printfn "Canceled"

Output:

0: And...Done
1: And...Done
2: And...Canceled

All methods for cancellation can be found in the [Core Library Documentation](https://fsharp.github.io/fsharp-core-docs/reference/fsharp-control-fsharpasync.html#section3)

### More to Explore

Asynchronous programming is a vast topic. Here are some other resources worth exploring:

- [Asynchronous Programming in F#](https://learn.microsoft.com/en-us/dotnet/fsharp/tutorials/async) - Microsoft's tutorial guide. Recommended as it is up-to-date and expands on some of the topics here.
- [Iced Tasks](https://github.com/TheAngryByrd/IcedTasks?tab=readme-ov-file#icedtasks) - .NET Tasks start immediately. The IcedTasks library provide additional [computational expressions](https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions) such as `cancellableTask`, which combines the benefits of .NET Tasks (natural interoperation with Task APIs and the performance benefits of the `task`'s State-Machine based implementation) with asynchronous expressions (composability, implicit `CancellationToken` passing, and the fact that you can invoke (or retry) a given computation multiple times).
- [Asynchronous Programming Best Practices](https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md#table-of-contents) by David Fowler - offers a fantastic list of good practices for .NET Task usage.

<div id="code-organization"></div>

Expand Down

0 comments on commit 51e0f11

Please sign in to comment.