C Processes with POSIX APIs: fork, exec, wait, and Process Management

A process is a running instance of a program. POSIX systems such as Linux and macOS provide APIs that allow C programs to create processes, execute programs, inspect process identifiers, wait for child processes, and control process termination.

This topic focuses on POSIX process APIs rather than strictly portable ISO C because functions such as fork(), exec(), wait(), and getpid() are not part of the ISO C standard.

What Is a Process?

A process contains a program's execution state and resources managed by the operating system. A process typically has its own virtual address space, process identifier, open file descriptors, environment, and execution context.

When a process creates another process, the original process is commonly called the parent and the newly created process is called the child.

Process IDs

POSIX systems identify processes using process IDs, commonly called PIDs.

C
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    printf("Process ID: %ld\n", (long)getpid());
    printf("Parent Process ID: %ld\n", (long)getppid());

    return 0;
}

Using fork()

The fork() system call creates a new process. After a successful fork(), both the parent and child continue execution from the instruction following the fork() call.

C
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    pid_t pid = fork();

    if (pid < 0)
    {
        perror("fork");
        return 1;
    }

    if (pid == 0)
    {
        printf("Child process: PID = %ld\n", (long)getpid());
    }
    else
    {
        printf("Parent process: child PID = %ld\n", (long)pid);
    }

    return 0;
}

Understanding fork() Return Values

Return ValueMeaning
-1Process creation failed
0Code is executing in the child process
Positive PIDCode is executing in the parent; value is the child's PID

The Parent and Child Have Separate Memory

After fork(), the child receives a logical copy of the parent's address space. Changes made to ordinary variables in one process do not directly change the corresponding variables in the other process.

C
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    int value = 10;

    pid_t pid = fork();

    if (pid < 0)
    {
        perror("fork");
        return 1;
    }

    if (pid == 0)
    {
        value = 20;
        printf("Child value: %d\n", value);
    }
    else
    {
        value = 30;
        printf("Parent value: %d\n", value);
    }

    return 0;
}

Operating systems commonly implement fork() efficiently using copy-on-write memory, but the processes still have independent address spaces from the program's perspective.

Forking Multiple Processes

C
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    fork();
    fork();

    printf("Hello from PID %ld\n", (long)getpid());

    return 0;
}

The number of processes can grow rapidly when multiple processes execute additional fork() calls. Process creation should therefore be designed carefully.

The exec Family

The exec functions replace the current process image with another program. Unlike fork(), a successful exec call does not create an additional process.

C
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    execlp("echo", "echo", "Hello from exec", (char *)NULL);

    perror("execlp");
    return 1;
}

If exec succeeds, the original program's instructions are replaced and execution does not return to the statement following exec. If exec fails, it returns -1 and sets errno.

fork() + exec(): A Common Pattern

A very common POSIX pattern is to create a child with fork() and then have the child call an exec function. This allows the parent to continue running while the child executes another program.

