Lesson 04 / 15
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.
Published Updated
In this lesson
- 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
Contents
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.
// 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
// 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);
}
// 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;
}
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_SHAREDmapping 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
0bytes 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_ANONYMOUSshows 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. socketpairsets 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_measurementreally 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.