---
title: 'Inter-Process Communication'
source: 'https://academia.sh/en/courses/c-systems-programming/inter-process-communication'
course: 'Systems Programming with C'
language: en
updated: '2026-10-21T05:02:45+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'shows that a value written to a pipe reaches the parent once, and that the second read ends with a 0-byte end-of-file'
    - "distinguishes a mapping set up with MAP_SHARED, which shows the same page to two processes at once, from the pipe's handoff by comparing them"
    - 'measures that a pipe carries no message boundary, by running a solution built with its own length prefix'
---

# Inter-Process Communication

A pipe carries bytes once, and every byte read drops out of the stream; a mapping set up with mmap and MAP_SHARED shows the same page to two processes at once, and these two fall into two different classes within the same lesson.

The previous lesson measured the trace a signal leaves: a flag, a handoff that
reaches the main flow once. This lesson measures the same class between two
processes, and deliberately places a different class next to it. A **pipe** carries
the bytes the child writes to the parent — every byte read drops out of the stream,
this is **handoff**. An **`mmap` mapping**, on the other hand, shows the same memory
page to two processes at once — when one writes, the other sees it at the same time,
this is **shared**. Placing the two classes side by side in the same lesson makes the
difference between them visible.

## The Value Written to the Pipe

`observe.h` and `observe.c` do not change. This lesson's own file,
`ipc_measurement.c`, runs four measurements in order: a pipe, a shared mapping, a
two-way channel, and two messages separated by a length prefix.

```c
// observe.h — a record that classifies whom a change reaches
#ifndef OBSERVE_H
#define OBSERVE_H

enum Class { COPY, SHARED, HANDOFF, UNOBSERVED };

/* Records one change: seen is whether the side that did not make the
   change can read it (1 or 0). */
void observe(const char *change, enum Class cls, int seen);

/* Prints the count per class and the reached / not reached totals. */
void observe_summary(void);

#endif
```

```c
// observe.c — the counts live only in this unit
#include <stdio.h>
#include "observe.h"

static const char *const names[] = { "copy", "shared", "handoff", "unobserved" };
static int counts[4];
static int reached = 0;
static int not_reached = 0;

void observe(const char *change, enum Class cls, int seen) {
    /* shared and handoff reach, copy and unobserved do not */
    int expected = (cls == SHARED || cls == HANDOFF);
    counts[cls]++;
    if (seen) {
        reached++;
    } else {
        not_reached++;
    }
    printf("%-34s %-8s %s%s\n", change, seen ? "saw" : "missed",
           names[cls], seen == expected ? "" : "  MISMATCH");
}

void observe_summary(void) {
    printf("classes: copy %d, shared %d, handoff %d, unobserved %d\n",
           counts[COPY], counts[SHARED], counts[HANDOFF], counts[UNOBSERVED]);
    printf("reached %d, not reached %d\n", reached, not_reached);
}
```

