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.
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:forkmakes a new process,execputs a new program into it,exitends it with a code, andwaitcollects that code in the parent. The PID and the open files surviveexec; 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.
| Call | Waits for | Notes |
|---|---|---|
wait(NULL) | any one child | Throws the status away – fine when you only need the child to be finished. |
wait(&status) | any one child | Stores how it ended in status. |
waitpid(pid, &status, 0) | the child with that PID | Use the PID that fork returned. pid = -1 means any child, like wait. |
waitpid(pid, &status, WNOHANG) | nobody – it only checks | Returns 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).
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
}| Macro | Meaning |
|---|---|
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 goneParent 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 10Parent 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).
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).
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).
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 processthe 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 processThe 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.
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| Letter | Meaning | Use it when |
|---|---|---|
l | Arguments listed one by one, ending with NULL. | The number of arguments is fixed in the code. |
v | Arguments in a NULL-terminated array (vector). | The arguments are built at run time, e.g. from user input. |
p | Search the directories of PATH for the program name. | Starting a command like ls or grep without its full path. |
e | Pass 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.
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 exec | Replaced or reset |
|---|---|
| PID and parent PID | Code, global variables, heap, stack |
Open file descriptors (unless opened with O_CLOEXEC) | stdio buffers – unflushed output is lost |
| Current directory, user, umask | Signal 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 filesystem(): 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));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 + wait | system() | |
|---|---|---|
| Lines of code | about ten | one |
| Processes | one child | a child running a shell, which starts the command |
| Arguments | an exact array – no parsing, no surprises | one string, split and interpreted by the shell |
| Shell features (|, >, *) | no – do it yourself (dup2, pipe) | yes |
| Control | PID, parallel children, WNOHANG, signals | blocks 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
waittakes any child,waitpid(pid, …)a specific one; both remove the zombie.WNOHANGonly checks.- Decode the status with
WIFEXITED/WEXITSTATUSandWIFSIGNALED/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
waitis a zombie until then. execreplaces 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/varguments,pPATH search,eenvironment. Argument lists start with the program name and end withNULL. - fork + exec + wait is how a shell runs a command;
systemdoes the same through/bin/sh -c– convenient, but not for user input. - Flush stdout before
fork,execandsystem.