C Inter-Process Communication (IPC) with POSIX APIs

Inter-Process Communication (IPC) allows separate processes to exchange data and coordinate their work. Because processes normally have independent address spaces, one process cannot simply access another process's ordinary variables.

POSIX systems provide several IPC mechanisms, including pipes, named pipes (FIFOs), shared memory, message queues, and signals. Each mechanism has different performance, complexity, and communication characteristics.

Why Do Processes Need IPC?

Processes often need to cooperate. A parent process may send work to a child, two independent programs may exchange data, or multiple worker processes may share information.

  • Exchange data between processes
  • Send commands or events
  • Coordinate process execution
  • Build producer-consumer systems
  • Implement pipelines
  • Share large blocks of data
  • Notify processes about important events

Common POSIX IPC Mechanisms

MechanismTypical Use
Anonymous pipeCommunication between related processes
FIFOCommunication through a named filesystem object
Shared memoryFast exchange of large amounts of data
Message queueStructured message-based communication
SignalSmall asynchronous notifications

Anonymous Pipes

A pipe provides a unidirectional byte stream between processes. It is commonly created before fork(), allowing the parent and child to inherit the pipe's file descriptors.

C
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    int pipefd[2];

    if (pipe(pipefd) == -1)
    {
        perror("pipe");
        return EXIT_FAILURE;
    }

    pid_t pid = fork();

    if (pid == -1)
    {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (pid == 0)
    {
        const char message[] = "Hello from child";

        close(pipefd[0]);

        if (write(pipefd[1], message, sizeof message) == -1)
        {
            perror("write");
            _exit(1);
        }

        close(pipefd[1]);
        _exit(0);
    }

    close(pipefd[1]);

    char buffer[100];
    ssize_t bytes = read(pipefd[0], buffer, sizeof buffer - 1);

    if (bytes == -1)
    {
        perror("read");
        close(pipefd[0]);
        return EXIT_FAILURE;
    }

    buffer[bytes] = '\0';
    printf("Received: %s\n", buffer);

    close(pipefd[0]);
    return EXIT_SUCCESS;
}

Pipe File Descriptors

pipe() returns two file descriptors.

DescriptorPurpose
pipefd[0]Read end
pipefd[1]Write end

The processes should close the ends they do not need. Closing unused descriptors is important because EOF detection and blocking behavior depend on whether write ends remain open.

Pipes Are Byte Streams

A pipe does not preserve application-level message boundaries. It provides a stream of bytes. If an application needs distinct messages, it must define a protocol, such as fixed-size records, length prefixes, delimiters, or another framing scheme.

Two-Way Communication with Two Pipes

A single ordinary pipe is normally used in one direction. If two processes need bidirectional communication, a common approach is to create two pipes: one for each direction.

C
int parentToChild[2];
int childToParent[2];

if (pipe(parentToChild) == -1 ||
    pipe(childToParent) == -1)
{
    perror("pipe");
    return 1;
}

The parent writes to parentToChild[1] and reads from childToParent[0]. The child reads from parentToChild[0] and writes to childToParent[1].

Pipe EOF Behavior

When all file descriptors referring to the write end of a pipe have been closed, a reader that has consumed all available data can observe end-of-file.

This is why every process should close unused pipe descriptors. Accidentally leaving a write end open can cause a read operation to wait indefinitely for EOF.

Named Pipes (FIFOs)

A FIFO is a named pipe represented by a filesystem entry. Unlike an anonymous pipe, unrelated processes can use a FIFO as long as they can access the FIFO and have appropriate permissions.

C
#include <sys/stat.h>
#include <stdio.h>

int main(void)
{
    if (mkfifo("myfifo", 0666) == -1)
    {
        perror("mkfifo");
        return 1;
    }

    return 0;
}

The FIFO can then be opened for reading or writing by separate processes. The exact blocking behavior depends on how the FIFO is opened and whether a corresponding reader or writer is present.

Shared Memory

Shared memory allows multiple processes to access the same region of memory. It is often much faster for large data transfers than repeatedly copying data through pipes or message queues, but it requires explicit synchronization.

A common POSIX shared-memory API uses shm_open(), ftruncate(), mmap(), and shm_unlink().

