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