A mutex protects shared data.
A condition variable allows threads to wait for a change in that data.
The usual pattern is:
- lock a mutex
- check a condition
- sleep until the condition changes
- wake up and check again
A producer and consumer example shows the idea.
const std = @import("std");
const State = struct {
mutex: std.Thread.Mutex = .{},
condition: std.Thread.Condition = .{},
ready: bool = false,
value: u32 = 0,
};
fn producer(state: *State) void {
state.mutex.lock();
defer state.mutex.unlock();
state.value = 42;
state.ready = true;
state.condition.signal();
}
fn consumer(state: *State) void {
state.mutex.lock();
defer state.mutex.unlock();
while (!state.ready) {
state.condition.wait(&state.mutex);
}
std.debug.print("value = {d}\n", .{state.value});
}
pub fn main() !void {
var state = State{};
const t1 = try std.Thread.spawn(.{}, producer, .{&state});
const t2 = try std.Thread.spawn(.{}, consumer, .{&state});
t1.join();
t2.join();
}The shared state contains:
mutex: std.Thread.Mutex = .{},
condition: std.Thread.Condition = .{},
ready: bool = false,
value: u32 = 0,The mutex protects the shared data.
The condition variable allows a thread to sleep until another thread signals that the data changed.
The consumer waits here:
while (!state.ready) {
state.condition.wait(&state.mutex);
}wait does three things:
- unlocks the mutex
- puts the thread to sleep
- locks the mutex again before returning
This is important.
If the mutex stayed locked while waiting, the producer could never acquire it to update the shared state.
The condition is checked in a while loop, not an if statement.
This is required.
A thread may wake up even though the condition is still false. This is called a spurious wakeup.
Always write:
while (!condition) {
wait(...)
}not:
if (!condition) {
wait(...)
}The producer signals the condition here:
state.condition.signal();signal wakes one waiting thread.
There is also:
state.condition.broadcast();which wakes all waiting threads.
Use signal when only one thread should continue.
Use broadcast when all waiting threads must recheck the condition.
Condition variables are useful for queues.
Here is a small single-item queue:
const std = @import("std");
const Queue = struct {
mutex: std.Thread.Mutex = .{},
condition: std.Thread.Condition = .{},
full: bool = false,
value: u32 = 0,
fn put(self: *Queue, n: u32) void {
self.mutex.lock();
defer self.mutex.unlock();
while (self.full) {
self.condition.wait(&self.mutex);
}
self.value = n;
self.full = true;
self.condition.signal();
}
fn get(self: *Queue) u32 {
self.mutex.lock();
defer self.mutex.unlock();
while (!self.full) {
self.condition.wait(&self.mutex);
}
const n = self.value;
self.full = false;
self.condition.signal();
return n;
}
};
fn producer(queue: *Queue) void {
var i: u32 = 1;
while (i <= 5) : (i += 1) {
queue.put(i);
}
}
fn consumer(queue: *Queue) void {
var i: u32 = 0;
while (i < 5) : (i += 1) {
const n = queue.get();
std.debug.print("got {d}\n", .{n});
}
}
pub fn main() !void {
var queue = Queue{};
const t1 = try std.Thread.spawn(.{}, producer, .{&queue});
const t2 = try std.Thread.spawn(.{}, consumer, .{&queue});
t1.join();
t2.join();
}The queue has one slot.
If the queue is full, the producer waits.
If the queue is empty, the consumer waits.
The condition variable avoids busy waiting.
Without a condition variable, the consumer might repeatedly check:
while (!queue.full) {}This wastes CPU time.
A condition variable lets the thread sleep until another thread changes the state.
The mutex and condition variable work together:
- the mutex protects the state
- the condition variable waits for changes in that state
The condition itself is always stored in ordinary shared data:
full: bool
ready: bool
count > 0The condition variable is only the waiting mechanism.
A condition variable does not remember signals.
This is wrong:
condition.signal();before another thread begins waiting, if no shared state records the event.
The waiting thread may sleep forever.
Always pair the condition variable with shared state protected by the mutex.
Exercise 18-13. Change the queue size from one item to four items.
Exercise 18-14. Add multiple producer threads.
Exercise 18-15. Add multiple consumer threads.
Exercise 18-16. Replace the busy-wait atomic flag example from the previous section with a condition variable.