```c
// ipc_measurement.c — a pipe, a shared mapping, and a two-way channel
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include "observe.h"

/* 1. Pipe: the value the child writes reaches the parent once, the second read gives end-of-file */
static void pipe_measurement(void) {
    int ends[2];
    if (pipe(ends) != 0) {
        return;
    }
    fflush(stdout);
    pid_t p = fork();
    if (p == 0) {
        int value = 42;
        close(ends[0]);
        ssize_t n = write(ends[1], &value, sizeof value);
        close(ends[1]);
        _exit(n == (ssize_t)sizeof value ? 0 : 1);
    }
    close(ends[1]);
    int received = 0;
    ssize_t first = read(ends[0], &received, sizeof received);
    waitpid(p, NULL, 0);
    char remaining;
    ssize_t second = read(ends[0], &remaining, 1);
    close(ends[0]);
    observe("value written to the pipe", HANDOFF,
           first == (ssize_t)sizeof received && received == 42 && second == 0);
}

/* 2. Shared mapping: the parent sees the page the child writes at the same time */
static void mapping_measurement(void) {
    int *shared = mmap(NULL, sizeof *shared, PROT_READ | PROT_WRITE,
                     MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (shared == MAP_FAILED) {
        return;
    }
    *shared = 0;
    fflush(stdout);
    pid_t p = fork();
    if (p == 0) {
        *shared = 99;
        _exit(0);
    }
    waitpid(p, NULL, 0);
    observe("shared value written by the child", SHARED, *shared == 99);
    munmap(shared, sizeof *shared);
}

/* 3. Two-way channel: a value written on one end is read from the other end */
static void channel_measurement(void) {
    int ends[2];
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, ends) != 0) {
        return;
    }
    fflush(stdout);
    pid_t p = fork();
    if (p == 0) {
        close(ends[0]);
        int value = 7;
        write(ends[1], &value, sizeof value);
        int reply = 0;
        read(ends[1], &reply, sizeof reply);
        _exit(reply == 14 ? 0 : 1);
    }
    close(ends[1]);
    int incoming = 0;
    read(ends[0], &incoming, sizeof incoming);
    int reply = incoming * 2;
    write(ends[0], &reply, sizeof reply);
    int status = 0;
    waitpid(p, &status, 0);
    close(ends[0]);
    observe("reply received over the channel", HANDOFF,
           incoming == 7 && WIFEXITED(status) && WEXITSTATUS(status) == 0);
}

/* 4. Length-prefixed message boundary: two messages from a pipe are split apart without mixing */
static ssize_t read_full(int fd, void *buffer, size_t count) {
    size_t received = 0;
    while (received < count) {
        ssize_t n = read(fd, (char *)buffer + received, count - received);
        if (n <= 0) {
            return n;
        }
        received += (size_t)n;
    }
    return (ssize_t)received;
}

static void write_prefixed(int fd, const char *text) {
    unsigned char length = (unsigned char)strlen(text);
    write(fd, &length, 1);
    write(fd, text, length);
}

static void prefix_measurement(void) {
    int ends[2];
    if (pipe(ends) != 0) {
        return;
    }
    fflush(stdout);
    pid_t p = fork();
    if (p == 0) {
        close(ends[0]);
        write_prefixed(ends[1], "first");
        write_prefixed(ends[1], "secondmessage");
        close(ends[1]);
        _exit(0);
    }
    close(ends[1]);

    unsigned char length1 = 0;
    read_full(ends[0], &length1, 1);
    char first[32];
    read_full(ends[0], first, length1);
    first[length1] = '\0';

    unsigned char length2 = 0;
    read_full(ends[0], &length2, 1);
    char second[32];
    read_full(ends[0], second, length2);
    second[length2] = '\0';

    waitpid(p, NULL, 0);
    close(ends[0]);
    observe("two prefixed messages split correctly", HANDOFF,
           strcmp(first, "first") == 0 && strcmp(second, "secondmessage") == 0);
}

int main(void) {
    pipe_measurement();
    mapping_measurement();
    channel_measurement();
    prefix_measurement();
    observe_summary();
    return 0;
}
```

```text
value written to the pipe          saw      handoff
shared value written by the child  saw      shared
reply received over the channel    saw      handoff
two prefixed messages split correctly saw      handoff
classes: copy 0, shared 1, handoff 3, unobserved 0
reached 4, not reached 0
```

**SI12.** All four lines say `saw`, but the second line's class differs from the
others. The child process writes `42` to the pipe, the parent reads it with `read` —
this is a **handoff**: the byte drops out of the stream, the second `read` call
returns `0` because there is nothing left to read, that is, end-of-file. This `0`
return is not an error, it is a **status**: `read`'s return value is normally the
number of bytes read, negative means an error, zero means "the write end has closed
and there is no more data left to read in the buffer." `pipe_measurement` calls
`close(ends[1])` on the child side, then exits with `_exit` — once the last copy of
the write end closes too, the kernel carries the information "no new data will
arrive" to the read end through exactly this `0` return. Had the child not closed the
write end, the second `read` would have blocked until data arrived — a `0` return is
not a sign of absent data, it is a sign that **all writers have detached**.

