Understanding processes explains performance, why cd must be a builtin and where variables go.
For a simple command name, bash searches in this order: aliases (interactive shells only), keywords (if, [[), functions, builtins, then the hash table of remembered paths and finally the directories in PATH. type -a name shows every match. command name skips functions and aliases; builtin name forces the builtin, useful inside a wrapper function of the same name.
An external program runs via fork (clone the shell process) then exec (replace the clone's image with the program). The parent waits for it unless it was started with &. Builtins run inside the shell process, which is why cd, export, read and source must be builtins: they change the shell's own state. It is also why a loop that calls an external program thousands of times is slow and one using parameter expansion is fast.
Subshells are forks without exec. $$ always holds the main shell's PID, while $BASHPID gives the current (sub)shell's PID. Bash forks for each pipeline stage; with shopt -s lastpipe (non-interactive shells) the last stage runs in the current shell, so cmd | read -r v keeps v.
exec cmd replaces the shell itself, with no fork and no return. Wrapper and container entrypoint scripts end with exec "$@" so the real program becomes PID 1 and receives signals directly.
Example
type -a echo # echo is a shell builtin / echo is /usr/bin/echo
echo() { builtin echo "wrapped: $*"; }
echo hi # wrapped: hi
command echo hi # hi (skips the function)
unset -f echo
echo "main: $$ $BASHPID" # the same PID twice
( echo "sub: $$ $BASHPID" ) # $$ unchanged, BASHPID differs
printf 'x\n' | read -r v; echo "v=[$v]" # v=[]
shopt -s lastpipe # scripts only: last stage runs here
printf 'x\n' | read -r v; echo "v=[$v]" # v=[x]
bash -c 'exec echo "replaced"; echo "never printed"' # replaced
time (for i in {1..200}; do basename /a/b.txt >/dev/null; done) # slow: 200 forks
time (for i in {1..200}; do : "${p##*/}"; done) # fast: no forksRemember
- Lookup: alias, keyword, function, builtin, hash, PATH
- External commands cost a fork and exec; builtins do not
- $$ is the main shell, $BASHPID the current subshell; exec replaces the shell
Watch out for
- Entrypoint scripts that run the app without exec, leaving the shell as PID 1 that does not forward SIGTERM.