Mutex
Mutex gives you mutual exclusion: at most one thread is inside the critical section at a time. It also gives you visibility — every write made under the lock is visible to the next thread that takes it, so the state a mutex protects needs no atomic types of its own.
The mutex is non-recursive. A thread that already holds it must not ask for it again — the second Lock() waits for a release that only that same thread could perform, and neither platform reports the mistake.
Construct it, use it, drop it
Section titled “Construct it, use it, drop it”A zero-initialised Mutex is a valid unlocked mutex. There is no Init, no Destroy, nothing allocated and nothing to give back:
struct Stats{ Mutex mutex; // ready the moment the object exists u64 messages; u64 bytes; // has to stay consistent with the count above};Put the lock next to the state it protects, as above. The pairing is a convention the compiler cannot check, and physical proximity is what keeps a later reader from missing it.
The type is neither copyable nor movable: waiting threads queue on the object’s address, so a mutex that moved would strand them. Functions take a Mutex&.
Hold it with a scope guard
Section titled “Hold it with a scope guard”MutexScope locks on construction and unlocks when the scope ends, on every exit path.
void Record(Stats& stats, const Message& message){ MutexScope guard(stats.mutex); if (!IsCounted(message)) { return; // the guard unlocks on this path too } stats.messages += 1; stats.bytes += message.size;}Manual Lock() / Unlock() remains available for the cases a scope cannot express, such as a lock released in the middle of a function. It is on you to pair them on the same thread: unlocking a mutex this thread does not hold is undefined behaviour.
TryLock never blocks
Section titled “TryLock never blocks”TryLock returns whether it acquired the lock. false is an ordinary answer — the lock was busy — not a failure with a reason, which is why it is a plain bool and not a Result.
if (stats.mutex.TryLock()){ Publish(stats); stats.mutex.Unlock();}else{ Skip(); // another thread is publishing; try next frame}Called from the thread that already holds the lock, TryLock returns false on both platforms. It never blocks and never recurses, so it cannot deadlock the way a second Lock() does.
Re-entering it is a hang, not a report
Section titled “Re-entering it is a hang, not a report”The trap in practice is not a literal double Lock(). It is a helper that takes the lock, called from a section that already holds it:
void Record(Stats& stats, const Message& message){ MutexScope guard(stats.mutex); Rotate(stats); // if Rotate() locks stats.mutex, this thread stops here stats.messages += 1;}Nothing detects it. The thread waits for itself, and whatever depends on that thread stops with it.
Two habits keep it away. Keep the critical section to the operations that need it, and call nothing you do not control while holding a lock — a callback, an allocator you were handed, an I/O routine that may lock a sink of its own.
There is no recursive mode to switch to. A recursive mutex would let the code above run, and the unclear ownership that wrote it would surface later — as a corruption, rather than as a thread that stopped on the line responsible.
What holding it costs
Section titled “What holding it costs”An uncontended Lock() is a userspace atomic operation with no system call. Contention is what costs: the losing thread parks in the kernel and is woken when the lock is released.
Neither platform promises which waiter is served next — there is no fairness and no queue order. Ten threads contending give you ten acquisitions in some order, not in the order they arrived, so a mutex cannot be used to schedule anything.
When this is the wrong tool
Section titled “When this is the wrong tool”| Situation | Use |
|---|---|
| One word — a counter, a flag, a published pointer | Atomics |
| State read by many threads and written by few, with real work inside the read | RwLock |
| A thread that must wait until the state becomes something | ConditionVariable with this mutex |
| Work that can be split so threads share nothing | An arena per thread — see Choosing a primitive |
The last row is worth trying first: a lock that is never contended still shapes every function that has to take it.
API summary
Section titled “API summary”The full contract — pairing, undefined behaviour on misuse, the memory ordering each operation establishes — is documented in basalt/core/concurrency/Mutex.h.
| Signature | Purpose |
|---|---|
Mutex() |
An unlocked mutex. Cannot fail, allocates nothing. |
Lock() |
Acquires the lock, blocking until it is free. |
TryLock() |
Acquires the lock if it is free. Returns whether it did. Never blocks. |
Unlock() |
Releases the lock. Must be called by the thread holding it. |
MutexScope(mutex) |
Holds the mutex for the scope. Name the guard. |