Session 3 · Operating Systems

exec, system and waiting for children

waitpid and the status macros, exec that puts a new program into the same process, the fork + exec + wait pattern every shell uses, what survives exec, and system() as the shortcut through /bin/sh.

17 min readwait, waitpid and WEXITSTATUSConcurrent parent and child, zombiesThe exec family: l, v, p, efork + exec + waitWhat survives execsystem() and its costs

The previous session created processes with fork, but every child ran the same program as its parent. This session completes the picture. wait and waitpid let the parent find out when and how a child ended. exec replaces the program running in a process with another one – which is how the shell starts every command you type. And system wraps all of it into one call, with a shell in between.

The model in one breath: fork makes a new process, exec puts a new program into it, exit ends it with a code, and wait collects that code in the parent. The PID and the open files survive exec; the memory does not.

wait, waitpid and the exit status

wait(&status) blocks until one of the caller's children ends, then returns that child's PID and removes its zombie. waitpid(pid, &status, options) does the same for one specific child. Both return -1 on an error – most often ECHILD: there is no child left to wait for. Both live in sys/wait.h.

CallWaits forNotes
wait(NULL)any one childThrows the status away – fine when you only need the child to be finished.
wait(&status)any one childStores how it ended in status.
waitpid(pid, &status, 0)the child with that PIDUse the PID that fork returned. pid = -1 means any child, like wait.
waitpid(pid, &status, WNOHANG)nobody – it only checksReturns 0 immediately if the child is still running.

The status is not the exit code. It packs several facts into one int: whether the child exited normally or was killed by a signal, and the exit code or the signal number. The macros of sys/wait.h take it apart (Fig. 1).

how the child endedstatus (on Linux)ask the macrosexit(3)or return 3 from main768exit code × 256WIFEXITED → trueWEXITSTATUS → 3kill -9 PIDSIGKILL from outside9the signal numberWIFSIGNALED → trueWTERMSIG → 9Only the lowest 8 bits of the exit code survive: exit(256) arrives as 0, exit(-1) as 255.The raw numbers are an implementation detail – always decode with the macros from sys/wait.h.
Fig. 1 · status is an encoded int. Ask the macros: did it exit (and with what code), or was it killed (and by which signal)?

fork2_mashogy.c – waiting for one child and reading its status

if (waitpid(child1_pid, &status, 0) == -1)
{
    perror("waitpid child1 failed");
}
else if (WIFEXITED(status))
{
    printf("Child1 terminated normally with exit status: %d\n", WEXITSTATUS(status));
}
else
{
    printf("Child1 terminated abnormally\n");
}

The else branch covers the other way to end: a signal. To say which one, add a WIFSIGNALED branch – signals are the topic of the next session:

else if (WIFSIGNALED(status))
{
    printf("killed by signal %d\n", WTERMSIG(status));   // e.g. 9 after kill -9
}
MacroMeaning
WIFEXITED(status)True if the child ended with exit or by returning from main.
WEXITSTATUS(status)The exit code, 0–255. Only valid if WIFEXITED is true.
WIFSIGNALED(status)True if a signal killed the child.
WTERMSIG(status)The number of that signal. Only valid if WIFSIGNALED is true.

Waiting for all children

When you started several children and don't care about the order, loop until wait reports that there are none left. Each round collects one child:

pid_t pid;
int status;
while ((pid = wait(&status)) > 0)          // any child; -1 (ECHILD) when none is left
{
    if (WIFEXITED(status))
        printf("child %i ended with %d\n", pid, WEXITSTATUS(status));
}

With WNOHANG, waitpid becomes a question instead of a wait: has it ended yet? The parent can keep working and check from time to time:

pid_t r = waitpid(pid, &status, WNOHANG);
if (r == 0)
    printf("still running\n");       // WNOHANG: don't block, just look
else if (r == pid)
    printf("ended\n");               // status is filled, the zombie is gone

Parent and children at the same time

Nothing makes a parent wait for its child unless it calls wait. In the counter example both processes count, one line per second – the child to 10, the parent to 20 – and their lines interleave on the terminal:

counter.c – two loops at the same time

child1();                       // the child counts 0..9, one per second, then exit(0)
int count_parent = 0;
while (count_parent < 20)       // meanwhile the parent counts 0..19
{
    printf("Parent Process: %i\n", count_parent);
    sleep(1);
    count_parent++;
}
wait(NULL);                     // the child has been a zombie since second 10
Parent Process: 0
This is a Child Process!
Child Process: 0
Parent Process: 1
Child Process: 1
...
Child Process: 9
Parent Process: 10
Parent Process: 11
...

