Skip to content
academia.sh

Lesson 02 / 15

Process Management

fork splits a process in two and hands the child a copy of the memory at that moment; once the copy is made, the two sides are independent of each other — no change reaches the other side in either direction.

Published Updated

In this lesson

  • shows that after fork the two processes hold the same variable as independent copies, measuring both directions
  • explains the rule that fflush(stdout) is mandatory before fork by measuring the copy risk of a buffered structure
  • describes in prose, without running the code, why the exec family replaces the process image
Contents

The previous lesson showed that a descriptor derived with dup shares the same open file description: when one moved the offset, the other saw it. fork does the opposite. The moment it is called it hands the child a copy of the process’s memory, and once that copy is made the two sides are completely independent of each other. One of the course’s four classes, copy, describes exactly this: the change stays only on the side that made it, the other side never sees it. This lesson measures that independence in both directions, across two separate fork calls and two different data types: first two plain integers, then through the macros that read the exit status, and finally through a small structure that models standard output’s buffer.

Measuring the Copy in Both Directions

The measurement units stay the same: observe.h and observe.c are identical to the previous lesson’s. The new part in this file is process_measurement.c, which uses two fork calls back to back: the first measures two plain integers in both directions, the second measures a small structure that models standard output’s buffer the same way.

// 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);
}
// process_measurement.c — two separate forks, two separate data types, the same copy rule
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#include "observe.h"

/* Model: not a real stdio buffer, a small structure that follows the same copy rule */
typedef struct {
    char data[32];
    int length;
} ModelBuffer;

static void model_write(ModelBuffer *b, const char *s) {
    int n = 0;
    while (s[n] != '\0' && b->length < 31) {
        b->data[b->length] = s[n];
        b->length++;
        n++;
    }
}

int main(void) {
    int value_a = 0;
    int value_b = 0;

    fflush(stdout);
    pid_t p = fork();
    if (p == 0) {
        value_a = 1;      /* changes only in the child's own copy */
        _exit(value_b);   /* exits without ever seeing the parent's later change */
    }

    value_b = 7;           /* after fork, changes only in the parent's copy */
    int status = 0;
    waitpid(p, &status, 0);

    observe("child's variable after fork", COPY, value_a == 1);
    observe("parent's variable after fork", COPY, WEXITSTATUS(status) == 7);
    printf("child's normal exit (WIFEXITED): %s\n", WIFEXITED(status) ? "yes" : "no");

    /* second fork: does the model buffer follow the same rule too */
    ModelBuffer buffer;
    memset(&buffer, 0, sizeof buffer);
    model_write(&buffer, "previous");   /* model of content accumulated before fork, not yet flushed */

    fflush(stdout);
    pid_t p2 = fork();
    if (p2 == 0) {
        model_write(&buffer, "child");
        _exit(buffer.length);
    }
    model_write(&buffer, "parent");
    int status2 = 0;
    waitpid(p2, &status2, 0);

    observe("did the child's addition to the buffer reach the parent", COPY,
           WEXITSTATUS(status2) == buffer.length);

    observe_summary();
    return 0;
}
child's variable after fork        missed   copy
parent's variable after fork       missed   copy
child's normal exit (WIFEXITED): yes
did the child's addition to the buffer reach the parent missed   copy
classes: copy 3, shared 0, handoff 0, unobserved 0
reached 0, not reached 3

SI3. Both of the first two lines say missed. The child sets value_a to 1 in its own copy; after waitpid, the parent looks at its own value_a and still finds 0 — the child’s write never reached the parent’s copy. Symmetrically, the parent sets its own value_b to 7 after fork returns; the child exits with _exit(value_b) without ever seeing that line, and since value_b in its own copy is still 0, its exit code is also 0. When the parent reads that with WEXITSTATUS and finds 0, not 7, it confirms that its own change never reached the child at all. Both fall into the same class: copy. fork copies once, and after that the two sides are unaware of each other — which side writes first and which writes second makes no difference here, because there is no shared record, there are two independent copies.

Measuring the fflush Rule

