All writing

Linux processes, signals and the life of a program

Following a Linux process from creation and program execution through scheduling, signals, termination and the parent's final wait.

about 27 minutes min read

I began by separating two ideas that I had quietly been treating as one:

program

and:

process

A program is executable code and associated data stored somewhere, perhaps in a file on disk.

A process is a running execution context.

That distinction helped, but it left the process sitting rather statically in my head:

program on disk
      │
      ▼
running process

What I did not yet have was the whole lifecycle.

Where did the process come from?

How did it begin executing a different program?

Why does sleep appear to be doing nothing without having finished?

What exactly happens when I press Ctrl-C?

Why is the command for sending a polite termination request called kill?

And after a process exits, why might it briefly remain visible as a zombie?

The life of a Linux process involves several distinct operations:

creation
   │
   ▼
program execution
   │
   ▼
running and waiting
   │
   ▼
signals and state changes
   │
   ▼
termination
   │
   ▼
parent collects result

That is the journey I want to follow.

These examples assume Bash on Linux. The fork-and-exec diagrams are conceptual simplifications, particularly for multithreaded programs and shells that may use implementation optimisations.

Program, process and execution context

A process is more than a program currently using a CPU.

Among other things, it can have:

a process ID
a parent process ID
a virtual address space
one or more threads
open file descriptors
a current working directory
credentials
an environment
signal dispositions
per-thread signal masks
resource limits

A richer picture is:

PROCESS
│
├── identity
│   ├── PID
│   └── parent PID
│
├── execution
│   ├── program instructions
│   ├── registers
│   └── one or more threads
│
├── memory
│   └── virtual address space
│
├── operating-system resources
│   ├── file descriptors
│   ├── working directory
│   └── credentials
│
└── control state
    ├── scheduling state
    ├── signal handling
    └── termination status

The executable program is one important part of the process, but it is not the entire process.

Processes have parents

Except for special cases at the root of a process hierarchy, a process is created by another process.

The creator is the parent. The newly created process is its child.

I can inspect those relationships with:

ps -o pid,ppid,stat,cmd

where:

PID     process ID
PPID    parent process ID
STAT    current process state
CMD     command

A process tree makes the hierarchy clearer:

ps -eo pid,ppid,pgid,sid,stat,tty,cmd --forest

A simplified fragment might resemble:

PID   PPID  CMD

1200     1  terminal
  └─1250 1200  bash
      ├─1310 1250  sleep 300
      └─1311 1250  ps ...

Here Bash is the parent of the commands it started:

terminal process
       │
       ▼
      Bash
       │
       ├── sleep
       └── ps

The relationship matters because the parent can wait for a child, inspect how it ended and receive notification when it changes state.

Creating a child and executing a program

A new process must first be created. It can then be made to execute another program.

Those are separate operations.

The traditional Unix model expresses them using:

fork

and:

exec

Very roughly:

parent process
      │
      │ fork
      ▼
parent process + child process
                       │
                       │ exec
                       ▼
               child now executes
                 another program

Modern libraries and applications may also use interfaces such as posix_spawn(), and shells are free to optimise particular cases. The fork-and-exec model remains an excellent way to understand what the shell is arranging.

fork() creates a child

When a process calls fork(), the system creates a child with its own process ID.

Conceptually, the child begins as a copy of the parent’s process context:

BEFORE FORK

parent
PID 4100


AFTER FORK

parent                 child
PID 4100               PID 4101

Both continue from the point at which fork() returned. The return value lets the program distinguish them:

parent receives:
    child PID

child receives:
    0

So one source program can follow two paths:

pid = fork();

if (pid == 0) {
    /* child path */
} else {
    /* parent path */
}

The child is not the same process. It has a new PID, a logically separate virtual address space and its own process lifecycle.

Linux normally implements the memory side efficiently using copy-on-write. Parent and child can initially share physical memory pages, with a page copied only when one side modifies it, rather than duplicating every page immediately.