The child finishes at second 10, but the parent is still in its loop and calls wait only at second 20. For those ten seconds the child is a zombie – run ps -u $USER -o pid,ppid,stat,cmd from another terminal and you see it with state Z and <defunct> (Fig. 2).

0 s5 s10 s15 s20 sparentParent Process: 0 … 19 – one line per secondwait(NULL)childChild Process: 0 … 9Z – zombie, <defunct>exit(0)reaped hereBoth print at the same time: the lines interleave, and the order within one second can change between runs.
Fig. 2 · The child ends at 10 s and waits, as a zombie, for the parent's wait at 20 s.

Two children: nested or in functions

The week's examples start two children in two styles. One nests the second fork inside the parent's branch of the first, so only the parent forks again:

fork2.c – nested branches

pid_t child = fork();
if (child > 0)                          // only the parent forks the second time
{
    pid_t child2 = fork();
    if (child2 > 0)
    {
        waitpid(child, &status, 0);
        waitpid(child2, &status, 0);
        printf("The value is %i in parent process\n", notacommonvalue);
    }
    else { notacommonvalue = 16; /* child2 */ }
}
else { notacommonvalue = 5; /* child 1 */ }

The other puts each child into a function that forks, lets the child do its job and exit, and returns the PID to the parent – the style of the previous session. Nesting gets unreadable beyond two children; the function style scales, and it keeps each child's code in one place. Either way, the parent waits with waitpid for each PID it got, because a plain wait returns whichever child ends first (Fig. 3).

parent2 children runningchild 1ends after 5 schild 2ends after 1 swait(&status)returns after 1 s with child 2's PIDwhichever child ends firstwaitpid(child1_pid, &status, 0)returns after 5 s, for child 1child 2 stays a zombie until waited for
Fig. 3 · wait takes the first child that ends; waitpid takes the one you name.

exec: a new program in the same process

fork copies the running program. To run a different program, a process calls one of the exec functions. exec does not create a process: it loads the new program into the calling one, replacing its code, data, heap and stack. The PID stays, the parent stays, the open files stay (Fig. 4).

child, PID 4501 – running ./execcode of exec.cvariables, heap, stackstdio buffer (unflushed text)PID 4501 · PPID 4500file descriptors 0, 1, 2 …execvthe same process, PID 4501 – now ./writecode of write.cfresh variables, heap, stackempty stdio buffer – old text lostPID 4501 · PPID 4500 – keptfile descriptors 0, 1, 2 … – kept
Fig. 4 · Same PID before and after exec – but the program, its memory and its stdio buffer are new.

Because the old program is gone, a successful exec never returns. The line after it runs only if exec failed – the file does not exist, is not executable, or the path is wrong. That line must report the error and end the child; otherwise the child continues with the parent's code, as with a forgotten exit after fork.

exec.c – the child runs ./write

char *cmd = "./write";
char *arg[] = {"./write", "Operating Systems", "5", NULL};   // NULL ends the list
printf("./write program will be called by execv function\n");
fflush(stdout);              // the buffer would be thrown away with the old program
execv(cmd, arg);
perror("execv");             // only reached if execv failed
exit(1);

The arguments go into an array of strings that ends with NULL. By convention the first element is the program's name: it becomes argv[0] of the new program, and the real arguments follow. The started program is the small write.c, which prints its first argument as many times as the second says:

write.c – check argc, then print

if (argc < 3)
{
    fprintf(stderr, "Usage: %s text count\n", argv[0]);
    return 1;
}
for (i = 0; i < atoi(argv[2]); i++)
    printf("%s\n", argv[1]);

Both programs have to be compiled, and the binary must be called exactly what execv looks for:

gcc -Wall write.c -o write     # the program exec will start – name it exactly "write"
gcc -Wall exec.c -o exec
./exec
$ ./exec
./write program will be called by execv function
Operating Systems
Operating Systems
Operating Systems
Operating Systems
Operating Systems
The end of parent process

the same, without the write binary

$ ./exec            # write was not compiled
./write program will be called by execv function
execv: No such file or directory
The end of parent process

The exec family

There are six variants, and the letters after exec say how to call them (Fig. 5). They all end up in the same system call, execve.

