Mastering F# Async Workflows: Parallel I/O & Cancellation
Learn how F# async workflows let you write non‑blocking I/O, compose parallel tasks, and control cancellation with minimal boilerplate. A concrete example shows how to run two HTTP calls concurrently and handle errors.
13 Sept 2025, 05:34 UTC

Problem
When building I/O‑heavy services in .NET, developers often resort to blocking calls or manual thread‑pool tricks. In F#, the idiomatic way to keep the main thread free while waiting for network or disk I/O is the async { … } computation expression. However, many teams struggle to understand how to compose multiple async operations, handle cancellation, and avoid subtle deadlocks.
The Async Workflow Model
F#’s async is a computation expression that encapsulates a value that will be produced asynchronously. Internally it maps to Async<'T>, which is essentially a wrapper around Task<'T> but with a richer syntax for composition.
Key operators:
do!– Await an async value and ignore the result.return– Wrap a value into an async context.!>>– Bind (>>=) – chain async computations.!>!– Parallel composition (Async.Parallel) – run two asyncs concurrently and wait for both.
Example of a simple async that delays and returns a number:
let delayedValue = async {
do! Task.Delay(1000) |> Async.AwaitTask
return 42
}
Running delayedValue |> Async.RunSynchronously blocks the calling thread for one second, but the delay itself is non‑blocking.
Parallel Composition with !>! and !>>
Suppose you need to fetch two resources from a web service. You can run them sequentially:
let fetchData1 = async { … }
let fetchData2 = async { … }
let combined = async {
let! r1 = fetchData1
let! r2 = fetchData2
return (r1, r2)
}
or in parallel using Async.Parallel (the !>! operator):
let combinedParallel = async {
let! results = [fetchData1; fetchData2] |> Async.Parallel
return results
}
The parallel version submits both requests to the .NET thread pool immediately, allowing I/O to overlap. The result is an array of the two responses.
Cancellation and Custom Schedulers
Async workflows accept a CancellationToken that propagates through all awaited operations. Pass the token explicitly:
let fetchWithCancel (token: CancellationToken) url = async {
let client = new HttpClient()
let! response = client.GetAsync(url, token) |> Async.AwaitTask
return! response.Content.ReadAsStringAsync() |> Async.AwaitTask
}
When you run the workflow, supply the token:
let cts = new CancellationTokenSource()
let task = fetchWithCancel cts.Token "https://example.com"
// cancel after 500 ms
System.Threading.Tasks.Task.Delay(500).ContinueWith(fun _ -> cts.Cancel())
let result = task |> Async.RunSynchronously
Custom schedulers can be injected via Async.StartWithContinuations or by using the Async.StartImmediate overloads, but for most I/O scenarios the default thread‑pool scheduler is adequate.
Example: Concurrent HTTP Requests with Error Handling
Below is a minimal, self‑contained example that:
- Starts two HTTP GET requests concurrently.
- Handles exceptions from either request.
- Supports cancellation via a token.
open System
open System.Net.Http
open System.Threading
open System.Threading.Tasks
let getAsync (token: CancellationToken) url = async {
use client = new HttpClient()
try
let! response = client.GetAsync(url, token) |> Async.AwaitTask
response.EnsureSuccessStatusCode() |> ignore
let! body = response.Content.ReadAsStringAsync() |> Async.AwaitTask
return Ok body
with
| :? OperationCanceledException -> return Error "Cancelled"
| ex -> return Error ex.Message
}
let runConcurrent () = async {
let cts = CancellationTokenSource(TimeSpan.FromSeconds(3.0))
let urls = [
"https://httpbin.org/delay/2";
"https://httpbin.org/status/404"
]
let tasks = urls |> List.map (getAsync cts.Token)
let! results = tasks |> Async.Parallel
return results
}
let finalResult = runConcurrent () |> Async.RunSynchronously
match finalResult with
| [| Ok body1; Ok body2 |] -> printfn "Both succeeded."
| _ -> printfn "One or more requests failed: %A" finalResult
Running this code will:
- Fetch the first URL, which delays 2 s before returning 200 OK.
- Attempt the second URL, which immediately returns 404, triggering
EnsureSuccessStatusCodeand throwing an exception. - Because the token has a 3 s timeout, the workflow completes before any manual cancellation is required.
Trade‑offs and Limitations
- Deadlocks: Mixing blocking calls (e.g.,
Task.ResultorThread.Sleep) inside an async block can cause deadlocks if the async is awaited on the same synchronization context. Stick toAsync.AwaitTaskand avoid blocking. - Overhead: Each async workflow incurs allocation of an
Asyncobject and scheduling on the thread pool. For hot loops with trivial work, a plain imperative loop may be faster. - Thread‑pool saturation: Launching many concurrent async operations can exhaust the thread pool if each operation performs CPU work. Use
Async.Parallelwith a limited degree of parallelism or switch toTask.Runwith a custom scheduler for CPU‑bound tasks.
Actionable Takeaways
- Wrap I/O calls in
async { … }and useAsync.AwaitTaskto keep them non‑blocking. - Compose multiple asyncs with
!>!(Async.Parallel) for concurrent I/O, and!>>for sequential composition. - Propagate a
CancellationTokento allow callers to cancel long‑running workflows. - Handle errors inside the workflow and return a discriminated union (e.g.,
Result) to avoid unhandled exceptions. - Profile with a .NET profiler to confirm that thread‑pool usage matches expectations, especially when scaling to many concurrent requests.
With these patterns, F# async workflows become a powerful, expressive tool for concurrent programming, letting you write clean, composable, and cancellable code without the boilerplate of manual thread management.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.