AlgoMaster Logo

fork, exec, wait, and exit

28 min readUpdated August 7, 2026
Listen to this chapter
Unlock Audio

When you enter this command:

the shell does not turn itself into grep. It must remain alive so it can display the next prompt.

Instead, a Unix-like shell follows a four-part process:

  1. fork creates a child process.
  2. The child uses exec to replace its current program with grep.
  3. The original shell uses wait to collect the child's result.
  4. The child eventually exits, allowing the shell to continue.

Four separate calls appear here, and each does one job: fork creates, exec replaces, exit reports, and wait collects.

These four operations separate process creation, program loading, synchronization, and termination. Together, they form the core of Unix process management.

The Four Operations at a Glance

OperationPurposeKey result
fork()Create a child process based on the callerParent and child both continue after the call
exec...()Replace the calling process's programSame process identity, new program
wait() or waitpid()Observe a child state change and collect its resultParent receives encoded termination information
exit() or _exit()End executionKernel releases resources and records a termination status

No single operation means “start another program and wait for it.” Unix composes that behavior from smaller operations.

This separation is powerful. After fork but before exec, the child can redirect input and output, close unwanted file descriptors, change its working directory, or adjust other execution settings. The shell then loads the requested program into that prepared environment.

fork(): Child Process Creation

The interface is small:

fork() creates a new process called the child. The calling process is the parent.

The unusual part is that both processes continue from the instruction after fork():

The two processes need a way to discover which branch they are in. fork() provides that through three possible return values:

On success, the parent receives the child's PID while the child receives 0. The child can discover its actual PID with getpid().

On failure, the caller receives -1, errno describes the error, and no child exists. A process must check this case before assuming that either normal branch is available.

What the Child Inherits

At the moment of fork, the child begins with a runtime environment derived from the parent.

The child receives:

  • A virtual address space with the same initial contents
  • Copies of the parent's file-descriptor entries
  • The same current working directory
  • A copy of the environment
  • Inherited credentials and resource limits
  • Inherited signal settings, with some defined exceptions

The child is still a separate process. It has its own PID and parent relationship, its own scheduling and accounting state, and a logically separate address space.

If both processes begin with:

and the child later assigns requests = 20, the parent's variable remains 10.

Linux normally implements this separation with copy-on-write. Parent and child can initially refer to the same physical memory where it is safe to do so; when either modifies a private page, the kernel gives that process a separate copy. This is an implementation optimization—the programming model remains two separate address spaces.

File-Descriptor Handling Considerations

The child receives a copy of the parent's file-descriptor table, but corresponding descriptors refer to the same underlying open file description.

That means parent and child may share properties such as a current file offset. If both write through inherited descriptors without coordination, their operations can affect the same file or socket.

This inheritance is exactly what a shell needs for redirection and pipelines. Before exec, the child can arrange that:

DescriptorPoints to
0Command input
1Output file or pipe
2Error destination

It is also a source of resource leaks. A server may accidentally pass a listening socket, log file, or secret-bearing descriptor into an unrelated child program.

Descriptors marked close-on-exec are closed automatically when exec succeeds. Programs can set the FD_CLOEXEC flag, and many descriptor-creating interfaces provide an atomic O_CLOEXEC-style option.

Using close-on-exec by default is a strong defensive practice in long-running backend processes.

Independent Parent and Child Execution

After a successful fork, both processes are runnable. The scheduler decides which one executes first.

This code has no guaranteed output order:

Either line may appear first. On a multicore system, the two branches may even execute at nearly the same time.

Adding a short sleep to one branch does not create a reliable ordering guarantee. Correct process coordination uses an operation such as wait, a pipe, or another explicit synchronization mechanism.

The parent's local variable named pid and the child's local variable named pid are separate values in separate address spaces. The different fork() return values allow the same source code to choose different work in each process.

exec: Current Program Replacement

The name exec refers to a family of functions, including execl, execv, execve, and execvp.

They differ in how the executable is located and how arguments and environment variables are supplied. Their central behavior is the same:

exec replaces the program running in the calling process. It does not create a new process.

Suppose a child has PID 7312:

PropertyBefore execAfter successful exec
PID73127312
Code runningShell codegrep code
Address spaceShell address spaceNew grep address space
Parent relationshipUnchangedUnchanged

The PID stays the same because the process stays the same. Its program changes.

Calling execvp

Shell-like programs often use execvp:

The v indicates that arguments are passed as a vector, meaning a null-terminated array of pointers.

The p tells the library to search the directories in PATH when the file name does not contain a slash. Without p, the caller normally supplies an explicit path.

