ConditionVariable
ConditionVariable puts a thread to sleep until another thread says the state may have changed. It is the only way here to wait for a condition without burning a core: a Mutex can make you wait for access, but nothing makes you wait for a value except spinning on it.
It is never used alone. A condition variable is always three things together: a Mutex, the state that mutex protects, and a predicate over that state.
The condition variable remembers nothing. What decides whether a thread waits is your predicate, under the mutex; a notification only wakes a thread up to test it again.
The shape, complete
Section titled “The shape, complete”struct JobQueue{ Mutex mutex; ConditionVariable not_empty; Job items[64]; u32 count;};
void Submit(JobQueue& queue, const Job& job){ { MutexScope guard(queue.mutex); queue.items[queue.count] = job; // bounds handling omitted queue.count += 1; // the predicate's state changes under the mutex } queue.not_empty.NotifyOne(); // then the notification, outside the lock}
Job Take(JobQueue& queue){ MutexScope guard(queue.mutex); while (queue.count == 0) // while, never if { queue.not_empty.Wait(queue.mutex); } queue.count -= 1; return queue.items[queue.count];}Three properties of Wait make that work.
It releases the mutex while it sleeps, so the producer can take the lock and change the state. Without that, waiting would deadlock the thing you are waiting for.
It re-acquires the mutex before returning, on every path — notification, timeout, spurious wakeup alike. The line after Wait runs under the lock, so the loop can re-test count safely and the guard’s destructor still has a lock to release.
The caller must hold the mutex on entry. Calling Wait with the mutex unlocked is undefined behaviour, explicitly so on Windows. Neither platform exposes lock ownership, so nothing can check it for you.
while, never if
Section titled “while, never if”A return from Wait is not evidence that the predicate holds. Two separate mechanisms make that so, and both are documented platform behaviour:
- Spurious wakeups. The Linux kernel’s wait primitive documents that callers should “always conservatively assume that a return value of 0 can mean a spurious wake-up”.
- Stolen wakeups. Microsoft’s condition variable documentation notes that a third thread can acquire the mutex first and consume the state before the woken thread runs, and concludes: “you should recheck a predicate (typically in a while loop)”.
So the predicate is the only authority. while (!ready) { condition.Wait(mutex); } is the whole protocol, and an if in that position is a bug that appears under load and not under a debugger.
The notification carries no state
Section titled “The notification carries no state”A NotifyOne or NotifyAll with no thread parked is a legal no-op. It is not remembered for a thread that waits a moment later.
That is what makes the following the classic failure:
// Lost: the flag is set outside the// lock, so a waiter can test it,// find it false, and park after the// notification has already gone.ready = true;condition.NotifyOne();// Safe: the state changes under the// mutex the waiter holds while it// tests the predicate.{ MutexScope guard(mutex); ready = true;}condition.NotifyOne();The rule that follows: change the state under the mutex, then notify. A waiter that then arrives late does not need the notification at all — it tests the predicate, finds it true, and never waits.
Notifying with the mutex held is also correct. Releasing it first is usually better: it spares the woken thread an immediate block on a lock the notifier still owns.
NotifyOne and NotifyAll
Section titled “NotifyOne and NotifyAll”NotifyOne wakes at least one parked thread. It may wake more, and it says nothing about which one — there is no queue and no priority.
Reach for NotifyAll when waiters are not interchangeable: when they wait on different predicates over the same state, or when one change lets more than one of them proceed. A single condition variable serving several predicates needs NotifyAll, because NotifyOne may wake exactly the thread whose predicate is still false, which then goes back to sleep while the one that could have run was never woken.
Every thread parked on one condition variable must use the same Mutex, for as long as any of them is parked. Two mutexes for one condition variable leave the predicate unprotected even where the wait itself appears to work.
Waiting with a deadline
Section titled “Waiting with a deadline”WaitTimeout returns false when the wait ended because the deadline passed.
bool TakeWithin(JobQueue& queue, Job& out_job){ MutexScope guard(queue.mutex); while (queue.count == 0) { const Duration timeout{ 50ull * 1'000'000ull }; // 50 ms if (!queue.not_empty.WaitTimeout(queue.mutex, timeout)) { return false; // gave up; the mutex is held again here } } queue.count -= 1; out_job = queue.items[queue.count]; return true;}The timeout applies to each WaitTimeout call, not to the loop around it. A spurious wakeup restarts the 50 ms above, so a loop that must give up at a real deadline computes the remaining time from a starting Instant on each pass.
The return value is advisory, and the loop is still what decides. true is not proof that a notification arrived, and false is not proof that none did. Both results re-enter with the mutex held, timeouts included.
The timeout is a floor, never a ceiling. Windows rounds a request up to whole milliseconds, the Linux wait is documented not to expire early, and scheduling can extend either arbitrarily. Use it to stop waiting forever, not to measure time.
When this is the wrong tool
Section titled “When this is the wrong tool”| Situation | Use |
|---|---|
| Mutual exclusion, with nothing to wait for | Mutex on its own |
| A flag another thread polls, without sleeping | Atomics |
| Waiting a fixed time, not for a state change | Your platform’s sleep. Basalt has no sleep call; it has GetInstant for measuring. |
| A counted resource — N permits handed out and returned | Not provided. There is no semaphore; a count plus this pair is the shape to build. |
Work that can be partitioned needs no hand-off at all. A condition variable is the right answer when threads genuinely have to meet — see Choosing a primitive.
API summary
Section titled “API summary”The full contract — the ordering guarantees, the undefined-behaviour cases, what each return value does and does not prove — is documented in basalt/core/concurrency/ConditionVariable.h.
| Signature | Purpose |
|---|---|
ConditionVariable() |
A condition variable with no waiters. Cannot fail, allocates nothing. |
Wait(mutex) |
Releases mutex, sleeps, re-acquires it before returning. May return spuriously. |
WaitTimeout(mutex, timeout) |
The same, giving up after timeout. Returns false when the deadline passed. |
NotifyOne() |
Wakes at least one waiter, if any. A no-op when none is parked. |
NotifyAll() |
Wakes every current waiter. A no-op when none is parked. |