A mutex is a lock for shared data.
The word “mutex” means “mutual exclusion.” That means only one thread is allowed to enter a protected section of code at a time.
You use a mutex when several threads need to access the same data, and at least one of them may change it.
Why Mutexes Exist
This code looks harmless:
counter += 1;But it is not one simple operation at the machine level. It is closer to this:
read counter
add 1
write counterIf two threads do this at the same time, they can both read the same old value and both write back the same new value.
For example, suppose counter starts at 10.
Thread A reads 10
Thread B reads 10
Thread A writes 11
Thread B writes 11Two increments happened, but the final value is only 11.
The correct value should be 12.
A mutex prevents this by allowing only one thread to update the counter at a time.
A Basic Mutex
In Zig, a mutex is available as std.Thread.Mutex.
const std = @import("std");
var mutex = std.Thread.Mutex{};
var counter: u32 = 0;To protect shared data, lock the mutex before using the data, then unlock it afterward.
mutex.lock();
counter += 1;
mutex.unlock();Only one thread can hold the mutex at a time.
If another thread reaches mutex.lock() while the mutex is already locked, it waits.
Full Example
const std = @import("std");
var mutex = std.Thread.Mutex{};
var counter: u32 = 0;
fn incrementMany() void {
var i: u32 = 0;
while (i < 1000) : (i += 1) {
mutex.lock();
counter += 1;
mutex.unlock();
}
}
pub fn main() !void {
const t1 = try std.Thread.spawn(.{}, incrementMany, .{});
const t2 = try std.Thread.spawn(.{}, incrementMany, .{});
t1.join();
t2.join();
std.debug.print("counter = {}\n", .{counter});
}This program starts two threads. Each thread increments the same counter 1000 times.
Without the mutex, the final value may be wrong.
With the mutex, the final value should be:
counter = 2000The mutex protects this critical section:
mutex.lock();
counter += 1;
mutex.unlock();A critical section is code that must not be executed by multiple threads at the same time.
Use defer to Unlock Safely
Writing lock and unlock manually can be dangerous.
This code has a problem:
mutex.lock();
if (someCondition()) {
return;
}
counter += 1;
mutex.unlock();If someCondition() is true, the function returns before calling mutex.unlock().
The mutex stays locked forever. Other threads will wait forever.
This is called a deadlock.
Use defer to make the unlock automatic at the end of the scope:
mutex.lock();
defer mutex.unlock();
if (someCondition()) {
return;
}
counter += 1;Now mutex.unlock() runs when the scope exits, even if the function returns early.
This is the standard style:
mutex.lock();
defer mutex.unlock();
// use protected data hereKeep the Locked Section Small
A mutex blocks other threads while it is locked.
So this is bad:
mutex.lock();
defer mutex.unlock();
counter += 1;
expensiveCalculation();
writeLargeFile();The mutex protects counter, but it also blocks other threads while the calculation and file write happen.
Better:
mutex.lock();
counter += 1;
mutex.unlock();
expensiveCalculation();
writeLargeFile();Lock only around the shared data.
The locked section should be as small as possible while still being correct.
Protect Data, Not Code
A mutex should be associated with the data it protects.
This style is unclear:
var global_mutex = std.Thread.Mutex{};
var counter: u32 = 0;
var total: u32 = 0;
var current_name: []const u8 = "";Which data does global_mutex protect?
Maybe all of it. Maybe only some of it. The code does not say.
A better style is to group the mutex with the data:
const Counter = struct {
mutex: std.Thread.Mutex = .{},
value: u32 = 0,
fn increment(self: *Counter) void {
self.mutex.lock();
defer self.mutex.unlock();
self.value += 1;
}
fn get(self: *Counter) u32 {
self.mutex.lock();
defer self.mutex.unlock();
return self.value;
}
};Now the relationship is clear.
The mutex protects value.
The methods are responsible for locking and unlocking.
Using the Counter
const std = @import("std");
const Counter = struct {
mutex: std.Thread.Mutex = .{},
value: u32 = 0,
fn increment(self: *Counter) void {
self.mutex.lock();
defer self.mutex.unlock();
self.value += 1;
}
fn get(self: *Counter) u32 {
self.mutex.lock();
defer self.mutex.unlock();
return self.value;
}
};
fn worker(counter: *Counter) void {
var i: u32 = 0;
while (i < 1000) : (i += 1) {
counter.increment();
}
}
pub fn main() !void {
var counter = Counter{};
const t1 = try std.Thread.spawn(.{}, worker, .{&counter});
const t2 = try std.Thread.spawn(.{}, worker, .{&counter});
t1.join();
t2.join();
std.debug.print("counter = {}\n", .{counter.get()});
}This is cleaner than using global variables.
The shared state is explicit:
var counter = Counter{};Each worker receives a pointer to the same counter:
.{&counter}The counter itself decides how locking works.
Do Not Read Shared Data Without Locking
A common mistake is locking only when writing.
mutex.lock();
counter += 1;
mutex.unlock();
std.debug.print("counter = {}\n", .{counter});The print reads counter without holding the mutex.
That may still be a data race if another thread writes at the same time.
Use the mutex for both reads and writes:
mutex.lock();
defer mutex.unlock();
std.debug.print("counter = {}\n", .{counter});Or hide the access behind methods:
const value = counter.get();
std.debug.print("counter = {}\n", .{value});The rule is simple:
Every access to shared mutable data must follow the same locking rule.
Deadlock
A deadlock happens when threads wait forever.
The simplest deadlock is forgetting to unlock:
mutex.lock();
return;
mutex.unlock();The unlock line never runs.
Another common deadlock happens when two locks are taken in different orders.
Thread A locks mutex1
Thread B locks mutex2
Thread A waits for mutex2
Thread B waits for mutex1Both threads are waiting. Neither can continue.
To avoid this, use a fixed lock order.
For example:
Always lock user_mutex before account_mutex.
Never lock account_mutex before user_mutex.This rule matters more as programs get larger.
Avoid Calling Unknown Code While Holding a Mutex
Be careful with this pattern:
mutex.lock();
defer mutex.unlock();
callback();The function callback may do something unexpected. It may try to lock the same mutex. It may block for a long time. It may call code that calls back into your object.
A safer pattern is:
mutex.lock();
const snapshot = value;
mutex.unlock();
callback(snapshot);Copy the needed data while holding the lock, then release the lock before calling unknown code.
Mutexes and Performance
A mutex has a cost.
Locking and unlocking are not free. More importantly, a mutex can make threads wait.
If many threads constantly fight for the same mutex, the program may become slower than a single-threaded version.
This problem is called contention.
High contention means many threads want the same lock at the same time.
To reduce contention:
| Technique | Meaning |
|---|---|
| Smaller critical sections | Hold the lock for less time |
| Separate locks | Use different mutexes for unrelated data |
| Thread-local data | Let each thread work on its own data |
| Batch updates | Lock once, apply many changes, unlock |
| Atomics | Use atomic operations for simple shared values |
Do not start with clever locking. Start with correct locking. Then measure.
Mutexes vs Atomics
A mutex is good when you need to protect a group of operations.
For example:
balance -= amount;
transactions.append(transaction);
last_updated = now;These operations belong together. A mutex is a natural fit.
An atomic is better for very small shared values, such as a simple counter or flag. Atomics are covered later.
For beginners, use mutexes first. They are easier to reason about.
The Main Rule
A mutex protects an invariant.
An invariant is something that should always remain true.
For example, suppose you have this state:
const Account = struct {
balance: i64,
transaction_count: u64,
};You may want this rule:
Whenever balance changes, transaction_count must also change.Then both fields should be protected by the same mutex:
const Account = struct {
mutex: std.Thread.Mutex = .{},
balance: i64 = 0,
transaction_count: u64 = 0,
fn deposit(self: *Account, amount: i64) void {
self.mutex.lock();
defer self.mutex.unlock();
self.balance += amount;
self.transaction_count += 1;
}
};The mutex protects the relationship between the fields, not just the fields themselves.
Good Mutex Style
A good Zig mutex pattern looks like this:
const Thing = struct {
mutex: std.Thread.Mutex = .{},
value: u32 = 0,
fn update(self: *Thing) void {
self.mutex.lock();
defer self.mutex.unlock();
self.value += 1;
}
};The data and mutex live together.
The method locks before touching the data.
The method uses defer to unlock.
The locked section is small.
That is the basic discipline of mutexes.