There is also an important multithreading qualification. If a multithreaded process calls fork(), only the thread that called fork() exists in the child. The other threads are not copied. This is one reason the path between fork() and exec() requires particular care in multithreaded programs.

The child inherits file descriptors

One inherited resource is especially important for shells and pipelines.

The child receives copies of the parent’s open file descriptors. Suppose the parent has:

fd 0 ──► terminal input
fd 1 ──► terminal output
fd 2 ──► terminal output

After fork(), the child initially has corresponding descriptors:

PARENT

fd 0 ──► terminal
fd 1 ──► terminal
fd 2 ──► terminal


CHILD

fd 0 ──► terminal
fd 1 ──► terminal
fd 2 ──► terminal

The copied descriptors refer to the same underlying open file descriptions. This allows a shell to:

  1. create a child;
  2. rearrange the child’s descriptors;
  3. execute the requested program.

The new program then inherits the prepared input and output connections.

exec replaces the process image

After creating a child, the child can execute another program using an exec-family function.

This is not:

process A starts process B

It is closer to:

process A replaces the program it is executing

Suppose the child initially contains a copy of Bash’s process image:

PID 4101
program image: bash

It then successfully executes:

/bin/sleep

The result is:

PID 4101
program image: sleep

The PID is still 4101. The child did not create another process merely by performing exec; its process image was replaced.

BEFORE EXEC

process 4101
├── Bash program image
├── memory mappings
├── stack
└── instructions


AFTER EXEC

process 4101
├── sleep program image
├── new memory mappings
├── new stack
└── new instructions

A successful execve() does not return to the old program. The old process image is gone.

Not every process attribute disappears. The process keeps its identity, including its PID. Open file descriptors normally remain open unless marked to close during execution.

This allows the shell to prepare:

stdout ──► output.txt

and then execute a completely different program. The new program begins with file descriptor 1 already pointing to the file.

Other attributes have their own rules. For example, caught signal handlers are reset to their default dispositions during exec, while signals that were ignored normally remain ignored.

The useful model is:

exec replaces the program image

but

the process itself continues

What Bash is conceptually arranging

Suppose I type:

grep 'ERROR' application.log > errors.txt

A simplified lifecycle is:

BASH
 │
 ├── parses command
 ├── recognises output redirection
 ├── creates a child process
 │
 ▼
CHILD
 │
 ├── opens errors.txt
 ├── connects fd 1 to errors.txt
 └── executes grep
       │
       ▼
GREP PROCESS
 │
 ├── reads application.log
 ├── writes matches to fd 1
 └── exits
       │
       ▼
BASH
 └── waits and collects status

The exact implementation may be more sophisticated, but this model explains why:

  • grep does not need to understand Bash’s > syntax;
  • the program inherits streams prepared by the shell;
  • the child gets a distinct PID;
  • executing grep does not add another PID after the child already exists;
  • Bash can later retrieve the child’s completion status.

Running, runnable and waiting

A process can exist without currently using a CPU.

Try:

sleep 300 &
pid=$!

ps -o pid,ppid,stat,cmd -p "$pid"

The state will commonly include:

S

for interruptible sleep.

The process has not terminated. It simply does not need to consume CPU continuously while waiting for its timer.

Process states are snapshots

Tools such as ps and /proc/<pid>/status expose a process’s current state. The value is only a snapshot and may change again before I finish reading it.

Common state letters include:

State Broad meaning
R Running or runnable
S Interruptible sleep, waiting for an event
D Uninterruptible sleep, often associated with kernel I/O waits
T Stopped
Z Zombie, terminated but not yet reaped

These are not permanent identities. A process can move repeatedly between states:

runnable
   │
   ▼
executing
   │
   ▼
waiting
   │
   ▼
runnable again

For example, a process might become runnable, receive CPU time, request data from a file descriptor, sleep while no data is available and become runnable again when data arrives.

Seeing state S does not mean the process is broken. Waiting is normal.