But `mapping_measurement` works differently: the call
`mmap(..., MAP_SHARED | MAP_ANONYMOUS, ...)` allocates a memory page before `fork`
and marks that page as **shared**. When `fork` copies process memory it does not copy
this particular mapping — the parent and the child end up with two separate pointers
pointing at the same physical page. When the child writes `*shared = 99`, the parent,
looking at its own `*shared` after `waitpid`, also sees `99` — because both are
looking at the same page. This is the definition of the **shared** class: it is not
carried once like a record, it is shared continuously. There is no counterpart here
to the concept of "end-of-file": there is no such thing as a memory page "running
out," the page just sits there until one of the processes calls `munmap` or the
process ends.

## Pipe or Shared Memory

**SI13.** The visibility of these two mechanisms can be described with the same
words — "the parent sees what the child writes" — but the underlying mechanism is
completely different. A pipe is a buffer the kernel holds; writing and reading are
system calls, and data read is erased from the buffer. The two processes never look
at the same memory, the kernel is the carrier in between. A mapping set up with
`MAP_SHARED`, on the other hand, really does point at the same physical page; reading
and writing are ordinary memory access, no system call is needed, and data never
"runs out" — the page stays there every time it is read. That is why the pipe's
second read gives end-of-file, while the shared page's second read still gives the
same value; `mapping_measurement` does not try this because the difference is
already visible on the first read.

The practical consequence of this contrast is which mechanism to choose and when. A
pipe carries an ordered, one-way stream, synchronized by the kernel, so it gives a
natural answer to the question "who sent what and when" — the reading side blocks
until data arrives, it never enters a race. `MAP_SHARED`, on the other hand, does
**not** give synchronization: if two processes write to the same page at the same
time, the result depends on which write appears first, and this lesson does not set
up such a collision, it only uses a one-way write-then-read order. Shared memory
gives visibility, not ordering — locking is a separate responsibility, taken up
separately for threads in this topic's final lesson.

`channel_measurement` shows a two-way channel set up with `socketpair` once. This
channel is also a carrier that flows through the kernel and runs out, like a pipe —
the difference is that it is two-way, not one-way: the same two ends can both be
written to and read from. This lesson does not open a network socket here; `AF_UNIX`
is a local endpoint, no address or port is ever printed. The child sends a value, the
parent multiplies it by two and sends it back, the child reads the reply and writes
it into its exit code — this too is **handoff**, because every value flowing over the
channel is read once and consumed. The difference between `pipe` and `socketpair` is
the number of directions, not the class: `pipe` gives a one-way pair of file
descriptors, `socketpair` gives a fully two-way channel where both descriptors can be
both read from and written to — both are subject to the same kernel buffering rule,
the same running-out behavior.

This dependency has a concrete consequence: even though `socketpair`'s two ends look
like two independent descriptors, neither end sees end-of-file until the write side
of both descriptors is closed — just as with `pipe`. The only difference is who can
write to whom: with `pipe` one end can only write, the other can only read; with
`socketpair` both ends can be used in both directions. That is why
`channel_measurement` has to be careful about the order in which the child closes the
channel — if it did not close its own descriptor after sending the reply, the read on
the parent side could wait forever, because the kernel would still see an open write
end and would not produce end-of-file. Calling `close(ends[1])` after the
`write_prefixed` calls in `prefix_measurement` is another instance of the same rule:
the parent side does not keep waiting after reading the two messages, because the
child explicitly closes its own write end.

The choice among these three mechanisms depends on the shape of the data flow. For a
one-way, ordered stream, `pipe` is the simplest tool; if both sending and receiving
are needed from the same two ends, `socketpair` avoids opening an extra channel; if
the copying cost needs to be eliminated entirely for large or frequently shared data,
`MAP_SHARED` comes into play, but then the responsibility for synchronization falls
on the designer.

## The Bounding Measurement

