Processes, Jobs and Signals · about 40 min

Processes, PIDs and /proc

Why this matters

Everything you run is a process, and a shell script is mostly a machine for creating them: each external command, each pipeline stage, each $(...) is at least one new process. When a script is slow, hangs, leaves things running after it exits, or cannot be stopped, the explanation is in how processes are created, related and ended. This module goes underneath the shell to the kernel's view, and this first lesson gives you the vocabulary and the tools to look.

What a process is, to the kernel

The kernel keeps a record for every process. The important fields:

FieldMeaning
PIDprocess ID, a unique number
PPIDthe parent's PID: who created it
UID/GIDwhich user and group it runs as (permissions, module 4)
programthe executable it is running, and its arguments (argv)
environmentthe exported variables it received (module 7)
current directorywhere relative paths start
file descriptorsits open files, pipes and terminals: 0, 1, 2, ...
staterunning, sleeping, stopped, zombie
exit statusonce it has finished, until the parent collects it

fork, exec, wait: the whole cycle

Module 1 introduced fork and exec. Here is the full life cycle, now with wait and exit:

   bash (PID 40)
     │ fork()  ─────────────▶  bash (PID 41)   an exact copy: same memory, variables,
     │                          │               directory, open fds; fork returns 0 here
     │                          │ (child sets up redirections: open, dup2, close)
     │                          │ execve("/usr/bin/sort", argv, envp)
     │                          ▼
     │                        sort (PID 41)    same PID, same fds, new program
     │                          │ ... runs ...
     │                          │ exit(0)      memory freed; PID and status kept: a zombie
     │ wait(41) ◀───────────────┘              parent collects status 0; entry removed
     ▼ $? = 0

Points worth holding on to:

Builtins run inside bash with no fork. Subshells ( ... ), command substitutions $( ... ), pipeline stages and background jobs are forks of bash without an exec: a copy of the shell running shell code. This is why they cannot change the parent's variables or directory.

Looking at processes: ps

Open the shell for this lesson.

$ ps
    PID TTY          TIME CMD
      1 pts/0    00:00:00 bash
     23 pts/0    00:00:00 ps
$ ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
learner        1       0  0 14:02 pts/0    00:00:00 bash -l
learner       24       1  0 14:05 pts/0    00:00:00 ps -ef

Plain ps shows processes on your terminal; ps -ef shows every process (-e) in full format (-f). The sandbox is a container with its own PID namespace, so it sees only its own processes and your login shell is PID 1. On a normal machine you would see hundreds, starting with systemd as PID 1.

Choose your own columns with -o:

$ sleep 100 &
[1] 31
$ ps -o pid,ppid,stat,etime,cmd
    PID    PPID STAT     ELAPSED CMD
      1       0 Ss         03:10 bash -l
     31       1 S          00:04 sleep 100
     32       1 R+         00:00 ps -o pid,ppid,stat,etime,cmd

The STAT letters: R running or runnable, S sleeping (waiting for something: time passing, input, a child), D uninterruptible sleep (usually disk I/O), T stopped, Z zombie. Extra characters: s session leader, + in the foreground process group (lesson 2).

pstree -p draws the parent-child relationships as a tree, and pgrep sleep prints the PIDs of processes by name. top gives a live view; press q to leave it.

The shell gives you a few PIDs directly:

ParameterMeaning
$$PID of the main shell (the script)
$BASHPIDPID of the current bash process (differs inside subshells)
$PPIDPID of the shell's parent
$!PID of the most recent background job

/proc: the kernel's process table as files

Linux exposes every process as a directory under /proc, named by its PID. These are not files on disk; the kernel generates their contents when you read them. /proc/self always points to the process doing the reading.

$ ls /proc/$$
attr  cmdline  cwd  environ  exe  fd  limits  maps  status  ...
$ cat /proc/$$/status | head -9
Name:    bash
Umask:    0022
State:    S (sleeping)
Tgid:    1
Ngid:    0
Pid:    1
PPid:    0
...
$ readlink /proc/$$/cwd
/work
$ readlink /proc/$$/exe
/usr/bin/bash
$ ls -l /proc/$$/fd
lrwx------ 1 learner learner 64 Nov  1 14:10 0 -> /dev/pts/0
lrwx------ 1 learner learner 64 Nov  1 14:10 1 -> /dev/pts/0
lrwx------ 1 learner learner 64 Nov  1 14:10 2 -> /dev/pts/0
lrwx------ 1 learner learner 64 Nov  1 14:10 255 -> /dev/pts/0

Try watching a redirection happen:

$ sleep 60 > /tmp/out.txt &
[1] 40
$ ls -l /proc/40/fd
lr-x------ 1 learner learner 64 Nov  1 14:12 0 -> /dev/null
l-wx------ 1 learner learner 64 Nov  1 14:12 1 -> /tmp/out.txt
lrwx------ 1 learner learner 64 Nov  1 14:12 2 -> /dev/pts/0