C
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    const char *name = "/example_shared_memory";
    const size_t size = 4096;

    int fd = shm_open(name, O_CREAT | O_RDWR, 0600);

    if (fd == -1)
    {
        perror("shm_open");
        return EXIT_FAILURE;
    }

    if (ftruncate(fd, (off_t)size) == -1)
    {
        perror("ftruncate");
        close(fd);
        return EXIT_FAILURE;
    }

    void *memory = mmap(NULL, size,
                        PROT_READ | PROT_WRITE,
                        MAP_SHARED, fd, 0);

    if (memory == MAP_FAILED)
    {
        perror("mmap");
        close(fd);
        return EXIT_FAILURE;
    }

    strcpy(memory, "Hello through shared memory");
    printf("Shared data: %s\n", (char *)memory);

    munmap(memory, size);
    close(fd);
    shm_unlink(name);

    return EXIT_SUCCESS;
}

Why Synchronization Is Necessary with Shared Memory

Shared memory only provides a shared data region. It does not automatically coordinate access to that data.

If two processes modify the same object concurrently without proper synchronization, they can produce data races, inconsistent state, or lost updates.

POSIX Shared Memory Lifecycle

  1. Create or open a shared-memory object with shm_open()
  2. Set its size with ftruncate()
  3. Map it into the process using mmap()
  4. Read or write the shared region
  5. Unmap it with munmap()
  6. Close the file descriptor
  7. Remove the shared-memory object with shm_unlink() when it is no longer needed

Message Queues

Message queues allow processes to exchange discrete messages rather than an undifferentiated byte stream. POSIX message queues are accessed through APIs such as mq_open(), mq_send(), mq_receive(), and mq_close().

C
#include <fcntl.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    const char *name = "/example_queue";

    struct mq_attr attr = {0};
    attr.mq_maxmsg = 10;
    attr.mq_msgsize = 256;

    mqd_t queue = mq_open(name,
                          O_CREAT | O_RDWR,
                          0600,
                          &attr);

    if (queue == (mqd_t)-1)
    {
        perror("mq_open");
        return EXIT_FAILURE;
    }

    const char message[] = "Hello from a message queue";

    if (mq_send(queue, message, sizeof message, 0) == -1)
    {
        perror("mq_send");
        mq_close(queue);
        mq_unlink(name);
        return EXIT_FAILURE;
    }

    mq_close(queue);
    mq_unlink(name);
    return EXIT_SUCCESS;
}

A real producer-consumer application would normally have one process send messages and another receive them.

Message Priorities

POSIX message queues support message priorities. Higher-priority messages can be selected before lower-priority messages according to the queue's scheduling semantics.

C
unsigned int priority = 5;

mq_send(queue,
        message,
        strlen(message) + 1,
        priority);

Signals as IPC

Signals provide a lightweight mechanism for notifying a process that an event occurred. They are not designed for transferring large amounts of data.

C
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

static volatile sig_atomic_t received = 0;

static void handleSignal(int signal)
{
    if (signal == SIGUSR1)
    {
        received = 1;
    }
}

int main(void)
{
    struct sigaction action = {0};
    action.sa_handler = handleSignal;
    sigemptyset(&action.sa_mask);

    if (sigaction(SIGUSR1, &action, NULL) == -1)
    {
        perror("sigaction");
        return 1;
    }

    printf("PID: %ld\n", (long)getpid());

    while (!received)
    {
        pause();
    }

    printf("SIGUSR1 received.\n");
    return 0;
}

The example uses sig_atomic_t for a simple flag that can safely be accessed from normal code and a signal handler. Signal handlers have strict restrictions on which operations they may safely perform.

Signals Are Not General-Purpose Data Channels

A normal signal communicates an event rather than an arbitrary data structure. If an application needs to transfer substantial information, pipes, sockets, shared memory, or message queues are usually better choices.

Anonymous Pipe vs FIFO

FeatureAnonymous PipeFIFO
Filesystem nameNoYes
Unrelated processesNot directly through the pipe itselfYes
Common creation APIpipe()mkfifo()
Typical useParent-child communicationCommunication between independent processes

Pipe vs Shared Memory

CharacteristicPipeShared Memory
Data modelByte streamShared memory region
SynchronizationBasic stream semantics provide some coordinationMust be designed explicitly
Large dataRequires copying through the pipeEfficient for large shared datasets
ComplexityRelatively simpleMore complex

Choosing an IPC Mechanism

The best IPC mechanism depends on what the processes need to communicate and how they need to coordinate.

  • Use a pipe for simple streaming between related processes
  • Use a FIFO when unrelated processes need a named byte stream
  • Use shared memory for high-volume shared data when you can implement synchronization correctly
  • Use a message queue when communication naturally consists of discrete messages
  • Use signals for lightweight notifications and events

Producer-Consumer with IPC

A producer-consumer architecture separates work creation from work processing. One process generates data and another consumes it.

C
/* Producer */
write(pipefd[1], data, dataSize);

/* Consumer */
read(pipefd[0], buffer, sizeof buffer);

