Why Compare Synchronization Techniques?
Linux offers multiple ways to synchronize concurrent processes and threads: System V semaphores (the older POSIX.1 approach), POSIX semaphores (the modern approach), and Pthreads mutexes (for thread-level synchronization). Understanding when to use each — and why — is an important part of systems programming expertise.
Section 53.5 of TLPI directly compares these three approaches with concrete tradeoffs. This file walks through each comparison in depth with code context where helpful.
Both can synchronize unrelated processes. The difference is in API design, initialization, and performance.
POSIX semaphore API uses individual sem_t objects with simple functions. System V uses semaphore sets (semget, semctl, semop), requires IPC keys, and has complex data structures. POSIX achieves the same functional power with far fewer concepts.
System V semaphores have a notorious race condition: a process must first create the semaphore set, then separately initialize it with semctl(SETVAL). Between these two steps, another process might start using an uninitialized semaphore. POSIX sem_open() creates AND initializes atomically, eliminating this problem entirely.
A POSIX unnamed semaphore can be embedded directly inside a struct or a dynamically allocated memory object. You cannot do this with System V semaphores, which exist only as kernel objects accessed via an integer ID (semid).
/* Embedding a semaphore directly in a struct */
struct shared_data {
sem_t lock; /* Semaphore lives inside the struct */
int counter;
char buffer[256];
};
/* mmap a region, cast to struct, init the embedded semaphore */
struct shared_data *shm = mmap(...);
sem_init(&shm->lock, 1, 1); /* Process-shared, value=1 */
This is the most significant practical advantage. POSIX semaphore operations (especially on Linux using futexes) only require a system call when actual blocking is needed. System V semaphore operations always require a system call regardless of contention. The author of TLPI measured a performance difference of more than an order of magnitude (10x) in favor of POSIX semaphores under low contention.
POSIX IPC objects are reference-counted, making it easy to determine when an object can be safely deleted. The POSIX API follows the familiar file model (names, open/close/unlink), which is more intuitive. System V uses numeric keys and integer IDs with no connection to the filesystem model.
On Linux, named POSIX semaphores were supported only since kernel 2.6. System V semaphores have been available much longer across all UNIX variants. Today (Linux 2.6+), this is rarely a concern, but legacy codebases on older platforms may still need System V.
System V semaphores have a SEM_UNDO flag. When a process terminates abnormally, the kernel automatically reverses any semaphore operations that were performed with SEM_UNDO. POSIX semaphores have no such feature — if a process crashes while holding a POSIX semaphore, the semaphore stays decremented and other processes may deadlock. However, the SEM_UNDO feature has limitations and is not always reliable or useful.
| Feature | POSIX Named Semaphore | System V Semaphore |
|---|---|---|
| Create/Open | sem_open("/name", O_CREAT, mode, value) |
semget(key, nsems, IPC_CREAT | perms) |
| Initialize | Atomic with open (O_CREAT + value arg) | Separate semctl(SETVAL) — race window exists |
| Operate | sem_wait(), sem_post() |
semop() with sembuf array |
| Delete | sem_unlink("/name") |
semctl(semid, 0, IPC_RMID) |
| Granularity | One semaphore per object | Set of semaphores per semid |
| Performance | Futex-based: syscall only on contention (10x+ faster under low contention) | Always syscall — slower uniformly |
| Undo on crash | Not available | SEM_UNDO flag available |
| Reference counted | Yes — kernel tracks open count | No — must manually track users |
| API complexity | Simple, file-like | Complex: key generation, semun union, multiple ctl commands |
Both can synchronize threads within the same process and have similar performance. But they have different characteristics that make each better suited for different problems.
A mutex has a strict ownership rule: only the thread that locked the mutex can unlock it. This enforces a clear lock/unlock pattern within the same scope and prevents accidental unlock by unrelated code.
A semaphore has no ownership. Thread A can decrement it and Thread B can increment it. While this flexibility is sometimes useful, it can lead to poorly structured synchronization designs where it is unclear which code is responsible for releasing a resource.
This is why semaphores are sometimes called the “gotos of concurrent programming” — they work, but can make code hard to reason about.
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void thread_func(void) {
pthread_mutex_lock(&lock);
/* Critical section */
shared_counter++;
pthread_mutex_unlock(&lock);
/* Clear ownership: only locker unlocks */
}
sem_t sem;
sem_init(&sem, 0, 1);
void thread_func(void) {
sem_wait(&sem);
/* Critical section */
shared_counter++;
sem_post(&sem);
/* No ownership: ANY thread could post */
}
sem_post() is async-signal-safe. This means it can safely be called from within a signal handler. Pthreads mutex functions are NOT async-signal-safe — calling pthread_mutex_unlock() from a signal handler results in undefined behavior.
This matters when you need a signal handler to wake up a blocking thread. The recommended pattern:
/*
* Pattern: signal handler posts a semaphore to wake a waiting thread.
* Works because sem_post() is async-signal-safe.
*/
#include <semaphore.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
static sem_t sig_sem;
/* Signal handler: just post the semaphore */
static void sigint_handler(int sig) {
sem_post(&sig_sem); /* async-signal-safe: OK */
}
/* Thread that waits for a signal via semaphore */
static void *waiter_thread(void *arg) {
printf("Thread: waiting for signal...\n");
sem_wait(&sig_sem);
printf("Thread: received signal, cleaning up.\n");
return NULL;
}
int main(void) {
pthread_t tid;
struct sigaction sa;
sem_init(&sig_sem, 0, 0); /* start at 0: thread will block */
sa.sa_handler = sigint_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
pthread_create(&tid, NULL, waiter_thread, NULL);
pthread_join(tid, NULL);
sem_destroy(&sig_sem);
return 0;
}
/* Press Ctrl+C to trigger SIGINT and wake the thread */
/* Compile: gcc -o sig_sem sig_sem.c -lpthread */
Note from TLPI: It is usually more elegant to handle async signals using sigwaitinfo() rather than signal handlers (see Section 33.2.4). This makes the semaphore advantage over mutexes in signal handling less commonly needed in practice.
| Use Case | Mutex | POSIX Semaphore | System V Semaphore |
|---|---|---|---|
| Protect shared data between threads | Best | OK | Poor |
| Sync between unrelated processes | N/A | Best (named) | OK |
| Signal from signal handler to thread | Cannot | Best (sem_post is async-signal-safe) | Cannot (semop not async-signal-safe) |
| Pool of N resources (counting) | No (binary only) | Best | OK |
| Producer-consumer signaling | With condition variable | Best (simpler) | OK |
| High-contention locking | Best | Similar | Similar |
| Auto-undo on process crash | No | No | Yes (SEM_UNDO) |
| Performance under low contention | Best (futex) | Best (futex) | Poor (always syscall) |
POSIX semaphores on Linux are implemented using futexes (fast user-space mutexes). When the semaphore value allows an operation to proceed without blocking, the operation is completed with an atomic user-space instruction and no system call is made. System V semaphore operations (semop) always make a system call regardless of whether blocking is needed, causing kernel overhead every time.
In System V, creating a semaphore set (semget) and initializing it (semctl with SETVAL) are two separate steps. Another process could use the semaphore between these steps when it is in an undefined state. POSIX sem_open() with O_CREAT atomically creates AND sets the initial value in a single step, eliminating the window entirely.
Mutexes enforce ownership: only the thread that acquired the lock can release it. This enforces disciplined coding — the lock and unlock always appear in the same scope/function. Semaphores have no ownership, so any thread can increment them, which can lead to accidental double-release or mismatched acquire/release patterns — bugs that are hard to detect.
Just as goto in sequential code can make control flow hard to follow, semaphore operations scattered across different threads can make synchronization flow hard to reason about. Because sem_post() and sem_wait() can be called by unrelated code in any order with no ownership constraint, it is easy to create synchronization patterns that are correct but difficult to understand, maintain, or audit.
SEM_UNDO is a flag for semop() that asks the kernel to automatically reverse any semaphore operations when the process terminates. This prevents deadlock if a process crashes while holding a semaphore. POSIX semaphores have no equivalent. However, TLPI notes that SEM_UNDO has limitations (it doesn’t work well across fork, and has per-process adjustment limits), so in many real-world scenarios it is not as useful as it sounds.
Yes. sem_post() is listed as async-signal-safe in POSIX (Table 21-1 in TLPI), meaning it is safe to call from a signal handler. This makes it possible to use a semaphore to wake up a blocked thread from a signal handler. Mutex functions (pthread_mutex_lock/unlock) are NOT async-signal-safe and cannot be used in signal handlers.
Next: Semaphore Limits and Complete Summary
Learn about SEM_NSEMS_MAX, SEM_VALUE_MAX, and review all concepts with a final Q&A.