**SI14.** A pipe is a **stream**, it carries no message boundary. In the previous
three measurements every `write` call was met with a single `read`, but this
guarantee is not general: two separate `write` calls can arrive together in a single
`read`, or the data of one `write` can be split across multiple `read` calls. A pipe
keeps the bytes sent in a queue and has no idea where one message ends and another
begins — there is nothing carrying that information.

**SI15.** `prefix_measurement` really sets up this boundary and really tests it. The
child process writes two messages to the same pipe back to back — `"first"` and
`"secondmessage"` — but not as raw text; `write_prefixed` puts a one-byte length
field in front of each message. The parent side reads these two messages not with a
single `read` call, but with the `read_full` helper function: first the length byte,
then exactly that many data bytes. `read_full` itself also compensates for a
guarantee — a single `read` call may not bring back all the bytes requested, so the
function keeps reading in a loop until it reaches the requested count. The bytes of
the two messages flow one after another on the same pipe, with no separator between
them; the only thing that cuts the reading side at the right place is the length
information each message carries with it. The measurement confirms that `"first"`
and `"secondmessage"` are reconstructed correctly, without mixing — this lesson did
not draw its own message boundary on its own before, because it showed the earlier
three values in a single read each, but `prefix_measurement` draws one now: what
gets printed is only the reconstructed messages, not the raw bytes off the pipe.

`write_prefixed` carries the length as an `unsigned char`, that is, a one-byte field
— it can describe a message of at most 255 bytes. That is enough for this
measurement, because `"secondmessage"` is well under that limit; but in a real
protocol, if message size is unpredictable, one byte is not enough, a two- or
four-byte field is needed, and then the question of byte order (`endianness`) comes
in — do the sending and receiving machines write and read in the same order. This
lesson does not open that question, because the pipe runs both ends on the same
machine; in a protocol talking over a network, the order of the length field must be
explicitly fixed, otherwise a number read correctly on one end comes out wrong on the
other. The rule that matters does not change no matter how large the length field is:
the mechanism carrying it is the same — first read how much data is coming, then read
exactly that much.

`read_full`'s loop is not cosmetic, it is a direct consequence of pipe semantics. The
kernel does not guarantee delivering the bytes of a `write` call as a single unit; when
the reading side calls `read`, the kernel returns however many bytes happen to have
accumulated in the pipe buffer at that moment, which can be fewer than requested.
This never showed up in the `pipe_measurement` and `mapping_measurement`
measurements, because the values written were only a few bytes long and a single
`read` call always sufficed; but that is not the general rule. `read_full` closes
this gap in one place, once, by continuing to read until it reaches the requested
count — the calling side no longer has to deal with the question "how many `read`
calls did it take," `read_full` itself makes short reads invisible. It separately
checks the moment `n <= 0` is returned, because `0` indicates the pipe has closed and
a negative value indicates an error; both mean "a full count of bytes was expected
but did not arrive," and in that case the loop exits immediately instead of spinning
forever.

## Summary

- This lesson places two different classes side by side: a pipe is **handoff**, a
  `MAP_SHARED` mapping is **shared** — even though both can be described with the
  sentence "the parent sees what the child writes," their mechanisms are separate.
- A pipe is a carrier that flows through the kernel and runs out as it is read; the
  second read returns `0` bytes because all write ends have closed — this is not an
  absence of data, it is a sign that the writers have detached.
- A mapping set up with `MAP_SHARED | MAP_ANONYMOUS` shows the same physical page to
  two processes at once; when one side writes, the other side reads the same page,
  data never runs out — but it gives visibility only, not ordering; locking is a
  separate responsibility.
- `socketpair` sets up a two-way channel; this too is a handoff, because every value
  that flows is read once and consumed. No network socket is opened, no address or
  port is printed.
- A pipe carries no message boundary; `prefix_measurement` really sets one up with a
  length prefix and shows it by reconstructing two messages without mixing them.

## Next Step

The next lesson — the last in this topic — stays within the same process but moves
from processes to threads: a counter written under a lock, a **shared** value that
reaches the main thread after a `join`.