Linux schedules runnable threads

The R state can mean that a thread is currently executing or ready to execute when scheduled.

A machine can have more runnable threads than available CPU cores:

RUN QUEUE

thread A
thread B
thread C
thread D

    │
    ▼

available CPU time

The kernel scheduler chooses among runnable threads. In a single-threaded process, that is effectively the process’s only thread.

A process can therefore be alive, ready and making progress over time without continuously owning a CPU.

Sleeping is efficient

Suppose a server waits for a network request.

A poor design would repeatedly ask:

anything yet?
anything yet?
anything yet?

while consuming CPU.

Instead, a thread can wait for an event:

server calls read
      │
      ▼
no data available
      │
      ▼
thread sleeps
      │
      ▼
network data arrives
      │
      ▼
thread becomes runnable

Sleeping means the execution context is waiting for something before it can continue.

The distinction between:

not running

and:

finished

is essential.

Signals and job control

Signals introduce asynchronous events into a process lifecycle.

A signal can report that:

the user requested interruption
a timer expired
a child changed state
a terminal disconnected
an invalid memory access occurred
a pipe lost all readers
another process requested termination

The event does not have to arrive at a point chosen by the program’s main logic, which is why signal handling requires care.

How signal delivery works

The command:

kill "$pid"

sounds decisive. By default, however, it sends SIGTERM.

More generally, kill is a signal-sending interface:

kill -TERM "$pid"
kill -INT "$pid"
kill -STOP "$pid"
kill -CONT "$pid"

A signal has a disposition that determines its broad treatment. A catchable signal can use its default action, be ignored or invoke a handler.

Default actions include:

terminate
terminate and produce a core dump
ignore
stop
continue

Signal dispositions generally belong to the process. Signal masks, which temporarily block delivery of selected signals, belong to individual threads.

signal disposition
    generally process-wide

signal mask
    per thread

If a blocked signal is generated, it may remain pending until an eligible thread unblocks it:

signal generated
      │
      ▼
is delivery blocked?
   │       │
  no      yes
   │       │
   ▼       ▼
deliver   mark pending
            │
            ▼
      deliver when eligible

Blocking is different from ignoring. An ignored signal is discarded according to its disposition. A blocked signal is being held back from delivery.

Familiar signals

Signal Typical meaning Default action Catchable?
SIGTERM Conventional termination request Terminate Yes
SIGINT Interactive interrupt, commonly Ctrl-C Terminate Yes
SIGHUP Terminal disconnect; sometimes used for reload Terminate Yes
SIGTSTP Interactive stop request, commonly Ctrl-Z Stop Yes
SIGCONT Continue a stopped process Continue Yes
SIGCHLD Child changed state Ignore notification Yes
SIGPIPE A pipe write found no readers Terminate Yes
SIGKILL Unconditional termination Terminate No
SIGSTOP Unconditional stop Stop No

SIGTERM is normally the right first request for a long-running process. Because it can be caught, a well-designed program may stop accepting work, close listeners, flush required state, remove temporary files and terminate children before exiting.

SIGKILL provides no such opportunity. It cannot be caught, blocked or ignored. The kernel reclaims operating-system resources such as memory and file descriptors, but application consistency may depend on cleanup that the program itself would otherwise perform.

A deliberate shutdown policy is therefore:

send SIGTERM
      │
      ▼
allow graceful shutdown
      │
      ▼
verify whether process exited
      │
      ▼
escalate only if required

SIGCHLD is different again. It notifies a parent that a child changed relevant state. The parent can then use a wait operation to retrieve the details.

Send signals deliberately

Signal names communicate intention more clearly than unexplained numbers:

kill -TERM "$pid"
kill -KILL "$pid"

is clearer than:

kill -15 "$pid"
kill -9 "$pid"

Signal numbers are also not equally portable across every architecture.

The kill interface recognises signal 0 too:

if kill -0 "$pid" 2>/dev/null; then
    printf 'process exists and is signalable\n'
