A semaphore is a counter used for synchronization.
Threads may increase the counter or wait until the counter becomes positive.
A semaphore is useful when:
- a limited number of resources exist
- threads must wait for available work
- access should be restricted to a fixed capacity
Unlike a mutex, a semaphore does not protect one critical section owned by one thread.
A mutex is either locked or unlocked.
A semaphore may allow several threads to continue at the same time.
Suppose a program allows at most three workers to access a resource simultaneously.
A semaphore models this naturally.
const std = @import("std");
var semaphore = std.Thread.Semaphore{ .permits = 3 };
fn worker(id: u32) void {
semaphore.wait();
defer semaphore.post();
std.debug.print(
"worker {d} entered\n",
.{id},
);
std.Thread.sleep(1_000_000_000);
std.debug.print(
"worker {d} leaving\n",
.{id},
);
}
pub fn main() !void {
const t1 = try std.Thread.spawn(.{}, worker, .{1});
const t2 = try std.Thread.spawn(.{}, worker, .{2});
const t3 = try std.Thread.spawn(.{}, worker, .{3});
const t4 = try std.Thread.spawn(.{}, worker, .{4});
const t5 = try std.Thread.spawn(.{}, worker, .{5});
t1.join();
t2.join();
t3.join();
t4.join();
t5.join();
}The semaphore begins with three permits:
var semaphore = std.Thread.Semaphore{
.permits = 3,
};Each call to:
semaphore.wait();tries to take one permit.
If a permit is available, the thread continues.
If no permit is available, the thread sleeps until another thread releases one.
A permit is released with:
semaphore.post();At most three workers may execute the protected section simultaneously.
The output order is not fixed, but only three workers should be active at once.
A semaphore can also represent available work items.
Suppose producers generate tasks and consumers process them.
The semaphore tracks how many tasks are ready.
const std = @import("std");
var semaphore = std.Thread.Semaphore{
.permits = 0,
};
var mutex = std.Thread.Mutex{};
var jobs: [8]u32 = undefined;
var count: usize = 0;
fn producer() void {
var i: u32 = 1;
while (i <= 5) : (i += 1) {
mutex.lock();
jobs[count] = i;
count += 1;
mutex.unlock();
semaphore.post();
}
}
fn consumer() void {
var i: usize = 0;
while (i < 5) : (i += 1) {
semaphore.wait();
mutex.lock();
count -= 1;
const job = jobs[count];
mutex.unlock();
std.debug.print(
"job {d}\n",
.{job},
);
}
}
pub fn main() !void {
const t1 = try std.Thread.spawn(
.{},
producer,
.{},
);
const t2 = try std.Thread.spawn(
.{},
consumer,
.{},
);
t1.join();
t2.join();
}The semaphore counts available jobs.
The producer adds work:
semaphore.post();The consumer waits for work:
semaphore.wait();The semaphore removes the need for busy waiting.
Without it, the consumer might repeatedly check:
while (count == 0) {}This wastes CPU time.
Semaphores are often used for:
- worker queues
- connection limits
- resource pools
- producer-consumer systems
- rate limiting
A semaphore does not replace a mutex.
The semaphore tracks availability.
The mutex still protects the shared data structure itself.
In the producer-consumer example:
- the semaphore counts jobs
- the mutex protects the array and count
This separation is important.
Semaphores may also be binary:
.permits = 1A binary semaphore behaves somewhat like a mutex, but the ownership rules differ.
A mutex is normally unlocked by the thread that locked it.
A semaphore permit may be released by a different thread.
Mutexes protect ownership.
Semaphores coordinate availability.
Use the right tool for the problem.
Exercise 18-26. Change the semaphore example to allow only two workers simultaneously.
Exercise 18-27. Add a second consumer thread.
Exercise 18-28. Add a fixed queue size and block producers when the queue becomes full.
Exercise 18-29. Modify the worker example so each worker sleeps for a random duration before releasing the permit.
Exercise 18-30. Write a resource pool with four reusable objects protected by a semaphore.