«  2.4. System Call Interface   ::   Contents   ::   2.6. The UNIX File Abstraction  »

2.5. Process Life Cycle

From a very high-level perspective, we can describe the life cycle of a process as a sequence of events. The first event is the creation of the process as a new virtual memory instance. The CPU will then begin executing the program code according to the von Neumann instruction cycle until it reaches the halt instruction indicating the program is complete. At that point, the process is destroyed and any resources (such as physical memory) associated with it are reclaimed by the kernel.

2.5.1. Process Creation

There are generally two system calls that are associated with creating a new process: fork() and exec(). When a program calls fork(), a system call is triggered that asks the kernel to create a new virtual memory instance. In the immediate moment when this occurs, the contents of the new virtual memory instance is an exact duplicate of the process that was running. We refer to the two processes as the parent and child. While handling the fork(), the kernel will allocate new internal data structures for the child, assign it a process identifier (PID), and assign a small set of initial resources to it. A PID is an integer value that acts like a name that can be used to uniquely refer to a process.

Decorative C library image

C library functions – <unistd.h>


pid_t fork(void);
Creates a new process. Returns the child’s PID if successful.
pid_t getpid(void);
Gets the current process’s PID.
pid_t getppid(void);
Gets the PID of the current process’s parent.

Code Listing 2.6 demonstrates how to use fork(). Once both processes are running, the code can distinguish between the parent and child by looking at the value returned from fork().The kernel returns 0 to the child, and it returns the child’s PID to the parent. If the kernel failed or refused to create a new process for some reason, it would return a negative value to the parent; the global variable errno (declared in the standard library file errno.h) would be set to an integer value indicating the error that occurred.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* Code Listing 2.6:
   Using fork() to create a child process and access its PID
 */

pid_t child_pid = fork ();

if (child_pid < 0)
  printf ("ERROR: No child process created\n");
else if (child_pid == 0)
  printf ("Hi, I am the child!\n");
else
  printf ("Parent just learned about child %d\n", child_pid);

The return values from fork() make sense when you consider that the C library defines two functions that allow the process to determine its own PID (getpid()) and to find out its parent’s PID (getppid()). The child can use these two functions to identify itself and its parent. However, the parent process may have already created dozens of child processes. The return value from fork() informs the parent of the PID for this new child process that was just created.

Code Listing 2.7 shows an example of what happens when the return code from fork() is not checked. On line 6, the original process creates a child. For clarity, let the original process of PID 1000 and this child has PID 1001. Since both processes continue to execute with the same code, both reach line 9 and create two additional processes (PIDs 1002 and 1003). As a result, there would be four processes that execute line 12, printing the message four times (with different PIDs each time).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/* Code Listing 2.7:
   Running the same code in multiple processes
 */

/* Start by creating a second process */
pid_t first_fork = fork ();

/* BOTH the original parent and child create new processes */
pid_t second_fork = fork ();

/* This line will print four times */
printf ("Hello world from %d!\n", getpid ());

It may seem odd that fork() would create a new process running the exact same code as the parent. However, there are several times where this makes sense. Consider a web browser, such as Google Chrome. Whenever you open a new tab, Chrome will make a call to fork() to create a new process for that tab. Intuitively, all tabs in a web browser work the same way: they get a URL request from the user and retrieve the corresponding web page or file from a server somewhere. Consequently, it makes sense that all of the processes for Chrome would start with the same code segment. Making the tabs run in separate processes provides some security and reliability: If a plug-in crashes in one tab, only that tab is affected; the other tabs exist in other processes, so they are isolated from the crash.

Decorative bug warning

Bug Warning


Once a process uses fork() to create a child process, the timing of the execution becomes nondeterministic from the programmer’s point of view. The programmer cannot predict or control when the child begins executing relative to the parent. In some systems, the child process may begin executing immediately before the parent process returns from the call to fork(). In others, the child may simply be created and start running at some later time. Consequently, if the order of the execution matters, the programmer must take extra steps to ensure correct behavior; one example of this is to use wait() as described below. We will examine more advanced techniques in later chapters.

2.5.2. Switching Program Code

In many cases, after making a call to fork(), you want to switch to a different program. For instance, consider the bash terminal program. Within bash, some commands are considered built-in and are executed by bash itself. One example of a built-in is the export command, which can be used to define or change an environment variable, such as PATH or CLASSPATH. Other commands are used to call a separate compiled program, such as ls, gcc, or vim. The code to execute these commands does not exist as a part of bash. Rather, these are distinct programs and the code needs to be loaded into the new process.

Decorative C library image

C library functions – <unistd.h>


