CodeRigor

FREE LESSON

Processes: fork, exec, pipes & wait

Launch a program without a shell, arrange inherited descriptors, distinguish exec failure from exit status, drain a pipe before waiting and reap every child.

A process supplies an address space, a descriptor table and execution state. A thread runs within a process and shares its ordinary memory. Unix fork creates a child process with logically separate ordinary memory; inherited descriptors can still refer to shared open file descriptions.

Fork separates ordinary memory, shares open-file references
Parent and child have separate variable state. Their inherited descriptors can still point to the same open file description.
Fork separates ordinary memory, shares open-file references
Parent and child have separate variable state. Their inherited descriptors can still point to the same open file description.
C++
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int p[2];assert(::pipe(p)==0);auto child=::fork();assert(child>=0);
    if(child==0){::close(p[0]);char c='Z';if(::write(p[1],&c,1)!=1)::_exit(99);::_exit(7);}
    ::close(p[1]);char c=0;assert(::read(p[0],&c,1)==1);::close(p[0]);int status;assert(::waitpid(child,&status,0)==child);
    assert(WIFEXITED(status));std::printf("%c %d\n",c,WEXITSTATUS(status));
}

The actual PIDs are intentionally absent from predicted output: allocation and scheduling are not stable answers. The parent explicitly reads data through a pipe and waits for its specific child, so the observed output has a real ordering.

Fork, then replace the child image

Fork returns zero in the child, a positive child PID in the parent, and minus one on failure. Exec replaces the current image and preserves the PID. A successful exec does not return to the old program. Build argv before forking, use literal entries, and keep shell interpretation outside this launcher.

After fork, inherited library locks and buffered stdio are delicate, especially in a multithreaded caller. The child path here only rearranges descriptors, executes, or reports failure with async-signal-safe operations and _exit. It must not throw, run arbitrary C++ cleanup, or flush a copy of the parent's stdio buffers.

Two pipes answer two different questions
Stdout carries arbitrary program bytes. The error pipe carries errno only when exec fails; successful exec atomically closes that pipe’s CLOEXEC write end.
Two pipes answer two different questions
Stdout carries arbitrary program bytes. The error pipe carries errno only when exec fails; successful exec atomically closes that pipe’s CLOEXEC write end.

Close-on-exec gives the error pipe a useful protocol: a successful exec closes the write end, so the parent sees EOF; an unsuccessful exec writes errno before _exit. Exit 127 alone is ambiguous, because a real program may choose that exit code. A second pipe separates launch failure from the launched program's stdout.

Drain, then reap

Close every unused pipe end. A reader sees EOF only when all write references are gone. The parent must drain output before waiting: a child can fill the pipe and block until the parent reads, so wait-before-read can deadlock.

Do not wait while the child is blocked on output
The parent first closes unused write ends and drains stdout. Only then can a full-output child always reach exit and waitpid complete.
Do not wait while the child is blocked on output
The parent first closes unused write ends and drains stdout. Only then can a full-output child always reach exit and waitpid complete.
C++
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int value=3;int p[2];assert(::pipe(p)==0);auto child=::fork();assert(child>=0);
    if(child==0){::close(p[0]);value=8;if(::write(p[1],&value,sizeof value)!=sizeof value)::_exit(99);::_exit(0);}
    ::close(p[1]);int copied=0;assert(::read(p[0],&copied,sizeof copied)==sizeof copied);::close(p[0]);int status;assert(::waitpid(child,&status,0)==child);
    std::printf("%d %d\n",value,copied);
}

A child that exited still needs waitpid to release its waitable status. Decode the result only with the matching WIFEXITED/WEXITSTATUS or WIFSIGNALED/WTERMSIG macros. An interrupted wait retries for the same child. The build returns the reaped PID so a test can verify that a second wait reports ECHILD.

Keep the launch contract bounded

The teaching launcher caps output. If the cap is exceeded, it kills and reaps that child before returning EFBIG. It is designed for terminating programs in a single-threaded caller, not process groups, shell job control or arbitrary long-running descendants. Its tests inspect literal arguments, large binary output, failed exec, nonzero exits, signal termination and descriptor cleanup.

Before the fill, distinguish child exit 127 from failed exec of a missing file. Then decode a signaled child without treating the packed status as a number the program returned. Continue with memory mapping or signals and event notifications.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Start with fork’s two return paths and independent address spaces. The lesson adds literal argv, close-on-exec error reporting, full pipe draining and wait-status decoding in C++.