fi

No signal is delivered. The operation checks whether the caller can address the process under the relevant existence and permission rules.

That result is only a snapshot. Between checking the PID and acting on it, the original process could exit and the PID could eventually be reused. A PID is an identifier with a lifecycle, not a permanent identity token.

Ctrl-C, process groups and jobs

When I press Ctrl-C, the terminal does not inspect the visible command and select one PID. The terminal driver generates SIGINT for the foreground process group.

This matters because one interactive job may contain several processes:

generate |
transform |
display

Bash can place the pipeline’s processes in one process group:

foreground process group
│
├── generate
├── transform
└── display

A terminal-generated SIGINT can then target the whole foreground operation.

Bash job control adds another abstraction:

job

A job may be one command or a pipeline containing several processes. Bash tracks jobs with:

jobs
fg
bg

If I run:

sleep 300

and press Ctrl-Z, the terminal sends a stop signal to the foreground process group. Bash reports a stopped job.

I can resume it in the background:

bg

or return it to the foreground:

fg
foreground running
       │
       │ Ctrl-Z / SIGTSTP
       ▼
stopped
       │
       ├── bg / SIGCONT ──► background running
       │
       └── fg / SIGCONT ──► foreground running

The process was not restarted. Its execution was suspended and later continued.

Foreground and background are relationships with the controlling terminal. The foreground process group receives terminal-generated signals and is normally the group allowed to read from the terminal. A background job that attempts to read may be stopped with SIGTTIN.

Programs and scripts can handle signals

A long-running program can install handlers for selected signals. In C, the modern interface is sigaction().

A simplified pattern is to let a handler set a flag:

static volatile sig_atomic_t stop_requested = 0;

static void
handle_term(int signal_number)
{
    stop_requested = 1;
}

The main program can notice the flag later:

while (!stop_requested) {
    do_work();
}

shutdown_cleanly();

The handler should perform only async-signal-safe operations. It should not casually run arbitrary application logic while interrupting normal execution.

Bash scripts can use trap for shell-level handling:

#!/usr/bin/env bash

workspace=$(mktemp -d)

cleanup()
{
    rm -rf -- "$workspace"
}

trap cleanup EXIT
trap 'exit 0' TERM
trap 'exit 130' INT

perform_work "$workspace"

This is an illustrative lifecycle sketch, not a complete process supervisor. Supervising children safely introduces additional races and status decisions, and a trapped signal can interrupt Bash’s wait.

The important idea is that a script may end through ordinary completion, SIGTERM, SIGINT or another external event. Reaching the final source-code line is not its only exit path.

For most catchable signals, the receiving process participates in what happens. SIGKILL and SIGSTOP are deliberately exceptional: the process cannot override them.

Termination, waiting and zombies

A process can terminate normally by returning from main() or calling an exit function. It supplies an exit status:

return 0;

A non-zero value can communicate another outcome.

In Bash:

some_command
status=$?

printf 'status=%d\n' "$status"

captures the most recent command’s status before another command replaces $?.

A process can also terminate because a signal’s action was termination, including signals such as:

SIGTERM
SIGINT
SIGSEGV
SIGPIPE
SIGKILL

depending on the circumstances and dispositions.

Bash normally exposes signal termination as:

128 + signal number

This is a shell-level encoding of richer wait information, not a unique or complete description. A program can deliberately exit with the same numeric value.

The parent ordinarily waits

A parent can call a wait-family interface such as:

wait()
waitpid()
waitid()

to learn about child state changes and collect termination information.

In Bash:

sleep 2 &
pid=$!

wait "$pid"
status=$?

printf 'child %s finished with status %s\n' \
    "$pid" "$status"

Ordinarily, a parent must perform a wait operation to collect a terminated child’s status. Specialised configurations, including particular SIGCHLD handling or SA_NOCLDWAIT, can cause the kernel to discard that status automatically, but explicit waiting is the normal model.

