Skip to content

Condition Variables

A condition variable lets one thread sleep until another thread says that something has changed.

A condition variable lets one thread sleep until another thread says that something has changed.

It is used with a mutex.

The mutex protects the shared data. The condition variable lets waiting threads wake up when that data may now be ready.

The Problem

Suppose one thread produces work, and another thread consumes work.

The consumer should not run forever checking:

while (queue_is_empty) {
    // keep checking
}

That wastes CPU.

A condition variable lets the consumer sleep while the queue is empty. When the producer adds work, it wakes the consumer.

Basic Shape

A condition variable in Zig is std.Thread.Condition.

const std = @import("std");

const QueueState = struct {
    mutex: std.Thread.Mutex = .{},
    condition: std.Thread.Condition = .{},
    has_work: bool = false,
};

There are three pieces:

FieldPurpose
mutexProtects the shared state
conditionLets threads wait and wake
has_workThe condition being checked

The condition variable does not store the condition by itself. The condition is your data, such as has_work, queue.items.len > 0, or shutdown == true.

Waiting

A waiting thread locks the mutex, checks the condition, and waits if the condition is false.

state.mutex.lock();
defer state.mutex.unlock();

while (!state.has_work) {
    state.condition.wait(&state.mutex);
}

The important line is:

state.condition.wait(&state.mutex);

This does two things:

  1. unlocks the mutex while the thread sleeps
  2. locks the mutex again before returning

That matters because the producer needs the same mutex to change the shared state.

Signaling

The producer changes the shared state while holding the mutex, then signals the condition variable.

state.mutex.lock();
defer state.mutex.unlock();

state.has_work = true;
state.condition.signal();

signal wakes one waiting thread.

If several threads may be waiting, use:

state.condition.broadcast();

broadcast wakes all waiting threads.

Full Example

const std = @import("std");

const State = struct {
    mutex: std.Thread.Mutex = .{},
    condition: std.Thread.Condition = .{},
    has_work: bool = false,
};

fn worker(state: *State) void {
    state.mutex.lock();
    defer state.mutex.unlock();

    while (!state.has_work) {
        state.condition.wait(&state.mutex);
    }

    std.debug.print("worker received work\n", .{});
}

pub fn main() !void {
    var state = State{};

    const thread = try std.Thread.spawn(.{}, worker, .{&state});

    std.time.sleep(500 * std.time.ns_per_ms);

    state.mutex.lock();
    state.has_work = true;
    state.condition.signal();
    state.mutex.unlock();

    thread.join();
}

The worker starts first and waits.

The main thread sleeps briefly, sets has_work to true, then signals.

The worker wakes up, checks has_work, and continues.

Always Wait in a Loop

This is wrong:

if (!state.has_work) {
    state.condition.wait(&state.mutex);
}

Use this instead:

while (!state.has_work) {
    state.condition.wait(&state.mutex);
}

A waiting thread can wake up even when the condition is not ready. This is called a spurious wakeup.

Also, another thread may consume the work before this thread gets the mutex again.

The rule is simple:

When a thread wakes up, it must check the condition again.

The Condition Is Your Data

The name “condition variable” can be misleading. The condition variable does not know what condition you care about.

This is the condition:

state.has_work

This is only the waiting and waking tool:

state.condition

So this is the real pattern:

while (!condition_is_true) {
    condition_variable.wait(&mutex);
}

The condition must be protected by the same mutex used with wait.

Producer and Consumer Example

Here is a small one-item queue.

const std = @import("std");

const State = struct {
    mutex: std.Thread.Mutex = .{},
    condition: std.Thread.Condition = .{},
    has_item: bool = false,
    item: u32 = 0,
};

fn consumer(state: *State) void {
    state.mutex.lock();
    defer state.mutex.unlock();

    while (!state.has_item) {
        state.condition.wait(&state.mutex);
    }

    const value = state.item;
    state.has_item = false;

    std.debug.print("consumed {}\n", .{value});
}

pub fn main() !void {
    var state = State{};

    const thread = try std.Thread.spawn(.{}, consumer, .{&state});

    state.mutex.lock();
    state.item = 42;
    state.has_item = true;
    state.condition.signal();
    state.mutex.unlock();

    thread.join();
}

The shared state has two fields:

has_item: bool = false,
item: u32 = 0,

The mutex protects both fields.

The condition variable wakes the consumer when the producer stores an item.

Why Signal After Changing State

This is correct:

state.mutex.lock();
state.has_item = true;
state.condition.signal();
state.mutex.unlock();

This is wrong:

state.condition.signal();

state.mutex.lock();
state.has_item = true;
state.mutex.unlock();

The signal should mean: “the shared state may now satisfy the condition.”

If you signal before changing the state, a waiting thread can wake up, check the condition, find it still false, and go back to sleep.

Signal or Broadcast

Use signal when one waiting thread can make progress.

state.condition.signal();

Use broadcast when many waiting threads may need to wake up.

state.condition.broadcast();

Examples:

SituationOperation
One new queue itemsignal
Shutdown flag changedbroadcast
One worker slot availablesignal
Configuration changed for all threadsbroadcast

A common rule:

If the change can help only one thread, signal. If the change affects all waiting threads, broadcast.

Adding Shutdown

Real worker threads need a way to stop.

A condition variable is often used with both “work is ready” and “shutdown requested.”

const std = @import("std");

const State = struct {
    mutex: std.Thread.Mutex = .{},
    condition: std.Thread.Condition = .{},
    has_work: bool = false,
    shutdown: bool = false,
};

fn worker(state: *State) void {
    state.mutex.lock();
    defer state.mutex.unlock();

    while (!state.has_work and !state.shutdown) {
        state.condition.wait(&state.mutex);
    }

    if (state.shutdown) {
        std.debug.print("worker shutting down\n", .{});
        return;
    }

    std.debug.print("worker doing work\n", .{});
}

The wait condition now checks two facts:

while (!state.has_work and !state.shutdown)

The worker sleeps only while there is no work and no shutdown request.

To stop the worker:

state.mutex.lock();
state.shutdown = true;
state.condition.broadcast();
state.mutex.unlock();

broadcast is useful here because all waiting workers should wake up and exit.

Do Not Hold the Lock During Long Work

Usually, the worker should take the work while holding the mutex, then release the mutex before doing the expensive part.

Bad:

state.mutex.lock();
defer state.mutex.unlock();

while (!state.has_work) {
    state.condition.wait(&state.mutex);
}

doExpensiveWork();

This keeps the mutex locked during the expensive work.

Better:

state.mutex.lock();

while (!state.has_work) {
    state.condition.wait(&state.mutex);
}

const item = state.item;
state.has_work = false;

state.mutex.unlock();

doExpensiveWork(item);

The lock protects the queue. It should not protect the whole job.

Mental Model

A condition variable is not a message queue. It does not remember every signal as a separate event.

Think of it this way:

The shared data is the truth.

The mutex protects the truth.

The condition variable helps threads sleep until the truth might have changed.

That is why waiting always uses a loop. A wakeup is not a guarantee. It is only a reason to check again.

Good Condition Variable Style

A good pattern looks like this:

mutex.lock();
defer mutex.unlock();

while (!condition_is_true) {
    condition.wait(&mutex);
}

// use protected state

And the signaling side looks like this:

mutex.lock();
defer mutex.unlock();

change_shared_state();
condition.signal();

For shutdown or global state changes:

mutex.lock();
defer mutex.unlock();

shutdown = true;
condition.broadcast();

Condition variables are useful when a thread should wait without wasting CPU. They are the standard tool for queues, worker pools, producer-consumer systems, and graceful shutdown.