execreplace the program+l or varguments: list or array+p (optional)search PATH for the name+e (optional)pass an environmentexecl execlp execleexecl("/bin/ls", "ls", "-l", NULL)execv execvp execveexecv("/bin/ls", args) – args = {"ls", "-l", NULL}Always: the first argument after the path is argv[0] (the program name), and the list ends with NULL.
Fig. 5 · Read the name: l or v for the argument format, p for PATH search, e for an explicit environment.
execl("/bin/ls", "ls", "-l", NULL);          // l: arguments listed, full path
execlp("ls", "ls", "-l", NULL);              // p: look the program up in PATH

char *args[] = {"ls", "-l", NULL};
execv("/bin/ls", args);                      // v: arguments in an array
execvp("ls", args);                          // v + p
execve("/bin/ls", args, envp);               // e: pass the environment explicitly
LetterMeaningUse it when
lArguments listed one by one, ending with NULL.The number of arguments is fixed in the code.
vArguments in a NULL-terminated array (vector).The arguments are built at run time, e.g. from user input.
pSearch the directories of PATH for the program name.Starting a command like ls or grep without its full path.
ePass the environment variables explicitly.The new program needs a different environment; without e it inherits the current one.
exec
A family of calls that replace the program of the calling process. Same PID, new code and memory.
argv[0]
The first argument passed to exec: by convention the program name, not a real argument.
PATH
The list of directories the shell – and the p variants of exec – search for a command.
exit code
The number a process ends with (0–255); 0 means success. The parent reads it with WEXITSTATUS.

fork + exec + wait

Put together, the three calls give the pattern every shell uses to run a command: fork a child, exec the program in the child, wait in the parent (Fig. 6). The parent survives – it was never replaced – and learns how the command went from the exit status.

parentfork()waitpid(child, &status, 0) – blockedgoes onchild, fork() = 0childexecv()./write runs: prints its 5 linesexit(0)statusbash runs every command this way: fork, exec in the child, wait in the parent.With a trailing & the shell skips the wait – the job runs in the background.
Fig. 6 · The parent waits; the child becomes the other program; its exit code comes back through waitpid.
pid_t pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0)
{
    execlp("ls", "ls", "-l", NULL);   // the child becomes ls
    perror("execlp");                 // exec returned: it failed
    exit(127);
}
int status;
waitpid(pid, &status, 0);            // the parent waits, like the shell does
if (WIFEXITED(status))
    printf("ls ended with %d\n", WEXITSTATUS(status));

This is why echo $? in the shell shows the exit code of the last command: bash got it from waitpid through WEXITSTATUS. The 127 after a failed exec follows the shells' convention for "command not found".

What survives exec

exec replaces the program, not the process. Everything the kernel keeps about the process stays; everything that lives in the old program's memory goes:

Kept across execReplaced or reset
PID and parent PIDCode, global variables, heap, stack
Open file descriptors (unless opened with O_CLOEXEC)stdio buffers – unflushed output is lost
Current directory, user, umaskSignal handlers – reset to the default (ignored signals stay ignored)
Environment variables (unless an e variant passes new ones)Memory from malloc, without free – it simply disappears

The kept file descriptors are the key to redirection. A shell running ls > out.txt forks, opens out.txt in the child, puts it on descriptor 1, and then execs ls. ls writes to descriptor 1 as always and never learns that it is a file. The call that moves a descriptor is dup2:

in the child – redirect, then exec

int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1);                     // descriptor 1 now refers to out.txt
close(fd);
execlp("ls", "ls", "-l", NULL);  // ls writes to descriptor 1 – into the file

system(): the shortcut through the shell

system("command") from stdlib.h does the whole fork + exec + wait in one call. It forks, execs the shell /bin/sh -c "command" in the child, waits for it, and returns – so, unlike exec, your program continues after it (Fig. 7).

system.c – run a command line, then continue

printf("Call ./write program by calling system function\n");
fflush(stdout);                                   // keep the order of the output
int rc = system("./write 'Operating Systems' 5");  // returns when the command has ended
if (rc == -1)
    perror("system");                             // the child could not be created
else if (WIFEXITED(rc))
    printf("exit code: %d\n", WEXITSTATUS(rc));
exec.c./execparent – waitpidchild → becomes ./writeexecv: the child's code is gone2 processes; after exec the childnever runs exec.c code againsystem.c./systemparent – waitpidchild – calls system()blocked until the command ends/bin/sh -c "./write …"a shell parses the line./writeprints, exits
Fig. 7 · exec replaces the child; system adds a shell and waits for it, then the caller continues.

