Skip to main content

Command Palette

Search for a command to run...

State Machines, cancellation token, and Concurrency Control in F#

Updated
4 min read
State Machines, cancellation token, and Concurrency Control in F#

Modern applications frequently perform long-running or I/O-bound operations such as HTTP downloads, file writes, or database calls. In F#, these are typically implemented using task { ... } or async { ... }. While the syntax looks sequential, the compiler transforms such code into a state machine under the hood.

Understanding how state machines, cancellation, and concurrency control work together is key to writing efficient, safe, and scalable asynchronous F# code.


1. State Machines: The Foundation of Async Execution

A state machine is a compiler-generated structure that allows an asynchronous function to pause and resume execution. Every time you use do! or let!, the function may suspend, freeing the current thread, and later resume from the same point.

Conceptually, an async download function progresses through states such as:

  • Waiting for a semaphore
  • Sending an HTTP request
  • Receiving data
  • Writing to disk
  • Cleaning up resources

The F# compiler automatically tracks:

  • The current execution state
  • Local variables
  • The next instruction after an await

Without this mechanism, developers would need to manually write callbacks or continuation chains. The state machine provides correctness, readability, and scalability with minimal effort.

task {
    do! semaphore.WaitAsync()
    let! bytes = client.GetByteArrayAsync(url)
    File.WriteAllBytes(path, bytes)
}

Although this looks linear, it is actually resumable and non-blocking.

You can think of the state machine as a bookmark in a book:

StateWhat happens
0Enter task, wait for semaphore (WaitAsync)
1Semaphore acquired
2HTTP request in progress
3Write file to disk
4Release semaphore
DoneTask completed

The compiler remembers exactly where to resume execution after each awaited operation.


2. CancellationToken: Cooperative Cancellation

A CancellationToken provides a controlled stop mechanism for asynchronous operations. Instead of forcibly terminating tasks, cancellation in .NET and F# is cooperative.

Each async API periodically checks the token and throws OperationCanceledException when cancellation is requested.

let cts = new CancellationTokenSource()
downloadPdf cts.Token url
cts.Cancel()

This approach ensures:

  • Resources are released safely
  • finally blocks still execute
  • Partial work does not corrupt state

Cancellation tokens are essential for:

  • User-initiated aborts
  • Application shutdown
  • Timeouts
  • Cancelling queued or waiting operations

Think of a CancellationToken as a stop button that you pass into your async operations. When pressed, all observing tasks are notified and exit cleanly.


3. SemaphoreSlim: Controlling Parallelism

SemaphoreSlim limits the number of concurrent operations. This is critical when performing network- or disk-heavy tasks to avoid overwhelming the system or remote services.

let semaphore = new SemaphoreSlim(3)

do! semaphore.WaitAsync()
try
    // critical section
finally
    semaphore.Release() |> ignore

With a semaphore:

  • Only maxParallel tasks run simultaneously
  • Excess tasks wait without consuming threads
  • Throughput becomes predictable and stable

Example with maxParallel = 3:

Slots: [X] [X] [X]

Tasks queued:
A | B | C | D | E | F

Step 1: A, B, C acquire slots → start HTTP
Step 2: D, E, F wait
Step 3: A finishes → slot released → D starts
Step 4: B finishes → slot released → E starts
Step 5: Cancellation requested → remaining tasks stop

The semaphore limits active states, while the state machine remembers where each task is paused.


4. How These Concepts Work Together

Each asynchronous task has its own internal state machine:

[Task: downloadPdf url1]
State 0: Waiting for semaphore
State 1: Semaphore acquired
State 2: HTTP request started
State 3: File written
State 4: Semaphore released → Done

If a cancellation token is triggered:

  • Waiting tasks observe cancellation immediately
  • Running tasks throw OperationCanceledException at their next check
  • finally blocks still run, releasing the semaphore

This cooperation between mechanisms is what makes F# async code both powerful and safe.


5. The Role of |> ignore

You may often see code like this:

semaphore.Release() |> ignore

The forward pipe operator |> passes the value on the left into the function on the right.

ignore is defined as:

val ignore : 'a -> unit

It simply discards a value. Since Release() returns an integer that is not needed, piping it into ignore makes the intent explicit.


Why This Matters

Together, these concepts enable:

  • Efficient use of threads
  • Safe, cooperative cancellation
  • Controlled concurrency
  • Clean, maintainable asynchronous code

Without compiler-generated state machines, you would be forced to manually split logic into callbacks and continuations. F# hides that complexity while still giving you precise control over cancellation and parallelism.

Mastering these tools lets you write async code that scales gracefully and behaves predictably—even under heavy load. ```