How a shell runs a command: fork, exec, source and friends

3 min read

./script.sh, exec ./script.sh, . ./script.sh and ./script.sh & look alike and do four different things. What differs each time is whether a new process exists, whose environment changes, what $? holds afterwards, and who gets the terminal’s signals.

The ways to run something

./script.sh
Fork, then exec. The child gets a copy of the environment, the parent waits, and $? is the child's exit code. The default, and the isolation it gives is usually what you want.
exec ./app
No fork: the running process becomes app, PID and open descriptors included. Nothing returns to the shell, so there is no exit code to read. This is how a wrapper script hands over to the real program without leaving a shell in the process tree.
. ./script.sh, or source
No new process: the lines run in the current shell, so variables, functions and cd stick. $? is the last command's. For profiles, function libraries and environment files.
./script.sh &
Fork without waiting. $? is 0 as soon as the launch worked; wait gives the real status later. In an interactive shell the job lives in its own process group and keyboard signals do not reach it.
nohup ./script.sh &
Background, plus immunity to SIGHUP when the terminal goes, with stdin from /dev/null and output to nohup.out unless redirected.
(cd /tmp && ls)
A subshell: a forked copy of the shell, so the cd and any assignment are gone when the parenthesis closes. $? is the group's last status.
daemon -- ./app
A detached process reparented to init, with a clean environment and its descriptors closed. For a service started by hand; a service manager does the same with supervision.

Becoming another user

su user starts a shell as the user with most of your environment kept, only HOME and SHELL changed. su - user starts a login shell: your environment is discarded and the user’s profiles are read, which is what a service account expects. su -c 'cmd' user runs one command in a non-interactive shell with no profile read, so PATH is whatever su passes on.

Capturing output

$(command) runs the command in a subshell and substitutes its output; it nests and it is POSIX. Backticks do the same with worse quoting, and are legacy. <(command) is different: it starts the command in the background and substitutes a file name that reads its output, so a program that wants a file can read a pipe, as in diff <(sort a) <(sort b). Bash and Zsh only.

Descriptors and signals

  • A forked child inherits every open descriptor that was not closed or marked close-on-exec; exec keeps them too, since the process is the same; source shares them outright. nohup and daemon swap the standard three for /dev/null or a file.
  • Signal dispositions survive a fork. An exec resets handled signals to their defaults and keeps ignored ones ignored, which is why a background job launched with job control still ignores SIGINT after it execs.
  • Foreground commands share the terminal’s process group and get Ctrl-C; background jobs sit in their own group and do not. A pipeline is one group.

What $? means

a && b
The first failure, or b's status when both pass.
a || b
The first success, or b's status when both fail.
a | b
The last command's, unless set -o pipefail, which makes it the last failure in the pipe.
! a
a's status, inverted.
if a; then
a's status picks the branch; the if itself exits with the branch's last command.