Skip to content

Semaphores

A semaphore is a counter used for synchronization.

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 = 1

A 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.