int execl(const char *path, const char *arg0, ...  /*, (char *)0 */);
Executes the program identified by the exact path.
int execle(const char *path, const char *arg0, ...  /*, (char *)0, char* const envp[]*/);
Executes the program at path and set environment variables.
int execlp(const char *file, const char *arg0, ...  /*, (char *)0 */);
Looks up the program file in the current PATH.
int execv(const char *path, char *const argv[]);
Like execl, but command-line arguments are in an array.
int execve(const char *path, char *const argv[], char *const envp[]);
Like execle, but command-line arguments are in an array.
int execvp(const char *file, char *const argv[]);
Like execlp, but command-line arguments are in an array.
int fexecve(int fd, char *const argv[], char *const envp[]);
Executes the program stored in an open file handle fd.

Loading a new program is handled by the exec() system call, which can be invoked by a family of C functions. The functions differ by how you pass the parameters. The execl(), execle(), and execlp() functions take the command line arguments as additional function parameters, whereas execv(), execve(), and execvp() require you to create a single array that contains all of the arguments. The execl() and execv() functions expect that you provide the exact path to the executable file, while execlp() and execvp() will look up the path for you. The execle() and execve() functions allow you to specify a custom set of environment variables for the new program. Finally, fexecve() allows you to pass an open file descriptor for the program instead of the program’s name or path. Code Listings 2.8 and 2.9 show two different ways to call the ls program to list files, one using execl() and the other using execvp().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* Code Listing 2.8:
   Using execl() lists all parameters in one list and requires an exact path
 */

/* Create the child and run 'ls -l' */
pid_t child_pid = fork ();
if (child_pid < 0)
  exit (1); /* exit if fork() failed */

if (child_pid == 0)
  {
    int rc = execl ("/bin/ls", "ls", "-l", NULL);
    exit (1); /* exit if exec fails */
  }	

/* Make the parent wait for the first child */
wait (NULL);

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* Code Listing 2.9:
   Using execvp() puts command-line arguments in a vector and does a path lookup
 */

/* Set up all parameters for 'ls -l' in a single vector */
char *const parameters[] = { "ls", "-l", NULL};
child_pid = fork ();
if (child_pid < 0)
  exit (1); /* exit if fork() failed */

if (child_pid == 0)
  {
    int rc = execvp ("ls", parameters);
    exit (1); /* exit if exec fails */
  }

/* Make the parent wait for the second child */
wait (NULL);

There are a couple of important points to note in this code. First, note that both calls to ls will have the same three arguments (argv[0] = "ls", argv[1] = "-l", and argv[2] = NULL). That is, the first parameter passed to execl() on line 12 of Code Listing 2.8 is not included in the argument list as the child process receives it. The convention that argv[0] is the name of the program arises from how programs like bash specify the remaining parameters to the exec() functions.

Second, observe that both versions use NULL to indicate the end of the arguments. In the case of execl(), function takes a variable-length parameter list, so it relies on NULL to know when it should stop looking for arguments. In the case of execvp(), the function expects exactly two arguments, with the second being an array of unknown length.

Third, note that the first parameter of execl() must be an exact path to the location of the executable file. If you call execl() with just "ls" instead of "/bin/ls", then it will look for an executable file in the current directory that is named ls. If the file does not exist, the call to execl() will return a negative value to indicate an error. For the call to execvp(), bash will look through the directories specified by the PATH environment variable to try to find the executable file for you. If line 12 of Code Listing 2.8 used execlp() instead of execl(), bash would perform a PATH lookup there, as well.

Finally, note that no code after a successful call to any of the exec() functions will execute in the current process. In this case, Code Listing 2.8 line 13 and Code Listing 2.9 line 14 should never be reached, so neither of the calls to exit() should happen. The reason is that exec() replaces the current process’s code, data, stack, and heap segments. Assuming the call to exec() is successful, the child process will start over with a clean virtual memory instance; none of the parent process’s information is retained within the child process.

Decorative bug warning

Bug Warning


Always make sure to check the return values for both fork() and exec(). Most OS enforce a limit on the number of processes that a single user can create; once you reach this limit, fork() will fail. Similarly, if you make a mistake in the name of the file that you want to run or you do not have permissions, exec() will fail and the child process will continue to execute the current program’s code. Failing to check these return values can lead to unexpected behavior.

2.5.3. POSIX Spawn Interface

In recent years, the use of fork() has been criticized for a variety of reasons. For instance, the implementation requirements of it are considered too slow and require too much power for very small embedded computing systems (e.g., think of the “computers” in a car that operate the cruise control or anti-lock breaking). Another problem is that calling fork() on a process with multiple threads (see Chapter 7) can lead to inconsistent copies of the parent process; that is, the thread that calls fork() might not have a completely accurate memory image, particularly in multicore architectures. Lastly, the semantics of fork() have become very complicated, as there are many special cases that must be handled, such as how to deal with open files, timers, asynchronous I/O buffers, etc. [1]