For more sophisticated producer-consumer systems, message queues or shared memory combined with synchronization primitives can provide better control.

IPC and Synchronization

Communication and synchronization are related but distinct concepts. Sending data does not necessarily protect shared state from concurrent modification.

For shared memory, synchronization may involve POSIX semaphores, process-shared pthread mutexes, condition variables, or other appropriate mechanisms.

Process-Shared POSIX Semaphores

A semaphore can be placed in shared memory and configured for use between processes. It can help control access to a shared resource.

C
#include <semaphore.h>

struct SharedData
{
    sem_t lock;
    int value;
};

The semaphore must be initialized with pshared set appropriately when it is intended to synchronize processes rather than threads within one process.

Handling Partial read() and write() Operations

POSIX read() and write() calls can transfer fewer bytes than requested. Robust IPC code should therefore handle partial transfers when the protocol requires a complete buffer or record.

C
#include <unistd.h>
#include <stddef.h>

ssize_t writeAll(int fd, const void *data, size_t size)
{
    const char *bytes = data;
    size_t total = 0;

    while (total < size)
    {
        ssize_t written = write(fd, bytes + total, size - total);

        if (written <= 0)
        {
            return -1;
        }

        total += (size_t)written;
    }

    return (ssize_t)total;
}

Production code should additionally consider interruption by signals and handle errors such as EINTR according to its requirements.

IPC and File Descriptor Inheritance

After fork(), file descriptors are inherited by the child. This makes pipes particularly useful for constructing communication channels before creating a child process.

After exec(), file descriptors normally remain open unless they have the close-on-exec flag set. Programs should deliberately manage descriptor inheritance to avoid leaking IPC channels into unrelated programs.

Avoiding Deadlocks

IPC programs can deadlock when processes wait for data that can never arrive or when each side waits for the other to perform an action.

  • Define who writes and who reads
  • Close unused pipe ends
  • Avoid circular waiting
  • Define message formats clearly
  • Use timeouts or nonblocking mechanisms where appropriate
  • Design synchronization rules before implementing shared memory

Security Considerations

IPC objects can expose data or control channels to other processes. Use appropriate permissions and validate all data received from another process.

For named IPC mechanisms, carefully choose names, permissions, ownership, and cleanup behavior. Never assume that data received from another process is trustworthy.

Common Mistakes

  • Forgetting to close unused pipe descriptors
  • Assuming pipe operations preserve application-level message boundaries
  • Ignoring partial read() or write() results
  • Using signals to transfer large amounts of data
  • Accessing shared memory without synchronization
  • Failing to remove unused named IPC objects
  • Creating circular waits between processes
  • Ignoring permissions on named IPC resources
  • Assuming fork() makes ordinary memory permanently shared

Best Practices

  • Choose IPC based on the communication model rather than convenience alone
  • Define a clear data protocol for byte streams
  • Close unused descriptors immediately after fork()
  • Handle partial transfers when using read() and write()
  • Synchronize access to shared memory
  • Keep signal handlers minimal and async-signal-safe
  • Validate all data received from another process
  • Use appropriate permissions for named IPC objects
  • Clean up IPC resources when they are no longer needed
  • Document ownership and lifecycle rules for every IPC resource

Quick Reference

APIPurpose
pipe()Create an anonymous pipe
mkfifo()Create a named pipe
shm_open()Create or open a POSIX shared-memory object
mmap()Map shared memory into a process address space
mq_open()Create or open a POSIX message queue
mq_send()Send a message through a POSIX message queue
mq_receive()Receive a message from a POSIX message queue
sigaction()Install a signal-handling configuration

Practice Exercises

  • Create a parent-child program that sends a string through a pipe
  • Modify the program to send multiple messages using a delimiter-based protocol
  • Create a FIFO and write a separate reader and writer program
  • Build a producer-consumer example using a POSIX message queue
  • Create a shared-memory region containing a counter and protect it with a process-shared semaphore
  • Use SIGUSR1 to notify another process that work is ready
  • Build a small command pipeline using fork(), pipe(), dup2(), and execvp()
  • Design a two-way protocol using two pipes and prevent deadlocks

Conclusion

Inter-process communication is fundamental to Unix and POSIX programming. Pipes provide simple byte streams, FIFOs allow named communication between independent processes, shared memory enables fast access to common data, message queues provide structured messages, and signals offer lightweight notifications. Reliable IPC requires more than simply exchanging bytes: processes must also define protocols, synchronize shared state, handle errors, manage resources, and avoid deadlocks.

Note: Note: The APIs covered here are primarily POSIX interfaces rather than ISO C. Availability, linking requirements, and some implementation details can vary between Unix-like systems.