Memra

What a process is, and how the shell runs a command

◈ 9 cards

A process is a program in execution. The shell runs an external command with fork, exec and wait — and a builtin with none of them.

A process is a program in execution

A program is a file on disk. A process is that program running: an image in memory with its own address space, its own open files, its own working directory, and a numeric identity — the PID — that the kernel hands out when the process is created and never reuses while the process lives.

UNIX has exactly one way to run an external command, and it is process creation followed by process termination. There is no other mechanism. Every time you type wc, grep, gcc or firefox, a new process comes into existence, does the work, and is destroyed. That single fact explains a surprising amount of shell behaviour, including several things that look like bugs the first time you meet them.

The three calls: fork, exec, wait

The kernel gives the shell three system calls and the shell composes them:

  • fork() creates a new process that is a copy of the caller. Both the original (the parent) and the copy (the child) return from the same fork() call and carry on at the next statement. They differ in exactly one visible way: fork() returns the child's PID in the parent and 0 in the child, so each can tell which one it is.
  • exec() does not create a process. It replaces the image of the process that calls it with a different program. Same PID, same open files, entirely new code and data. If exec() succeeds it never returns, because the code that would have received the return value has been overwritten.
  • wait() suspends the caller until one of its children terminates, and collects the child's exit status.

Worked example — tracing ls -l

You type ls -l and press Enter. Step by step:

  1. The shell reads the line, splits it into words, and expands anything that needs expanding. It looks up ls: not a builtin, so it searches PATH and finds /usr/bin/ls.
  2. The shell calls fork(). There are now two shells. They are identical — same code, same variables, same terminal — but one is the parent and one is the child.
  3. The child calls exec("/usr/bin/ls", …). Its shell image is thrown away and replaced by ls. The PID does not change; the process that was a shell a microsecond ago is now ls, and it inherits the shell's standard output, which is why the listing lands on your terminal.
  4. The parent calls wait() and blocks. This is why the prompt does not come back while ls is running — the shell is asleep, not busy.
  5. ls finishes and calls exit(0). The kernel wakes the parent, wait() returns the child's status, and the shell prints the prompt again.

Now put & on the end — ls -l & — and step 4 disappears. The shell skips the wait() and prints the prompt immediately. Nothing else changes. Backgrounding is not a different mechanism; it is the same mechanism with the wait omitted.

A script is the same mechanism, one level deeper

Run a shell script and the shell forks a child as usual, but the child execs another shell, which opens the script file and reads it as its input. That child shell then forks and execs once per external command inside the script, waiting for each in turn. At end of file the child shell exits, and your original shell — which has been sitting in wait() the whole time — prints the prompt.

So a three-command script costs you four processes, not three: one child shell plus one per external command. That is also why a variable a script sets is gone when the script ends. The assignment happened in the child shell's memory, and the child shell no longer exists.

Builtins make no process at all

Some commands live inside the shell's own executable rather than in a file: cd, pwd, echo, export, read, set, shift, umask, wait, jobs, fg, bg, exit, trap. Running one costs zero new processes — the shell just calls its own internal function.

Some of these have to be builtins. cd is the clearest case. If cd were an external program the shell would fork, the child would change its own working directory, and the child would then exit — leaving the parent exactly where it started. A child cannot reach into its parent. cd can only work by being code the shell runs in its own process.

type -a name tells you which kind you have, and -a shows every match in order. On many systems type -a echo reports both a shell builtin and /usr/bin/echo; the builtin wins, and the binary exists only so that programs like find -exec have something to execute.

Why cd inside ( … ) does not move you

Parentheses are command grouping: the shell forks a subshell and runs the whole group inside it. So ( cd /usr; pwd ); pwd prints /usr and then your original directory. The cd really did happen — in the subshell, which then exited and took its working directory with it. This is the same parent/child independence that forces cd to be a builtin, viewed from the other side, and it is the cheapest way to visit a directory without having to remember to come back.

two shells nowls is runningexit statuswait returnsshell forkschild is a copychild execs/usr/bin/lsparent waitsshell blockschild exitsstatus 0prompt returns
The whole of external command execution. Remove the third stage and you have described a background command.
Commandtype -a reportsNew process?cdshell builtinnojobsshell builtinnoechobuiltin, and /usr/bin/echono - builtin winsumaskshell builtinnols/usr/bin/lsyes - fork + execps/usr/bin/psyes - fork + execcd MUST be a builtin: a child cannot move its parent.
Only the bottom two cost a fork and an exec. A builtin runs inside the shell you are already talking to.
NORMAL ~/memra/learn/comp-325/what-a-process-is-and-how-the-shell-runs-a-command utf-8 LF