Skip to content

Mutexes

A mutex is a lock for shared data.

A mutex is a lock for shared data.

Only one thread may hold a mutex at a time. If another thread tries to lock it, that thread waits.

A mutex is used when several threads must read or change the same value.

const std = @import("std");

var counter: u32 = 0;
var mutex = std.Thread.Mutex{};

fn increment() void {
    var i: u32 = 0;

    while (i < 100000) : (i += 1) {
        mutex.lock();
        counter += 1;
        mutex.unlock();
    }
}

pub fn main() !void {
    const t1 = try std.Thread.spawn(.{}, increment, .{});
    const t2 = try std.Thread.spawn(.{}, increment, .{});

    t1.join();
    t2.join();

    std.debug.print("counter = {d}\n", .{counter});
}

The important part is this:

mutex.lock();
counter += 1;
mutex.unlock();

This section is protected. While one thread is inside it, the other thread cannot enter.

The protected section should be as small as possible.

This is better:

mutex.lock();
counter += 1;
mutex.unlock();

This is worse:

mutex.lock();

var i: u32 = 0;
while (i < 100000) : (i += 1) {
    counter += 1;
}

mutex.unlock();

The second version holds the mutex for too long. Other threads must wait even though the loop could have done most of its work without holding the lock.

A common pattern is to use defer:

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

counter += 1;

defer runs when the current scope exits. This prevents forgotten unlocks.

Use it when the protected block has several exits:

fn add(value: u32) void {
    mutex.lock();
    defer mutex.unlock();

    counter += value;
}

The lock is released even if the function returns early.

A mutex protects data, not code. The programmer decides which data belongs to the mutex.

For example:

const State = struct {
    mutex: std.Thread.Mutex = .{},
    count: u32 = 0,

    fn add(self: *State, n: u32) void {
        self.mutex.lock();
        defer self.mutex.unlock();

        self.count += n;
    }
};

Here the mutex and the data are stored together. This is usually clearer than having global locks and global data in separate places.

Use it like this:

const std = @import("std");

const State = struct {
    mutex: std.Thread.Mutex = .{},
    count: u32 = 0,

    fn add(self: *State, n: u32) void {
        self.mutex.lock();
        defer self.mutex.unlock();

        self.count += n;
    }
};

fn worker(state: *State) void {
    var i: u32 = 0;

    while (i < 100000) : (i += 1) {
        state.add(1);
    }
}

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

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

    t1.join();
    t2.join();

    std.debug.print("count = {d}\n", .{state.count});
}

This program passes a pointer to shared state into each thread.

The mutex is inside the state. Every function that changes count must lock the mutex first.

Do not read shared mutable data without the same lock.

This is wrong:

std.debug.print("count = {d}\n", .{state.count});

if another thread may still be changing state.count.

This is safe after both threads have joined, because no worker thread is still running.

A mutex can also protect several fields:

const State = struct {
    mutex: std.Thread.Mutex = .{},
    items_done: u32 = 0,
    bytes_read: u64 = 0,
    failed: bool = false,
};

All fields protected by the mutex must follow the same rule: lock before access.

Mutexes can cause deadlock.

A deadlock occurs when threads wait forever for locks that cannot be released.

A simple case:

var a = std.Thread.Mutex{};
var b = std.Thread.Mutex{};

Thread 1 does:

a.lock();
b.lock();

Thread 2 does:

b.lock();
a.lock();

Each thread may hold one mutex and wait for the other. Neither can continue.

The usual rule is simple: always take locks in the same order.

a.lock();
defer a.unlock();

b.lock();
defer b.unlock();

Every thread that needs both locks should lock a before b.

Mutexes are useful, but they are not a complete design. They are a tool for small, clear regions of shared state.

A good threaded program tries to reduce sharing. When sharing is necessary, it protects the shared data with a clear owner and a small lock boundary.

Exercise 18-5. Rewrite the global counter example so the mutex and counter are fields of a struct.

Exercise 18-6. Add a get method to the struct that returns the current count safely.

Exercise 18-7. Write two mutexes and two worker functions. Make both workers lock the mutexes in the same order.

Exercise 18-8. Move expensive work outside the locked section, then measure the difference.