To address some of these criticisms, POSIX includes a new function interface, posix_spawn(). This function takes multiple parameters, including the path to the executable and the argv and envp arrays that would be passed into execv() or execve() functions. In addition, posix_spawn() takes other parameters that can specify additional operations. These operations give the programmer explicit control over the special cases mentioned above.

Decorative C library image

C library functions – <spawn.h>


int posix_spawn(pid_t *pid, const char *path, const posix_spawn_file_actions_t *file_actions, const posix_spawnattr_t *attrp, char *const argv[], char *const envp[]);
Create a new process and load the code identified by the path in a single call.

Code Listing 2.10 shows how to use posix_spawn() to run an external program. In this case, the cksum program is run on a CSV file, as specified in the path and argv arguments. The child’s process ID is returned through the call-by-reference parameter &child. The assert() is used here to confirm that the process was successfully spawned; posix_spawn() returns a non-zero value if an error occurs at any point and the process was not successfully spawned.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/* Code Listing 2.10:
   Using posix_spawn() to run a helper program instead of fork() and execv()
 */

pid_t child = -1;
char *path = "/usr/bin/cksum";
char *argv[] = { "cksum", "movies.csv", NULL };

/* Spawn and run an external program in one step */
assert (posix_spawn (&child, path, NULL, NULL, argv, NULL) == 0);

/* Make the parent wait for the child */
wait (NULL);

2.5.4. Process Destruction

A process can be terminated in a number of ways. Some methods of process termination are voluntary in nature, such as when the code makes a call to exit(), abort(), or returns from main(). Other methods are involuntary, such as when a process is killed after a segmentation fault or similar exception. Regardless of what causes the process termination, the same set of procedures are typically performed: open file handles are closed, the virtual memory space is destroyed, the associated physical memory is reclaimed by the system, and any relevant data structures in the kernel are cleaned up.

The preceding example also illustrates a key element about the timing of the destruction of processes. Once a process calls fork() and creates a child process, the relative scheduling between the parent and child is nondeterministic from the programmer’s perspective. The two processes are scheduled independently by the OS running on that particular machine, and the scheduling will be influenced by other processes running, as well. However, the parent can temporarily pause its own execution by making a call to wait() or waitpid().

Decorative C library image

C library functions – <sys/wait.h>


pid_t wait(int *stat_loc);
Waits on all children, gets status information in stat_loc.
pid_t waitpid(pid_t pid, int *stat_loc, int options);
Waits on a particular child process identified by pid.

The wait() system call will block the current process until all of its children have been terminated. A call to waitpid() is similar, but it only waits for a single, identified child to terminate. In the preceding example, the first child makes a call to execl() to run the ls program. At approximately the same time, the parent process calls wait(NULL). The nondeterministic nature of scheduling means it is impossible to determine which happens first, but the result is the same: the parent process will not proceed to the second call to fork() until the first child process runs the ls program and terminates.

Note that there is no problem even if the child process terminates before the parent gets scheduled to run. If all children have been terminated by the time a process calls wait(), then the parent process will simply continue on without pausing. The only requirement for wait() is that the parent cannot continue if there is at least one child process still trying to run.

The convention with C programs is to return an integer value from main() to indicate whether the program completed successfully. The standard practice is to return 0 to indicate that no error occurred, whereas a non-zero value typically indicates an error occurred in some way. When working on the command-line, this value can be retrieved by printing the bash variable $?. In C programs, this value can be retrieved through the int * parameter passed to wait() as shown in Code Listing 2.11.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/* Code Listing 2.11:
   Using wait() and WEXITSTATUS() to retrieve a process's return code
 */

/* Create the first child and run 'ls -l' */
pid_t child_pid = fork ();
if (child_pid < 0)
  exit (1); /* exit if fork() failed */
if (child_pid == 0)
  exit (23); /* child process exits */

/* Make the parent wait to retrieve the return code*/
int status = 0;
pid_t exited = wait (&status);

/* Make the parent print the child's exit code of 23 */
printf ("PID %d exited with code %d\n", exited, WEXITSTATUS(status));
Decorative bug warning

Bug Warning


The return value of the child process is shifted to the left when it is retrieved using wait(), as the lower eight bits are used for other purposes. The WEXITSTATUS() macro corrects this bit shifting to receive the actual return code from the child process.

[1]For a full description of the problems with fork(), see the paper: A. Baumann, J. Appavoo, O. Krieger, and T. Roscoe, “A fork() in the road.” In Workshop on Hot Topics in Operating Systems (HotOS ‘19). New York, NY: ACM, 2019.
«  2.4. System Call Interface   ::   Contents   ::   2.6. The UNIX File Abstraction  »

Contact Us License