Termination and reaping are separate

These events do not have to happen at the same instant:

child stops executing

and:

parent collects the child's status

When a child terminates, the kernel releases most of its resources but retains enough information for the parent to learn:

which child terminated
whether it exited normally
its exit status
whether a signal terminated it
some resource-usage information

Until the parent performs an appropriate wait, the terminated child can remain as a zombie.

LIVE CHILD

PID
memory
threads
file descriptors
execution state
...


CHILD TERMINATES

memory released
threads gone
file descriptors closed


ZOMBIE RECORD

PID
termination status
minimal accounting information

A zombie is already dead. It is not using CPU or continuing application work. What remains is a small process-table record containing completion information.

That is why sending SIGKILL to a zombie does not solve anything. There is no execution left to interrupt.

The missing operation is:

parent must wait

not:

send a stronger death signal

Zombies and orphans are different

A zombie is a child that has terminated but has not yet been reaped.

An orphan is a process whose original parent terminated while the child was still alive.

ZOMBIE

child terminated
parent has not waited


ORPHAN

parent terminated
child still running

An orphaned process is reparented within the system, commonly to an init process or an appropriate subreaper, which can later collect its termination status.

A process can be orphaned without being a zombie. A zombie is already terminated.

SIGCHLD helps the parent notice relevant state changes:

child terminates
      │
      ▼
kernel records status
      │
      ▼
parent receives SIGCHLD
      │
      ▼
parent calls wait
      │
      ▼
child is reaped

The signal is notification. The wait operation retrieves the status and completes the ordinary lifecycle.

Operational consequences

This stops being an operating-system curiosity once software runs as a service.

A service manager or orchestrator needs to know:

How is the process started?
Which process should be monitored?
Which signal requests shutdown?
How long should graceful termination be allowed?
Does the process propagate shutdown to children?
When is escalation appropriate?
What exit statuses indicate success or failure?
Will every child be reaped?

A program that starts correctly but cannot stop correctly is not operationally complete.

Process trees complicate termination

Suppose a shell script starts a worker, and that worker starts a helper:

supervisor
    │
    ▼
script
    │
    ▼
worker
    │
    ▼
helper

Sending SIGTERM only to the script does not automatically guarantee that every descendant receives it.

The script may need to forward termination, wait for children, escalate when necessary and collect their statuses. Process groups can help treat a related set as one controllable job.

This is why robust supervision is more complex than:

some_program &

The process hierarchy becomes part of the system architecture.

PID 1 and containers

Inside a container, the application or entrypoint may become process 1 in its PID namespace.

That position brings extra responsibilities. PID 1 has special signal behaviour on Linux: signals whose default action would normally terminate a process may be ignored unless PID 1 has installed a handler. It must also ensure that orphaned descendants are reaped appropriately.

A shell wrapper that starts an application, never uses exec, ignores signals and never waits for children can therefore cause surprising behaviour.

A simple wrapper often benefits from replacing itself with the application:

exec /usr/local/bin/application "$@"
WITHOUT EXEC

container PID 1
    │
    ▼
shell wrapper
    │
    ▼
application


WITH EXEC

container PID 1 / wrapper process
    │
    │ exec
    ▼
application in the same process

Using exec removes an unnecessary process layer and lets the application receive signals directly as the container’s PID 1.

It does not solve every PID 1 responsibility. The application must still handle PID 1’s signal behaviour and reap children it creates, or the container should use a minimal init process designed for those jobs.

A small lifecycle experiment

Start a child:

sleep 120 &
pid=$!

printf 'child pid=%s\n' "$pid"

Inspect its relationship and current state:

ps -o pid,ppid,pgid,sid,stat,cmd -p "$pid"

Inspect Linux’s process view:

grep -E '^(Name|State|Pid|PPid):' \
    "/proc/$pid/status"

Stop it:

kill -STOP "$pid"

Inspect again:

ps -o pid,ppid,stat,cmd -p "$pid"

Continue it:

