Skip to content
academia.sh

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_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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close