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
| Mechanism | Typical Use |
|---|---|
| Anonymous pipe | Communication between related processes |
| FIFO | Communication through a named filesystem object |
| Shared memory | Fast exchange of large amounts of data |
| Message queue | Structured message-based communication |
| Signal | Small 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.
#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.
| Descriptor | Purpose |
|---|---|
| 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.
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.
#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().
#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
- Create or open a shared-memory object with shm_open()
- Set its size with ftruncate()
- Map it into the process using mmap()
- Read or write the shared region
- Unmap it with munmap()
- Close the file descriptor
- 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().
#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.
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.
#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
| Feature | Anonymous Pipe | FIFO |
|---|---|---|
| Filesystem name | No | Yes |
| Unrelated processes | Not directly through the pipe itself | Yes |
| Common creation API | pipe() | mkfifo() |
| Typical use | Parent-child communication | Communication between independent processes |
Pipe vs Shared Memory
| Characteristic | Pipe | Shared Memory |
|---|---|---|
| Data model | Byte stream | Shared memory region |
| Synchronization | Basic stream semantics provide some coordination | Must be designed explicitly |
| Large data | Requires copying through the pipe | Efficient for large shared datasets |
| Complexity | Relatively simple | More 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.
/* 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.
#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.
#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
| API | Purpose |
|---|---|
| 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.