kill -CONT "$pid"

Request termination:

kill -TERM "$pid"

Collect the result:

wait "$pid"
status=$?

printf 'wait status=%s\n' "$status"

The experiment follows several distinct operations:

Bash creates child
      │
      ▼
child executes sleep
      │
      ▼
sleep waits
      │
      ▼
SIGSTOP changes state
      │
      ▼
SIGCONT resumes execution
      │
      ▼
SIGTERM terminates process
      │
      ▼
Bash waits and collects status

That is a process lifecycle in miniature.

The mental model I’m keeping

My earlier model was:

program
   │
   ▼
process
   │
   ▼
eventually disappears

The new model is:

                    PARENT PROCESS
                          │
                          │ create child
                          ▼
                    CHILD PROCESS
                          │
                          │ execute program
                          ▼
                     PROCESS IMAGE
                          │
             ┌────────────┼────────────┐
             │            │            │
             ▼            ▼            ▼
          runnable      running       waiting
             ▲            │            │
             └────────────┴────────────┘
                          │
                          │ signals may:
                          │ interrupt
                          │ stop
                          │ continue
                          │ terminate
                          ▼
                    PROCESS TERMINATES
                          │
                          ▼
                  completion information
                          │
                          ▼
                    parent performs wait
                          │
                          ▼
                       reaped

Several distinctions now feel essential:

program
    is not the same thing as
process

creating a process
    is not the same thing as
executing a program

not using the CPU
    is not the same thing as
terminated

sending a signal
    is not always the same thing as
killing a process

termination
    is not the same event as
reaping

zombie
    is not the same thing as
orphan

A process has a beginning, changing execution states and a completion protocol.

It belongs to a hierarchy. It communicates through file descriptors. The scheduler decides when runnable threads execute. Signals let the environment change the process’s path. The parent ordinarily has the final responsibility of collecting how its child ended.

The life of a program is not simply:

start
work
stop

It is a negotiation between:

the program
the parent
the kernel
the terminal
other processes
the operator

That feels like the first genuinely useful process model I have had.

References and further reading

Core lifecycle

Linux fork(2) manual page
Documents child-process creation, copy-on-write behaviour, multithreaded restrictions, return values and inherited state.

Linux execve(2) manual page
Documents replacement of the calling process’s program image and the process attributes preserved or reset.

Linux signal(7) manual page
Provides the main Linux overview of dispositions, masks, pending signals, delivery and standard signals.

Linux wait(2) and waitpid(2) manual page
Documents waiting for child state changes, retrieving termination information and reaping zombies.

GNU Bash Reference Manual: Job Control Basics
Explains Bash jobs, process groups, foreground and background execution, terminal signals and suspension.

Further detail

GNU C Library: Process Creation Concepts
Provides a conceptual overview of creating children, executing programs and waiting for completion.

GNU C Library: Executing a File
Explains the exec family and replacement of the current process image.

Linux proc_pid_status(5) manual page
Documents /proc/<pid>/status, including identity, state and signal masks.

Linux proc_pid_stat(5) manual page
Documents lower-level process status fields and Linux process-state codes.

Linux ps(1) manual page
Documents process selection and display fields, including PID, PPID, groups, sessions and state.

Linux sigaction(2) manual page
Documents the preferred interface for inspecting and changing signal dispositions.

Linux kill(2) manual page
Documents sending signals to processes and process groups, including signal zero.

Linux setpgid(2) and getpgrp(2) manual page
Documents process groups, sessions and the controlling terminal’s foreground process group.

GNU Bash Reference Manual: Job Control Builtins
Documents jobs, fg, bg, wait, disown and Bash’s kill builtin.

GNU Bash Reference Manual: Signals
Describes how Bash responds to and propagates signals.

GNU C Library: Process Completion
Explains waiting, child termination status and process completion.

GNU Bash Reference Manual: Bourne Shell Builtins
Includes references for Bash’s trap, kill and wait builtins.