Inter-Process Semaphores in C Explained

Inter-process semaphores in C provide a kernel-level signaling mechanism that coordinates how independent processes access shared resources like shared memory, files, or hardware devices. This article explains the underlying synchronization mechanics of inter-process semaphores, details the standard POSIX implementation, walks through a practical code example, and outlines essential safety considerations to prevent deadlocks and race conditions.

The Core Synchronization Mechanism

A semaphore is essentially an integer counter maintained by the operating system kernel that controls access to a resource via atomic operations. When used across separate processes, it operates as a shared gatekeeper:

  1. Initialization: The semaphore is set to an initial integer value \(N\), representing the number of available resource units (or 1 for a binary semaphore acting as a mutex).
  2. Wait / Decrement (sem_wait): When a process needs access to the critical section, it executes a wait operation (historically called P). If the counter is greater than zero, the kernel decrements the counter and permits the process to proceed immediately. If the counter is zero, the kernel places the calling process into a sleep queue until the resource becomes available.
  3. Post / Increment (sem_post): When the process finishes using the resource, it executes a post operation (historically called V). The kernel increments the counter and automatically wakes one blocked process from the queue to proceed.

Because these operations are handled atomically by the kernel or supported hardware instructions, multiple processes cannot interfere with each other during counter updates.

POSIX Named Semaphores

While threads inside a single process can use memory-based semaphores (sem_init), unrelated processes cannot share standard local memory addresses. POSIX solves this through named semaphores, which are identified by a global name (e.g., /example_sem) that any process with adequate permissions can open.

The primary functions defined in <semaphore.h> and <fcntl.h> include:

  • sem_open(): Creates a new named semaphore or accesses an existing one across the system.
  • sem_wait(): Blocks until the semaphore value is greater than zero, then decrements it.
  • sem_post(): Increments the semaphore value and wakes a sleeping process.
  • sem_close(): Closes the calling process's descriptor to the semaphore.
  • sem_unlink(): Removes the named semaphore from the operating system once all processes have closed it.

Implementation Example in C

The following example demonstrates how a parent and child process synchronize access to a critical section using a POSIX named semaphore:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <semaphore.h>
#include <sys/wait.h>

#define SEM_NAME "/sync_example"

void critical_section(const char *process_name) {
    printf("[%s] Entering critical section.\n", process_name);
    sleep(1); // Simulating work with a shared resource
    printf("[%s] Leaving critical section.\n", process_name);
}

int main() {
    // Initialize a binary semaphore with value 1
    sem_t *sem = sem_open(SEM_NAME, O_CREAT | O_EXCL, 0644, 1);
    if (sem == SEM_FAILED) {
        perror("sem_open");
        exit(EXIT_FAILURE);
    }

    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        sem_close(sem);
        sem_unlink(SEM_NAME);
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        // Child Process
        sem_wait(sem);
        critical_section("Child");
        sem_post(sem);

        sem_close(sem);
        exit(EXIT_SUCCESS);
    } else {
        // Parent Process
        sem_wait(sem);
        critical_section("Parent");
        sem_post(sem);

        // Wait for child to complete
        wait(NULL);

        // Cleanup resources
        sem_close(sem);
        sem_unlink(SEM_NAME);
    }

    return 0;
}

Best Practices and Pitfalls

  • Unlinking: Always pair sem_open() with sem_unlink(). If a process crashes without unlinking, the semaphore persists in the kernel (often visible under /dev/shm on Linux) and may leave subsequent runs in an unexpected state.
  • Deadlocks on Crash: If a process crashes while holding a semaphore (between sem_wait and sem_post), the counter is never incremented. Other processes waiting on that semaphore will block indefinitely. Critical implementations should use signal handlers to catch aborts and release held resources, or utilize alternative mechanisms such as pthread_mutexattr_setrobust across shared memory blocks.
  • Error Handling: System calls like sem_wait() can be interrupted by signals, returning -1 with errno set to EINTR. Robust production code should wrap sem_wait() in a retry loop when errno == EINTR.