fd 1 of the sleep points at the file: bash set that up in the child before exec. (Background jobs in a non-interactive shell get stdin from /dev/null; you will see why in lesson 2.)

Tools like ps, top and pgrep are just programs that read /proc. You can do it yourself, as the exercises show.

How many processes does a line create?

count=$(grep -c error app.log | tr -d ' ')

bash forks a subshell for $( ); inside it, it forks twice more for the two pipeline stages, which exec grep and tr. Three forks, two execs, for one line. Forks are cheap on Linux (memory is shared copy-on-write until someone writes to it), but in a loop over 100,000 lines they add up: a builtin such as ${var//x/y} can be a thousand times faster than $(echo "$var" | sed ...). This is the real reason for preferring builtins inside loops.

Common mistakes

Exercises

Two quizzes on the process model, then two scripts that look at real processes through /proc: one at itself, one at a child it creates.

Recap

Going further: run man 5 proc and search for /proc/pid/status. Then compare tr '\0' '\n' < /proc/$$/environ with the output of env, and explain any differences (hint: environ is the environment at start).

Exercises

Exercise 1 · quiz

What is a zombie?

ps shows a process in state Z with <defunct> after its name. What is it?

Answer
  • A process that has exited, but whose parent has not yet collected its exit status with wait.

When a process exits, the kernel frees its memory but keeps a small entry (PID and exit status) until the parent calls wait. In between it is a zombie. It uses no CPU and cannot be killed, because it is already dead; the fix is for the parent to wait (or to exit, so PID 1 adopts and reaps it). Choice 3 describes an orphan, which is a live process; choice 4 is state T.

Exercise 2 · quiz

$$ in a subshell

In a script running as PID 500, what do these two lines print? Assume the subshell gets PID 501.

echo "$$ $BASHPID"
( echo "$$ $BASHPID" )
Answer
  • 500 500 / 500 501

$$ is defined as the PID of the main shell and is deliberately not updated in subshells (it is copied in the fork along with every other variable). $BASHPID is bash's way of asking "which process am I really?", so it differs inside ( ), $( ), pipelines and background jobs. Use $$ for things like unique temp names for the script, and $BASHPID when you really need the current process.

Exercise 3 · write code

Read your own process from /proc

Write a script that inspects its own process through /proc/$$:

  1. Print every element of its command line (/proc/$$/cmdline), one per line. In that file the arguments are separated by NUL bytes (\0), so turn each NUL into a newline with tr.
  2. Then print cwd: followed by the process's current directory, read from the symbolic link /proc/$$/cwd with readlink.

The checker runs bash script.sh "a b" c in /work, so the expected output is exactly:

bash
script.sh
a b
c
cwd: /work

Do not just echo the arguments: the point is to see what the kernel recorded. (The checker also runs it with no arguments.)

File: script.sh

Starting point

# print each element of /proc/$$/cmdline on its own line
# then: cwd: <the target of /proc/$$/cwd>

Example with arguments a b c

expected output

bash
script.sh
a b
c
cwd: /work

Example

expected output

bash
script.sh
cwd: /work
Hint 1

tr 'X' 'Y' < file replaces characters; a NUL byte is written '\0' for tr.

Hint 2

readlink /proc/$$/cwd prints where the link points.

Hint 3

tr '\0' '\n' < "/proc/$$/cmdline" then echo "cwd: $(readlink "/proc/$$/cwd")".

Reference solution
tr '\0' '\n' < "/proc/$$/cmdline"
echo "cwd: $(readlink "/proc/$$/cwd")"

Exercise 4 · write code

Parent, child and status

Write a script that:

  1. starts sleep 30 in the background and remembers its PID ($!);
  2. reads the child's parent PID from the PPid: line of /proc/PID/status and prints parent ok if it equals $$, or parent wrong otherwise;
  3. terminates the child with kill PID;
  4. collects its exit status with wait PID and prints child status: N.

Expected output, exactly:

parent ok
child status: 143

(143 = 128 + 15: killed by signal 15, SIGTERM.) The whole run must finish well within the 10 s limit, so do not wait for the sleep to end by itself.

File: script.sh

Starting point

sleep 30 &
child=$!
# check the parent, kill the child, wait for it and report its status

Example

expected output

parent ok
child status: 143
Hint 1

awk '/^PPid:/ {print $2}' /proc/$child/status prints just the number.

Hint 2

wait "$child" returns the child's status, so $? right after it is what you print.

Reference solution
sleep 30 &
child=$!
ppid=$(awk '/^PPid:/ {print $2}' "/proc/$child/status")
if [ "$ppid" = "$$" ]; then echo "parent ok"; else echo "parent wrong"; fi
kill "$child"
wait "$child"
echo "child status: $?"