By convention, arguments[0] contains the program name visible to the new program. The final pointer in the array must be NULL.

At the system-call level, execve receives an executable path, an argument vector, and an environment vector. The other exec-family functions are convenient library interfaces built around that model.

Successful exec and Non-Return

This is one of the most important rules in process programming:

On success, the old program is gone. There is no old stack frame to return to, so code after execvp does not execute.

On failure, execvp returns -1 and sets errno. Common reasons include:

  • The executable was not found
  • Execute permission was denied
  • The file was not a recognized executable format
  • A required interpreter or loader was missing
  • The argument or environment data was too large

An exec failure occurs inside the child process. If the child simply continues after the failed call, it may accidentally execute code intended only for the parent. The safe pattern is to report the error and terminate the child with _exit.

The status 127 is a shell convention commonly used when a command cannot be found. The kernel does not assign that value automatically.

What exec Replaces and What It Preserves

A successful exec replaces the calling process's user-space program image:

  • Program code and global data are replaced.
  • The old heap and user stacks disappear.
  • New arguments and an environment are installed.
  • Runtime startup begins at the new executable's entry point.
  • C functions registered with atexit are discarded.
  • Caught signal handlers are reset to their default dispositions.

Several pieces of process identity and context normally remain:

  • The PID and parent relationship
  • The current working directory
  • Resource limits
  • Many credentials, subject to executable security rules
  • Open file descriptors not marked close-on-exec

This selective preservation makes shell features possible. A child can redirect standard output to a file and then call exec; the new program inherits descriptor 1 already connected to that file.

It also explains a common production bug. If a newly executed helper unexpectedly keeps a server socket open, the socket may remain alive even after the original server closes its own descriptor. Close-on-exec prevents that hidden reference from crossing the program boundary.

wait() and waitpid(): Child Result Collection

A parent and child execute independently after fork. The child does not need the parent to call wait before it can run or terminate.

wait is for the parent:

With its normal behavior, wait() blocks until any child terminates. It is equivalent to:

waitpid provides more control:

A positive first argument selects one exact child PID. An options value of 0 waits for a terminating child. WNOHANG requests a nonblocking check: if the selected child exists but has no reportable state change, waitpid returns 0.

If a child has already terminated by the time the parent calls waitpid, the call can return immediately.

Waiting vs. Reaping

waitpid serves two closely related purposes.

First, it provides synchronization. Code after a successful blocking wait knows that the selected child has reached the requested state change.

Second, it collects the child's termination record. This operation is commonly called reaping.

When a child terminates, the kernel releases most of its resources but preserves a small record containing its PID, termination status, and accounting information. That record lets the parent retrieve the result even if the child exits before the parent waits.

Once the parent successfully collects the termination result, the kernel can release that remaining record.

This is why a parent should arrange to collect its children. Failure to do so can leave terminated children in the zombie state.

Encoded Wait Status

The integer written to status is not simply the child's exit code.

It encodes how the child changed state. Portable code uses macros from <sys/wait.h> to interpret it:

WEXITSTATUS is valid only when WIFEXITED is true. WTERMSIG is valid only when WIFSIGNALED is true.

Other macros describe stopped and continued children when the caller asks waitpid to report those state changes. For a basic command runner, normal exit and signal termination are the two essential outcomes.

Comparing the raw integer directly with an expected exit code is incorrect and can produce architecture-dependent bugs.

Handling an Interrupted Wait

A blocking waitpid can return -1 with errno == EINTR if the calling thread catches a signal while waiting.

The usual pattern retries:

Other errors are not blindly retried. For example, ECHILD means the selected process is not an unwaited-for child of the caller.

This loop is small, but omitting it can make a parent occasionally lose control flow under real signal activity.

exit(): Normal User-Space Termination

The C library function exit() performs normal process termination:

Returning from main is defined to perform normal termination as if exit had been called with the returned status:

Before asking the kernel to end the process, exit():

  • Runs functions registered with atexit
  • Flushes and closes C standard-I/O streams
  • Removes temporary files created by tmpfile

The kernel then closes the process's remaining file descriptors, releases its runtime resources, records termination information, and makes the result available to its parent.

By convention, status 0 means success and a nonzero status reports some form of failure. On POSIX systems, only the low eight bits supplied to normal exit or _exit are available through wait and waitpid, so portable command exit statuses stay in the range 0 through 255.

_exit(): Termination Without User-Space Cleanup

_exit() ends the process without running inherited user-space exit handlers or flushing C stdio buffers:

