Mutex
An atomic keeps one number safe. Most shared data is more than one number, and its parts must change together. Money moved between two accounts leaves one and arrives in the other, and no thread may ever see it in neither: two atomics, each changed safely on its own, still leave a moment between the two changes.
A lock closes that moment. While one thread holds it, no other thread can take it, so the whole group of changes made under the lock looks to everyone else like a single step. A Mutex<T> — short for mutual exclusion — is a lock with the value it guards inside:
import Sync::Mutex;
Locking
Lock waits until no other thread holds the lock, takes it, and returns a MutexGuard<T>. The guard is the only way to reach the value: its Get returns a *var T, the value's address, through which the fields are read and written as through any pointer:
func Transfer(accounts: &Mutex<Accounts>, amount: int64) {
let guard = accounts.Lock();
let book = guard.Get();
book.checking = book.checking - amount;
book.savings = book.savings + amount;
book.transfers = book.transfers + 1;
}
There is no Unlock call at the end. The guard's destructor releases the lock when the guard is dropped — here, as Transfer returns — so a lock cannot be forgotten, not even on an early return.
flowchart LR
a["`Lock()`"] --> w{"held by<br/>another thread?"}
w -- "yes" --> wait["sleep until<br/>it is released"] --> w
w -- "no" --> g["a `MutexGuard`:<br/>read and write"]
g --> d["guard dropped:<br/>lock released"]A thread waiting for the lock sleeps rather than spinning, so it costs nothing while it waits. Taking a lock nobody holds is one atomic instruction.
One value, many handles
A Mutex<T> is a handle. The value and its lock live in storage that every handle to them shares, and Clone makes another handle to the same storage. Each thread is given its own:
var tellers: JoinHandle<()>[4] = [
Spawn(Work, Teller { amount: 3, accounts: accounts.Clone() }) catch { else => return 1 },
Spawn(Work, Teller { amount: 2, accounts: accounts.Clone() }) catch { else => return 1 },
Spawn(Work, Teller { amount: -1, accounts: accounts.Clone() }) catch { else => return 1 },
Spawn(Work, Teller { amount: -2, accounts: accounts.Clone() }) catch { else => return 1 }
];
| Type | Is |
|---|---|
Mutex<T> | a handle to the value and its lock; Clone makes another |
MutexGuard<T> | the lock, held; Get reaches the value; dropping it releases it |
The value is destroyed when the last handle and the last guard are gone, so it does not matter which thread finishes last. Unlike the pointer in Atomic, no handle can outlive the value.
Four tellers make 4000 transfers between them, moving money in both directions, and Report — which takes the lock too — shows the total still at 10000. Every thread saw either all of a transfer or none of it.
Without waiting
TryLock takes the lock only if nobody holds it, and returns MutexGuard<T>? — none rather than waiting. A guard can also give the lock back early with Unlock, before it is dropped:
var held = accounts.Lock();
PrintLine("try while held: {}", accounts.TryLock() != none);
held.Unlock();
PrintLine("try after unlock: {}", accounts.TryLock() != none);
The first TryLock fails although it is the same thread that holds the lock. A Mutex is not reentrant: it does not remember who holds it, and a thread that calls Lock again while holding it waits for itself forever.
Holding a lock briefly
While one thread holds the lock every other thread that wants it is stopped, so do as little as possible under it: change the value and let go. Work out what to write first, and print, sleep or wait for anything else after the guard is gone.
Two more tools live beside Mutex in the Sync package. Lock is a bare lock that guards no value — whatever the code between its Acquire and Release touches. Condition lets a thread holding a guard sleep until another thread signals that something it waits for has changed, releasing the lock while it sleeps.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// An atomic keeps one number safe. Most shared data is more than one number, and its parts must
// change together: money moved between two accounts leaves one and arrives in the other, and
// nobody may see it in neither. A lock makes a group of changes look like one.
//
// A `Mutex<T>` holds a value that only one thread at a time may reach. `Lock` waits until no
// other thread holds the lock, takes it, and returns a `MutexGuard<T>`. The guard is the only way
// to the value, and when it is dropped, at the end of its block, the lock is released for the
// next thread. Forgetting to unlock is therefore impossible.
//
// A `Mutex<T>` is a handle: the value lives in storage that every handle shares. `Clone` makes
// another handle for another thread, and the value is destroyed when the last handle is gone.
import Io::PrintLine;
import Sync::Mutex;
import Thread::{ JoinHandle, Spawn };
// Two balances that must always add up to the same total.
struct Accounts {
checking: int64;
savings: int64;
transfers: uint64;
}
// A teller moves `amount` from checking to savings, a thousand times over.
struct Teller {
amount: int64;
accounts: Mutex<Accounts>;
}
func Transfer(accounts: &Mutex<Accounts>, amount: int64) {
// The guard holds the lock from here to the end of the function. While it does, no other
// thread can see the money half moved.
let guard = accounts.Lock();
let book = guard.Get();
book.checking = book.checking - amount;
book.savings = book.savings + amount;
book.transfers = book.transfers + 1;
}
func Work(teller: Teller) -> () {
for round in 0..1000 {
Transfer(teller.accounts, teller.amount);
}
}
func Report(accounts: &Mutex<Accounts>) {
let guard = accounts.Lock();
let book = guard.Get();
PrintLine("checking {} savings {} total {} transfers {}", book.checking, book.savings,
book.checking + book.savings, book.transfers);
}
func Main() -> int {
let accounts = Mutex<Accounts>(Accounts { checking: 10000, savings: 0, transfers: 0 });
Report(accounts);
// Each teller gets its own handle to the same accounts. A negative amount moves money back.
// A handle is never copied, so `accounts: accounts` is refused; `Clone` is the way to share:
//
// error: move-only value 'accounts' requires an explicit '<-' in aggregate
var tellers: JoinHandle<()>[4] = [
Spawn(Work, Teller { amount: 3, accounts: accounts.Clone() }) catch { else => return 1 },
Spawn(Work, Teller { amount: 2, accounts: accounts.Clone() }) catch { else => return 1 },
Spawn(Work, Teller { amount: -1, accounts: accounts.Clone() }) catch { else => return 1 },
Spawn(Work, Teller { amount: -2, accounts: accounts.Clone() }) catch { else => return 1 }
];
for index in 0..4 {
tellers[index].Join();
}
Report(accounts);
// `TryLock` never waits: it returns none while the lock is held, even by this same thread.
var held = accounts.Lock();
PrintLine("try while held: {}", accounts.TryLock() != none);
held.Unlock();
PrintLine("try after unlock: {}", accounts.TryLock() != none);
return 0;
}
Besides Io, its Rux.toml lists Sync and Thread under [Dependencies].
Run it
cd Examples/Concurrency/Mutex
rux run
checking 10000 savings 0 total 10000 transfers 0
checking 8000 savings 2000 total 10000 transfers 4000
try while held: false
try after unlock: true
Common mistakes
Try it yourself
- Add a fifth teller moving 4 at a time. What should the balances be?
- Add a
largest: int64field toAccounts, and haveTransferrecord the largest amount moved. - Make
ReportuseTryLockand printbusyinstead of waiting when it fails. Call it whileheldis held.
Learn more
- Sync — the package in the API reference, with
LockandCondition - Destructors — how a guard releases its lock when dropped
- Atomic — one value changed without a lock
- Read-write lock — a lock that lets many readers in at once
25.2 Atomic
Count across threads without a lock: four threads share Atomic<uint64> values, add to one with FetchAdd, keep a running maximum in the other with a CompareExchange retry loop, and pick a memory order.
25.4 Arc
Share one value between threads with Arc<T>, a counted handle whose copies may be made and dropped on any thread, and change what must change inside it through an Atomic<T>.