Skip to content
AgileLogics
8 min read

Async and Task

Prerequisites: Computation expressions

Programs spend much of their time waiting: for a web request, a database, a file. Asynchronous code lets a thread do other work while it waits. F# has two ways to write it:

  • task { } — produces a .NET Task<'T>, the same type C# async methods use. It starts running immediately ("hot"). Use it for ASP.NET Core, most .NET libraries, and performance-sensitive code.
  • async { } — produces an F# Async<'T>, a description of work that runs only when you start it ("cold"). It has built-in cancellation and composes nicely.

task { }

open System.Threading.Tasks

let fetchPrice (product: string) =
    task {
        do! Task.Delay 100      // simulate a slow network call
        return product.Length * 10
    }

let total =
    task {
        let! tea = fetchPrice "tea"
        let! coffee = fetchPrice "coffee"
        return tea + coffee
    }

printfn "%d" total.Result
90

let! awaits a task; do! awaits one that returns nothing. .Result blocks until the task finishes — fine at the top of a script, but in real applications you let! all the way up and let the framework wait.

Running tasks in parallel

The two calls above run one after the other (about 200 ms in total). To run independent work at the same time, start all the tasks first, then wait for them together:

open System.Diagnostics
open System.Threading.Tasks

let work (name: string) (ms: int) =
    task {
        do! Task.Delay ms
        return $"{name} done"
    }

let sw = Stopwatch.StartNew()
let results =
    [ work "A" 300; work "B" 300; work "C" 300 ]
    |> Task.WhenAll
let all = results.Result
printfn "%A" all
printfn "took under 600 ms: %b" (sw.ElapsedMilliseconds < 600L)
[|"A done"; "B done"; "C done"|]
took under 600 ms: true

Three 300 ms operations finished in roughly 300 ms, not 900.

async { }

let download (name: string) =
    async {
        do! Async.Sleep 100
        return name.ToUpper()
    }

let job = download "report"          // nothing has run yet
printfn "created the job"
printfn "%s" (Async.RunSynchronously job)

let all =
    [ "a"; "b"; "c" ]
    |> List.map download
    |> Async.Parallel
    |> Async.RunSynchronously

printfn "%A" all
created the job
REPORT
[|"A"; "B"; "C"|]
  • Async.Parallel runs a list of asyncs concurrently and collects the results in order. Async.Parallel(jobs, maxDegreeOfParallelism = 4) limits how many run at once.
  • Async.RunSynchronously starts an async and waits for it; Async.Start starts it without waiting.
  • Async.AwaitTask turns a Task into an Async; Async.StartAsTask goes the other way.

Cancellation

Async workflows check for cancellation automatically at every let! and do!:

open System.Threading

let slowCount =
    async {
        for i in 1 .. 10 do
            do! Async.Sleep 200
            printfn "tick %d" i
    }

let cts = new CancellationTokenSource(500)   // cancel after 500 ms
try
    Async.RunSynchronously(slowCount, cancellationToken = cts.Token)
with :? System.OperationCanceledException ->
    printfn "cancelled"
tick 1
tick 2
cancelled

For task { }, pass a CancellationToken explicitly to the methods you call (Task.Delay(ms, token), httpClient.GetAsync(url, token)).

A real HTTP call

// illustrative: needs network access
open System.Net.Http

let client = new HttpClient()

let pageSize (url: string) =
    task {
        let! html = client.GetStringAsync url
        return url, html.Length
    }

let sizes =
    [ "https://fsharp.org"; "https://dotnet.microsoft.com" ]
    |> List.map pageSize
    |> System.Threading.Tasks.Task.WhenAll

for (url, size) in sizes.Result do
    printfn "%s: %d characters" url size

Create one HttpClient and reuse it — creating one per request can exhaust network sockets under load.

Which one should I use?

Use task { } by default when working with .NET libraries and web frameworks. Use async { } when you want cold, composable workflows with implicit cancellation — for example, in agents (next lesson) and background jobs.

Common mistakes

  • Calling .Result or Async.RunSynchronously deep inside a web handler — it blocks a thread and can deadlock or starve the server. Stay asynchronous all the way down.
  • Forgetting that a task starts immediately: creating tasks in a list already runs them.