SI4. The third line carries this independence from an integer into a multi-line buffer. ModelBuffer stands in for a real stdio buffer: before fork it accumulates the text "previous", just like an unflushed output line waiting to be written to the terminal. When fork is called this structure is copied along with the rest of memory too — the child adds "child" to its own copy and reports its length through the exit code, the parent adds "parent" to its own copy. Because "child" and "parent" are different lengths, the two sides’ final lengths never come out equal, and the measurement reads this as missed: the child’s addition to its own buffer never reached the parent’s buffer. The rule that holds in every lesson of the course is exactly what this measurement shows: the fflush(stdout) call before fork clears whatever unflushed content sits inside the real stdio buffer, so there is nothing left to copy. Skip that line, and content that has not yet been written — like "previous" — sits in both the parent’s and the child’s copy together; if each flushes its own buffer at its own close, the same line appears twice — once from the parent, once from the child, because now both carry a separate copy belonging to the same class, copy. process_measurement.c closes off this risk twice: fflush(stdout) is called right before each of the two fork calls, and the observe calls themselves only run on the parent side after waitpid; the child never prints anything, it exits silently with _exit.

Exit Status Macros

SI5. The status that waitpid fills in is a single integer, but it encodes two separate questions: how the process ended, and with what value. WEXITSTATUS(status) only answers the second question — it returns the value the child gave to _exit or return — but this macro is only meaningful if the first question is affirmative, that is, if the child really did exit the normal way. WIFEXITED(status) answers the first question: it confirms that the child ended by its own choice, not through a signal. This lesson finds WIFEXITED true after both fork calls, because both children exit with _exit; the distinction stays abstract until the upcoming lesson on signals shows the macro’s counterpart, a process that ends through a signal. The point here is this: relying on WEXITSTATUS without a WIFEXITED check can be misleading, because the status field of a process that ended through a signal carries the same bits for a different purpose — this lesson does not run that case, but records here why the macro is not sufficient on its own.

The value WEXITSTATUS returns is limited to eight bits, because POSIX guarantees carrying the exit code at that width; both measurements inside process_measurement.c were chosen to stay under that limit — value_b is seven, the model buffer’s length is under thirty-one. A program that gives _exit a three-digit or negative value and then reads the exit code expecting that value can produce a misleading result because of this eight-bit truncation; this lesson does not measure that case separately, but the reason the observe calls choose small fixed values is exactly to avoid that limit.

WIFEXITED‘s counterpart is WIFSIGNALED: this macro returns true if the child ended because of a signal, and in that case WTERMSIG(status) reads which signal ended it. This lesson does not run that macro, following the rule that forbids printing a signal number or name — it would neither produce a printable value nor add anything to this lesson’s measurement axis, because a process that ended through a signal has no such thing as an exit code, only the signal that ended it. A program that relies on WEXITSTATUS after waitpid without checking both WIFEXITED and WIFSIGNALED ends up reading an undefined value as if it were an exit code, for a child that ended through a signal; so the distinction between the two is not merely a style preference, it is a precondition for reading correctly.

The exec Family

SI6. After fork, the child process is usually followed by a second step: a call from the exec family. fork copies memory but does not change the program code — the child is still a copy of the same program. exec, on the other hand, keeps the process identity but rebuilds the process image from scratch: the code, data, and stack are replaced with a new program’s, and when the call succeeds it does not return. This lesson does not run exec, because the question it measures — who sees what — loses its meaning after exec: the old process’s variables, its value_a, its value_b, its model buffer, are all gone, replaced with new ones. Using fork and exec together is the foundation of a shell starting a new program: the shell first produces its own copy with fork, the child process inherits the environment variables and open descriptors, and then loads the desired program over that copy with exec. The parent shell never changes; only the child calls exec, and once it succeeds it is no longer the shell, it is the program that was called.