C
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();

    if (pid < 0)
    {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (pid == 0)
    {
        execlp("ls", "ls", "-l", (char *)NULL);

        perror("execlp");
        _exit(127);
    }

    if (waitpid(pid, NULL, 0) == -1)
    {
        perror("waitpid");
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

Why Use _exit() After exec Failure?

After fork(), the child initially inherits the parent's standard I/O streams and other process state. If exec fails, calling _exit() in the child is often preferable to calling exit(), because exit() performs stdio flushing and other process-exit handling inherited from the parent.

The exact cleanup strategy depends on the program, but a child that fails before exec commonly reports the error and terminates with _exit().

The exec Variants

FunctionDescription
execlArguments supplied as a list
execlpList arguments and search PATH
execleList arguments and provide a custom environment
execvArguments supplied as an array
execvpArray arguments and search PATH
execvpeArray arguments, PATH search, and custom environment on systems providing it

The exec* family differs in how arguments, environment variables, and executable lookup are specified. execvp() is particularly convenient when launching a program using a command name searched through PATH.

Using execvp()

C
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    char *args[] = {
        "echo",
        "Hello",
        "POSIX",
        NULL
    };

    execvp(args[0], args);

    perror("execvp");
    return 1;
}

Waiting for a Child with wait()

A parent can use wait() to wait for one of its child processes to terminate.

C
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    pid_t pid = fork();

    if (pid < 0)
    {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (pid == 0)
    {
        printf("Child running.\n");
        _exit(42);
    }

    int status;

    if (wait(&status) == -1)
    {
        perror("wait");
        return EXIT_FAILURE;
    }

    if (WIFEXITED(status))
    {
        printf("Child exit status: %d\n", WEXITSTATUS(status));
    }

    return EXIT_SUCCESS;
}

Understanding wait Status

The integer returned through wait() or waitpid() should be inspected using the POSIX status macros rather than interpreting it as a simple integer.

MacroMeaning
WIFEXITED(status)Child terminated normally
WEXITSTATUS(status)Exit status when the child terminated normally
WIFSIGNALED(status)Child terminated because of a signal
WTERMSIG(status)Signal that caused termination
WIFSTOPPED(status)Child is currently stopped
WSTOPSIG(status)Signal that caused the child to stop

Using waitpid()

waitpid() provides more control than wait(). It can wait for a particular child or use options that change how the wait behaves.

C
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    pid_t child = fork();

    if (child < 0)
    {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (child == 0)
    {
        printf("Child process running.\n");
        _exit(0);
    }

    int status;

    if (waitpid(child, &status, 0) == -1)
    {
        perror("waitpid");
        return EXIT_FAILURE;
    }

    if (WIFEXITED(status))
    {
        printf("Child exited with %d.\n", WEXITSTATUS(status));
    }

    return EXIT_SUCCESS;
}

Avoiding Zombie Processes

When a child terminates, the operating system retains a small amount of termination information until its parent collects that information with wait(), waitpid(), or an appropriate related mechanism. A terminated child that has not yet been collected is commonly called a zombie.

A parent that creates children should have a strategy for collecting them, especially if children are created repeatedly.

Parent and Child Execution Order

After fork(), the parent and child execute independently. The program must not assume which one will run first unless explicit synchronization is used.

C
pid_t pid = fork();

if (pid == 0)
{
    printf("Child\n");
}
else if (pid > 0)
{
    printf("Parent\n");
}

The output order is not guaranteed simply because the parent called fork() first.

Process Exit Status

A process can terminate with an integer exit status. The parent can retrieve the child's normal termination status through wait() or waitpid().

C
#include <stdlib.h>

int main(void)
{
    return EXIT_SUCCESS;
}

For programs launched from a shell, only a limited range of exit-status values is conventionally useful on many POSIX systems. Keep exit statuses simple and use other mechanisms for detailed error information.

exit() vs _exit()

FunctionBehavior
exit()Performs normal C library termination processing, including stdio cleanup
_exit()Terminates the process without normal stdio cleanup

The distinction becomes particularly important after fork() when the child has not successfully executed a new program.

Using getpid() and getppid()

C
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    printf("PID: %ld\n", (long)getpid());
    printf("Parent PID: %ld\n", (long)getppid());

    return 0;
}

getpid() returns the calling process's PID, while getppid() returns its parent process ID.

Creating a Worker Process

C
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

static void worker(void)
{
    printf("Worker PID: %ld\n", (long)getpid());
}