Because a shell reads the command, you can use everything the shell offers: quotes ('Operating Systems' stays one argument), pipes, redirection, wildcards. The return value is a status like the one waitpid fills – decode it with the same macros – or -1 if no child could be created. If the shell cannot find the command, the exit code is 127.

fork + exec + waitsystem()
Lines of codeabout tenone
Processesone childa child running a shell, which starts the command
Argumentsan exact array – no parsing, no surprisesone string, split and interpreted by the shell
Shell features (|, >, *)no – do it yourself (dup2, pipe)yes
ControlPID, parallel children, WNOHANG, signalsblocks until the command ends

Practice

Compile with -Wall -Wextra. For the exec tasks, run which ls first to see where the program lives – you need the full path for the variants without p.

Task 1

Exit codes travel

The child exits with the number given as the program's argument; the parent waits and prints the code it received. Try 0, 3, 255, 256 and -1. Explain the last two results. Then print the raw status as well and compare.

Approach

exit in the child, waitpid + the macros in the parent. Only the lowest 8 bits arrive, so 256 becomes 0 and -1 becomes 255. The raw status is the code multiplied by 256.

pid_t pid = fork();
if (pid == 0)
    exit(atoi(argv[1]));             // the child's whole job
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status))
    printf("%d\n", WEXITSTATUS(status));

Task 2

Killed or exited?

Let the child sleep for a minute. While it sleeps, end it from another terminal with kill -9 <PID>. The parent should print either "exited with N" or "killed by signal N". Repeat with a plain kill: which signal number appears now?

Approach

Print the child's PID from the parent so you know what to kill. WIFSIGNALED + WTERMSIG gives 9 for kill -9 and 15 (SIGTERM) for a plain kill.

if (pid == 0)
{
    sleep(60);                       // time to run kill -9 <pid> from another terminal
    exit(0);
}

Task 3

ls in a child

Run ls -l in a child process; the parent waits and then prints done, exit code N. Write it once with execl and once with execvp. Then misspell the program name and check that your error message appears and that the parent still prints its line.

Approach

The fork + exec + wait fragment on this page. With execl the path must be complete (/bin/ls or /usr/bin/ls); with execvp the name is enough. Both need "ls" as the first argument and NULL at the end.

Task 4

A minimal shell

Read a line with fgets, run it as a program without arguments in a child, wait, and read the next line – until the user types exit or presses Ctrl+D. Test with ls, date, pwd and a name that does not exist. Extension: split the line at spaces into an array and use execvp, so that ls -l works.

Approach

Cut the '\n' with strcspn, compare with "exit", then fork. The child part is three lines; the parent part is a waitpid. For the extension, strtok(line, " ") in a loop fills the array – end it with NULL.

if (pid == 0)
{
    execlp(line, line, NULL);        // one word: program name = argv[0]
    perror(line);                    // e.g. "foo: No such file or directory"
    exit(127);                       // what shells use for "command not found"
}

Task 5

system versus exec

Run ls -l | wc -l with system and print the exit code. Then try to do the same with execlp("ls", "ls", "-l", "|", "wc", "-l", NULL). Why does the second one fail, and what does ls complain about?

Explanation

The pipe is a shell feature. system hands the line to /bin/sh, which builds the pipeline. exec starts ls directly, and ls receives |, wc and -l as file names it cannot find. Building a pipeline yourself takes pipe and dup2.

Task 6

Poll instead of waiting

Start a child that sleeps 5 seconds. The parent prints a dot every half second until the child has ended, then prints its exit code. Use waitpid with WNOHANG and usleep.

Approach

Loop while waitpid(pid, &status, WNOHANG) returns 0; after the loop the status is filled. Print each dot with fflush(stdout), since it has no '\n'.

Summary

  • wait takes any child, waitpid(pid, …) a specific one; both remove the zombie. WNOHANG only checks.
  • Decode the status with WIFEXITED/WEXITSTATUS and WIFSIGNALED/WTERMSIG – never print it raw. Exit codes are 0–255.
  • Parent and child run at the same time; a child that ended before the parent's wait is a zombie until then.
  • exec replaces the program, keeps the process: same PID, same open file descriptors, new memory. It returns only on failure – handle that.
  • The exec name says the calling convention: l/v arguments, p PATH search, e environment. Argument lists start with the program name and end with NULL.
  • fork + exec + wait is how a shell runs a command; system does the same through /bin/sh -c – convenient, but not for user input.
  • Flush stdout before fork, exec and system.