The members of the exec family differ from each other along three axes, all in prose: how they take their arguments (execl and similar take a variable number of arguments, execv and similar take an array), how they find the program (execve only accepts a full path, execvp searches the PATH environment variable), and how they inherit the environment (execve and execvpe accept a new environment array, the others use the calling process’s environment as is). What they all share is that there is no return value — if the call succeeds it does not return, if it fails it returns -1 and the calling process continues with its own variables. An exec called without fork irreversibly turns the calling process into another program; that is why a shell starting a new program without losing its own process depends on producing a copy first with fork and calling exec in that copy. Open file descriptors stay open across exec by default too — unless the FD_CLOEXEC flag is set, the descriptors the child inherited from fork are still valid in the new program; a shell’s implementation of redirection syntax (>, <) rests exactly on this: the shell first moves a descriptor to the desired slot with dup2, then calls exec, and the new program uses that descriptor without ever knowing where it came from.

How fork produces its memory copy is not this lesson’s subject; the operating systems course’s memory management section already answered that question — the kernel most of the time does not actually duplicate pages, it marks them with copy-on-write, and the real copy only happens when one side writes. This lesson does not touch that internal machinery; the only thing it measures is that, whatever mechanism does the copying, the two sides end up independent — whether it is an integer or a multi-line buffer.

The ModelBuffer measurement does not imitate a real stdio buffer’s internal structure; it is just a small structure of our own, following the same copy rule. The generality here matters: the byte sequence a real FILE* buffer holds, and the integer variable this lesson measures elsewhere, are both subject to the same copy rule at the moment of fork — the child takes the parent’s state at that moment, and afterward the two change independently. ModelBuffer makes this generality concrete: a counter three bytes wide and an array thirty-two bytes wide get equal treatment from fork‘s point of view. The difference in real stdio is that the library’s decision, not the programmer’s, determines when the buffer is flushed — a line-buffered terminal flushes at the end of every line, a fully buffered file only flushes when the buffer fills or the program closes. fork does not change this flush timing, it only splits the content accumulated up to that point into two independent copies; what violates the rule is not the absence of an fflush(stdout) call, it is the expectation that accumulated content will be flushed twice.

The Bounding Measurement

This measurement’s boundary is that it is independent of timing, and that is a deliberate design choice. Whichever order the child’s _exit call and the parent’s later write run in, the result does not change, because neither writes into the same memory — each writes into its own copy. That is why the measurement needs no sleep or wait; the waitpid call alone is enough, because it confirms not the ordering but the independence of the copies. The model buffer measurement carries the same boundary: choosing "child" and "parent" at different lengths is not arbitrary — it is deliberate, so the result comes out the same on every run, because this lesson measures not a race condition but only the presence of copying. Other classes in the course — handoff in particular — can be sensitive to ordering, but for the copy class, ordering never changes the result.

Another question this measurement does not cover is how the result changes with the number of fork calls. process_measurement.c calls two forks back to back, the second only after the first child has fully closed — the second fork does not wait for the first child to drop out of the process table, because waitpid already collected the first one. Had a program instead started two children at the same time, the second without waiting for the first to finish, both children would still each carry a separate copy of the same parent memory; the copy class’s guarantee is independent of the number of children, because every fork call produces its own new, independent copy on its own, unaware of previous children. This lesson does not produce more than one child at a time, because what the result shows is already fully proven through a single pair; extra children would only repeat the same guarantee, not introduce a new class or a new boundary.

Summary

  • fork hands the child a copy of the memory state at the moment it is called; once the copy is made, the two sides are independent.
  • This independence runs both ways: a variable the child writes does not reach the parent, and a variable the parent writes after fork does not reach the child either.
  • The same independence is not limited to a single integer; a larger structure, like a multi-line buffer, follows the same rule too.
  • fflush(stdout) is mandatory before fork; otherwise buffered content gets copied and the same line can be written twice.
  • WIFEXITED and WEXITSTATUS are read together: one says how the process ended, the other with what value; without one, the other can be misleading.
  • The exec family rebuilds the process image from scratch and does not return; this lesson does not run it, because the question of copied variables becomes meaningless after exec.

Next Step

The next lesson moves to a completely different way of carrying a change than the variables fork copies: a flag a signal handler raises, a handoff that reaches the main flow only once.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close