int main(void)
{
    pid_t pid = fork();

    if (pid < 0)
    {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (pid == 0)
    {
        worker();
        _exit(EXIT_SUCCESS);
    }

    if (waitpid(pid, NULL, 0) == -1)
    {
        perror("waitpid");
        return EXIT_FAILURE;
    }

    printf("Worker finished.\n");
    return EXIT_SUCCESS;
}

fork() and File Descriptors

File descriptors that are open when fork() occurs are inherited by the child. The parent and child then have separate descriptor tables referring to the underlying open file descriptions.

This behavior is fundamental to POSIX process composition and is one reason fork() is often combined with pipe(), dup2(), and exec() to construct pipelines and redirect standard input or output.

Redirecting Standard Output Before exec()

C
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    pid_t pid = fork();

    if (pid < 0)
    {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (pid == 0)
    {
        int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);

        if (fd == -1)
        {
            perror("open");
            _exit(1);
        }

        if (dup2(fd, STDOUT_FILENO) == -1)
        {
            perror("dup2");
            close(fd);
            _exit(1);
        }

        close(fd);

        execlp("echo", "echo", "Hello from child", (char *)NULL);

        perror("execlp");
        _exit(127);
    }

    if (waitpid(pid, NULL, 0) == -1)
    {
        perror("waitpid");
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

This example uses POSIX file-descriptor APIs. The child opens a file, redirects standard output to it with dup2(), and then executes another program.

fork() and stdio Buffers

fork() duplicates the process memory containing C stdio buffers. If output has already been buffered before fork(), both processes may later flush copies of that buffered data.

C
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    printf("Message before fork\n");

    pid_t pid = fork();

    if (pid < 0)
    {
        perror("fork");
        return 1;
    }

    printf("PID: %ld\n", (long)getpid());
    return 0;
}

Whether the first line is duplicated depends on buffering and the execution environment. Programs that mix fork() with stdio should understand buffering behavior and avoid unintentionally duplicating buffered output.

Handling fork() Failure

fork() can fail because of operating-system resource limits or other system conditions. Always check its return value before assuming a child was created.

C
pid_t pid = fork();

if (pid == -1)
{
    perror("fork");
    return EXIT_FAILURE;
}

Handling exec() Failure

A successful exec function does not return. Therefore, any code immediately after exec() is normally an error path.

C
execlp("program", "program", (char *)NULL);

perror("execlp");
_exit(127);

A Complete fork-exec-wait Example

C
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    pid_t child = fork();

    if (child == -1)
    {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (child == 0)
    {
        char *args[] = {
            "echo",
            "Child process executed this command",
            NULL
        };

        execvp(args[0], args);

        perror("execvp");
        _exit(127);
    }

    int status;

    if (waitpid(child, &status, 0) == -1)
    {
        perror("waitpid");
        return EXIT_FAILURE;
    }

    if (WIFEXITED(status))
    {
        printf("Child exited with status %d.\n",
               WEXITSTATUS(status));
    }
    else if (WIFSIGNALED(status))
    {
        printf("Child terminated by signal %d.\n",
               WTERMSIG(status));
    }

    return EXIT_SUCCESS;
}

Common Mistakes

  • Forgetting to check the return value of fork()
  • Assuming the parent always runs before the child
  • Assuming exec() creates a new process
  • Forgetting that successful exec() never returns
  • Calling exit() in a child after fork() when _exit() is more appropriate
  • Failing to collect child processes
  • Treating wait() status as a normal exit code without using WIFEXITED and related macros
  • Ignoring inherited file descriptors
  • Assuming ordinary memory is shared between parent and child after fork()
  • Forgetting that stdio buffers are duplicated across fork()

Best Practices

  • Always check fork(), exec(), and wait-related return values
  • Use fork() to create the child and exec() to replace the child's program image when appropriate
  • Use waitpid() when you need to wait for a specific child
  • Collect terminated children to avoid zombies
  • Use _exit() in the child when terminating after a failed exec path
  • Inspect child status with POSIX wait macros
  • Close file descriptors that are not needed by each process
  • Be deliberate about stdio buffering around fork()
  • Document whether an API is POSIX-specific rather than portable ISO C

Quick Reference

APIPurpose
fork()Create a child process
execvp()Replace the current process image with another program
wait()Wait for a child process
waitpid()Wait for a specific child or use wait options
getpid()Get the current process ID
getppid()Get the parent process ID
_exit()Terminate a process without normal stdio cleanup

Practice Exercises

  • Write a program that creates a child with fork() and prints both process IDs
  • Create a child process that executes the ls command using execvp()
  • Make the parent wait for the child with waitpid()
  • Report whether the child exited normally or was terminated by a signal
  • Create two child processes and collect both with waitpid()
  • Redirect a child's standard output to a file before calling exec()
  • Build a small command runner using fork(), execvp(), and waitpid()
  • Experiment with stdio buffering before fork() and observe the resulting output

Conclusion

POSIX provides powerful process-management primitives for C programs. fork() creates a child process, exec() replaces a process with another program, and wait() or waitpid() lets a parent collect and inspect child termination. Together, these APIs form the foundation for many Unix process-management patterns, including command execution, pipelines, and process-based workers.

Note: Note: fork(), exec(), wait(), waitpid(), getpid(), and related functions are POSIX APIs and are not part of the ISO C standard. The exact availability and behavior of some extensions can vary between POSIX implementations.