Choosing a primitive
Basalt ships four synchronisation primitives: atomic value types, a Mutex, an RwLock and a ConditionVariable. No other type in the framework locks anything on your behalf. Each type states its concurrency contract in its own header, and all of those contracts rest on one model.
Every Basalt type is a single-threaded type until you synchronise it. The design work is deciding where threads meet; a primitive is what you place at that point.
Nothing here is internally synchronised
Section titled “Nothing here is internally synchronised”No arena, container or string here takes a lock, and none is safe against a concurrent mutation — including the operations that only read, because a concurrent write may be moving what you are reading. The arena page gives the reason: an internally synchronised type puts an atomic instruction on the hottest path, paid by every caller that never shares the object.
One corollary catches people. Two threads mutating two different arena-backed containers still race when both containers were built on one arena. What they contend on is the arena’s cursor, not the container. One arena per producer removes that race; one container per producer does not.
Partition before you synchronise
Section titled “Partition before you synchronise”The default answer to a parallel workload here is not a lock. It is to give each thread its own memory, so most of the work has nothing to share.
- Scratch arenas are thread-local.
ScratchScopeis safe from any thread, with no extra work and no lock. - One arena per thread removes the cursor race above rather than managing it.
- Compose in per-thread memory, serialise at the egress. Each thread builds its own result — a
String8List, aChunkList<T>, a buffer — and the synchronisation sits on the one operation that publishes it: the write to the file, the insert into the shared table, the hand-off to a queue.
A program built that way needs one primitive at one place, not a lock on every data structure.
struct ReportFile{ Mutex mutex; File file; u64 offset; // the shared state, and all of it};
// Runs on every worker, over that worker's own slice of the items.void AppendBatch(ReportFile& report, const Span<const Item> items){ const ScratchScope scratch; // this thread's own arena String8List lines = {};
for (const Item& item : items) { Str8ListPush(scratch.GetArena(), lines, Describe(scratch.GetArena(), item)); } // all of the above touches nothing shared
const String8 block = Str8ListJoin(scratch.GetArena(), lines); const Span<const u8> bytes{ block.data, block.size };
MutexScope guard(report.mutex); // the one point where threads meet (void)FileWrite(report.file, report.offset, bytes); report.offset += bytes.size;}The loop is the bulk of the work and holds no lock. The critical section is three statements, because the result was already built before the thread entered it.
The four primitives
Section titled “The four primitives”| You need to | Use | What the acquire costs |
|---|---|---|
| Read and update one word — a counter, a flag, a published pointer | AtomicU32, AtomicBool, AtomicPointer<T> |
One instruction. The cache line holding it moves between the cores that write it. |
| Hold several variables consistent across a short critical section | Mutex + MutexScope |
A userspace atomic when uncontended; a park in the kernel when contended. |
| Protect state that is read far more often than written | RwLock + RwLockSharedScope |
A shared acquire is a read-modify-write on one word every reader shares. On Windows an exclusive acquire is the operation a Mutex performs. |
| Sleep until another thread makes something true | ConditionVariable + a Mutex |
A park and a wake per hand-off. Nothing at all while no thread waits. |
A mutex cannot express the last two rows. It serialises readers that never conflict with each other, and it has no way to park a thread on a state change — the alternative to parking is a spin, which burns a core for as long as it waits.
The first row is the one that gets over-used. An atomic is the right answer when the shared state is the word. It is the wrong answer as soon as two words have to agree, because two atomic operations are two separate instants, and any other thread may run between them.
What the four have in common
Section titled “What the four have in common”No lifecycle. There is no Init and no Destroy on any of them. A zero-initialised lock is a valid unlocked lock, on both platforms, so Mutex mutex; as a member or a local is ready to use. Destruction is trivial — nothing is allocated and nothing is given back.
They cannot be copied or moved. Waiting threads queue on the object’s address. Pass a Mutex&, an RwLock&, a ConditionVariable&; store the lock where the state it protects lives, so that the pair cannot drift apart.
The locks are non-recursive. A thread that holds one must not acquire it again. Nothing detects the mistake: it deadlocks, and on the RwLock’s shared mode it is undefined behaviour rather than a hang you can find in a debugger.
Neither platform promises fairness or ordering. Windows SRW locks are documented “neither fair nor FIFO”; the Linux implementation is writer-preferring. Concretely, a saturating stream of readers can starve a writer on Windows. A workload that needs a bound on waiting needs a different primitive from all four.
Misuse is undefined behaviour, not a reported error. Unlocking a lock you do not hold, waiting on a condition variable without its mutex, destroying a lock a thread is still parked on — all are contract violations, and none of them has a failure path. Nothing here returns a Result: TryLock returning false is a normal outcome, not a failure with a reason.
There is no thread type, and no job system
Section titled “There is no thread type, and no job system”Basalt today has no thread-creation API and no job system. There is also no thread pool, no semaphore, no future and no lock-free container.
So you create threads with whatever your project already uses — CreateThread, pthread_create, a wrapper of your own — and use these types to synchronise them. Nothing here assumes it created the threads it serialises, and no primitive is registered with the framework before you use it.
The pages
Section titled “The pages”| Page | What it is |
|---|---|
| Atomics | Concrete value types for one word: AtomicU32, AtomicBool, AtomicPointer<T> and the rest, with the memory orderings. |
| Mutex | The non-recursive mutual-exclusion lock, and its scope guard. |
| RwLock | Many readers or one writer, with a guard for each mode. |
| ConditionVariable | Parking a thread until a predicate you own becomes true. |
The per-operation contract for all four lives in basalt/core/concurrency/, next to the code.