It still enters the operating system's termination path. Kernel-managed file descriptors are closed and process resources are released.

The distinction matters after fork. The child inherits a copy of the parent's user-space memory, including C library buffering state and registered exit handlers. If the child cannot exec, calling ordinary exit() may repeat cleanup that belongs to the parent.

For this reason, the common child path is:

The ISO C function _Exit() has the same broad purpose as _exit(). Unix process code most commonly uses the POSIX spelling _exit.

The Duplicated Output Trap

Consider this program:

If starting... is still in a user-space stdio buffer when fork() runs, that buffer is copied into the child along with the rest of the address space.

Both processes then call exit(), and both flush their copy:

The output was not printed twice before fork. One buffered copy became two, and each process flushed it once.

There are two common protections:

  1. Flush stdio before fork() with fflush(NULL).
  2. Use _exit() in the child if exec fails.

Correct programs often use both.

A Complete Miniature Command Runner

The following program combines the four operations into a small command runner:

Compile it:

Run a command:

Expected output:

Confirm that the runner propagates a normal exit status:

Expected output:

Try a missing command:

The child reports the exec error, and the runner returns status 127.

Following the Program's Control Flow

The complete runner can be understood as two paths after fork.

Child path

The child receives 0 from fork, calls execvp, and becomes the requested command.

If execvp succeeds, none of the old child code runs again. If it fails, the child preserves errno, prints an error, and calls _exit.

Parent path

The parent receives the child's PID and passes that exact PID to waitpid.

While the child runs, the parent sleeps inside waitpid rather than consuming CPU in a polling loop. When termination becomes reportable, the parent decodes the status and returns a corresponding result.

The parent cannot assume which branch executes first. The wait only guarantees that wait_for_child does not complete successfully until it has collected the child result.

Loading simulation...

Observing the Relationship on Linux

Run a child long enough to inspect:

Display the runner and its child:

A typical snapshot is:

The runner is waiting. Its child has the same PID it received at fork, but its program is now sleep because execvp replaced the child program.

Wait for the background runner so the shell collects it:

To observe the process-related system calls, use:

The -f option follows the child. Depending on the C library and architecture, the trace may show lower-level system-call names rather than exactly the same names as the C wrappers. The important events remain creation, program replacement, waiting, and termination.

Multithreaded fork() Considerations

In a multithreaded process, fork() creates a child containing only the thread that called fork.

The child's copied memory still contains mutexes and other synchronization objects from the parent. A copied mutex may appear locked by a thread that does not exist in the child.

For this reason, after fork() in a multithreaded program, the child is restricted to async-signal-safe operations until it calls exec. Calling general library code can deadlock on inherited internal locks.

The miniature command runner in this chapter is single-threaded. In a multithreaded launcher, even convenience functions such as perror are not guaranteed to be safe in the child. Such launchers commonly use a minimal safe operation such as write to report an exec error.

Production systems handle this in several ways:

  • Fork before creating worker threads.
  • Keep the child path extremely small and call exec immediately.
  • Use carefully designed pthread_atfork handlers where necessary.
  • Use posix_spawn when its combined create-and-execute interface fits the task.

This constraint is especially relevant to language runtimes, application servers, and libraries that create background threads internally.

Failure Handling by Operation

Each operation fails at a different point in the lifecycle.

fork failure

No child was created. The parent receives -1 and should handle the resource or system-limit error without entering child logic.

exec failure

The child exists, but its old program is still running because replacement failed. Report the error and terminate that child with _exit.

waitpid failure

No valid child result was collected. Retry only errors with defined retry behavior, such as EINTR; handle errors such as ECHILD separately.

Child program failure

exec may succeed even though the new program later returns a nonzero status. This is not an exec failure. The parent learns about it through the wait status.

Keeping these cases separate produces useful logs. “Could not create a child,” “could not load the program,” and “program ran and reported failure” describe different operational problems.

Summary

fork() creates a child that continues from the same point as its parent, with a distinct PID and logically separate memory. The return value identifies whether code is running in the parent, the child, or the original process after a failure.

An exec function replaces the calling process's program without changing its PID. waitpid() lets the parent block efficiently, collect the child's encoded termination result, and release its remaining process record. exit() performs normal C library cleanup, while _exit() avoids inherited user-space cleanup in a post-fork child.

The essential Unix process pattern is:

Fork to create an execution context, configure the child, exec to load the intended program, wait to collect its result, and exit with a meaningful status.

Quiz

fork, exec, wait, and exit Quiz

5 quizzes