DocsConcurrency and runtime

Transactional memory.

Share variables between tasks and update them together in atomic, composable transactions.

When tasks share a single counter, State.runAtomic is enough. When several values have to change together, use software transactional memory (STM). A transaction reads and writes shared variables, and other tasks see either all of its writes or none of them. Transactions are ordinary functions, so small ones combine into larger ones, and a transaction can wait until shared state looks the way it needs.

STM runs on Async tasks, so it needs the native runtime and isn’t available in the browser playground.

API reference: STM · STM.MVar · STM.Queue

Variables and transactions.

import Async
import STM

transfer : STM.Var Int -> STM.Var Int -> Int ->{STM.STM} ()
transfer from to amount =
    STM.modify from { n -> n - amount }
    STM.modify to { n -> n + amount }

main() =
    result = Async.runOutcome {
        alice = STM.atomically { STM.new 100 }
        bob = STM.atomically { STM.new 0 }
        tasks = List.map { _ -> Async.spawn { STM.atomically { transfer alice bob 10 } } } [1, 2, 3]
        List.each Async.await tasks
        STM.atomically { (STM.read alice, STM.read bob) }
    }
    print result
Completed (70, 30)

An STM.Var a is a shared variable. STM.new, read, write, and modify perform the STM effect, and only STM.atomically handles it. Each transfer runs as one transaction: its reads see one consistent snapshot, and its two writes become visible at the same moment. No task ever sees money that has left Alice’s account without arriving in Bob’s, however the three transfers interleave.

transfer itself is just a function performing STM. Call it from a larger transaction and it becomes part of that one, which is how STM code composes.

Transactions can run again.

Nothing is locked while a transaction runs. If another task commits a write to a variable this one has read, the attempt is thrown away and the transaction starts over. For that to be safe, a transaction’s row is closed: inside atomically it can’t print, read files, spawn tasks, or perform any other effect handled outside it. Effects handled inside the transaction, such as a local Fail.attempt or Runtime.Local.run, are fine, because they start fresh on each attempt.

Read what you need from services before the transaction and pass it in. If something should happen depending on the result, return a value that describes it and act on it after atomically returns. Since atomically needs Async, transactions don’t nest; compose STM functions instead.

Wait for a condition.

STM.retry () abandons the current attempt and waits until a variable it read changes. STM.check condition retries unless the condition holds:

import Async
import STM

withdraw : STM.Var Int -> Int ->{STM.STM} ()
withdraw account amount =
    balance = STM.read account
    STM.check (balance >= amount)
    STM.write account (balance - amount)

main() =
    result = Async.runOutcome {
        account = STM.atomically { STM.new 0 }
        purchase = Async.spawn {
            STM.atomically { withdraw account 30 }
            "paid"
        }
        STM.atomically { STM.modify account { n -> n + 50 } }
        status = Async.await purchase
        (status, STM.atomically { STM.read account })
    }
    print result
Completed ("paid", 20)

The purchase starts with an empty account, so its transaction blocks rather than overdrawing. The deposit commits a write to account, which wakes it to try again. Any condition over shared state becomes a wait this way, without condition variables or polling. A waiting task can still be cancelled, and a retry that read nothing waits until it is.

STM.orElse first second runs first, and if it retries, discards its writes and runs second. If both retry, the transaction waits for a change to anything either of them read.

Undo on failure.

STM.attempt runs an action that can fail and discards its writes when it does:

import Async
import Fail
import STM

main() =
    result = Async.runOutcome {
        account = STM.atomically { STM.new 10 }
        withdrawn = STM.atomically {
            STM.attempt {
                STM.modify account { n -> n - 30 }
                if STM.read account < 0 then Fail.fail "overdrawn" else ()
            }
        }
        (withdrawn, STM.atomically { STM.read account })
    }
    print result
Completed (Err "overdrawn", 10)

The action’s reads still count, and the surrounding transaction commits either way, with the failure as an Err. To fail after the transaction, pass that result to Fail.fromResult.

Building blocks.

The library builds two structures out of these operations, written in ordinary Fango:

  • STM.MVar is a variable that’s either full or empty. Taking from an empty one, or putting into a full one, retries. It works as a one-shot result slot, a handoff between tasks, or, as MVar (), a mutex.
  • STM.Queue is a first-in, first-out queue, unbounded or bounded. Popping from an empty queue, or pushing onto a full bounded one, retries. Combine queues with orElse to serve an urgent queue first.

When you design your own, the main choice is granularity. A Var (Dict k v) makes every update conflict with every other, while a Dict k (Var v) only conflicts per key.

A handler whose clauses perform STM can interpret another effect inside a transaction. That way, code written against State can run atomically against a shared variable; the STM API page shows such a handler. The job pipeline example puts queues, variables, and workers together.