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:
fork creates a child process.exec to replace its current program with grep.wait to collect the child's result.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.
| Operation | Purpose | Key result |
|---|---|---|
fork() | Create a child process based on the caller | Parent and child both continue after the call |
exec...() | Replace the calling process's program | Same process identity, new program |
wait() or waitpid() | Observe a child state change and collect its result | Parent receives encoded termination information |
exit() or _exit() | End execution | Kernel 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 CreationThe 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.
At the moment of fork, the child begins with a runtime environment derived from the parent.
The child receives:
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.
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:
| Descriptor | Points to |
|---|---|
| 0 | Command input |
| 1 | Output file or pipe |
| 2 | Error 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.
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 ReplacementThe 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:
execreplaces the program running in the calling process. It does not create a new process.
Suppose a child has PID 7312:
| Property | Before exec | After successful exec |
|---|---|---|
| PID | 7312 | 7312 |
| Code running | Shell code | grep code |
| Address space | Shell address space | New grep address space |
| Parent relationship | Unchanged | Unchanged |
The PID stays the same because the process stays the same. Its program changes.
execvpShell-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.
exec and Non-ReturnThis 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:
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.
exec Replaces and What It PreservesA successful exec replaces the calling process's user-space program image:
atexit are discarded.Several pieces of process identity and context normally remain:
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 CollectionA 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.
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.
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.
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 TerminationThe 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():
atexittmpfileThe 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.
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:
fork() with fflush(NULL)._exit() in the child if exec fails.Correct programs often use both.
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.
The complete runner can be understood as two paths after fork.
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.
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...
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.
fork() ConsiderationsIn 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:
exec immediately.pthread_atfork handlers where necessary.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.
Each operation fails at a different point in the lifecycle.
fork failureNo child was created. The parent receives -1 and should handle the resource or system-limit error without entering child logic.
exec failureThe child exists, but its old program is still running because replacement failed. Report the error and terminate that child with _exit.
waitpid failureNo valid child result was collected. Retry only errors with defined retry behavior, such as EINTR; handle errors such as ECHILD separately.
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.
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.
5 quizzes