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
forkhands 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
forkdoes 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 beforefork; otherwise buffered content gets copied and the same line can be written twice.WIFEXITEDandWEXITSTATUSare read together: one says how the process ended, the other with what value; without one, the other can be misleading.- The
execfamily rebuilds the process image from scratch and does not return; this lesson does not run it, because the question of copied variables becomes meaningless afterexec.
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.