Processes, Jobs and Signals · about 45 min

Subshells, groups and exec

Why this matters

You have already met the symptoms: a variable set inside a while loop that is empty after the loop, a cd inside $( ) that did not stick, an exit that did not end the script. They all come from one question: is this code running in the current shell process, or in a forked copy? This lesson makes that question answerable at a glance, and adds the opposite tool, exec, which runs a program without forking at all, and which also lets you rewire a script's own file descriptors.

Subshells: ( ... )

Parentheses run a list of commands in a subshell: bash forks, the child runs the commands, and the parent waits.

$ x=1; echo "parent $BASHPID"
parent 1
$ ( x=2; echo "child $BASHPID sees x=$x"; cd /tmp; pwd )
child 87 sees x=2
/tmp
$ echo "x=$x, pwd=$PWD"
x=1, pwd=/work

The child starts as a copy of the parent: all variables (exported or not), functions, options, the current directory and open file descriptors. Traps are reset, except ignored signals. Whatever the child changes is thrown away when it exits. Its exit status (the status of its last command, or its exit N) becomes the status of the whole ( ... ).

Uses:

( cd "$src" && tar cf - . ) | ( cd "$dest" && tar xf - )   # copy a tree, no cd leak
( set -e; step1; step2 )                                   # strict mode for a block only
( export LC_ALL=C; sort big.txt )                          # temporary environment

For the last one, LC_ALL=C sort big.txt is simpler: an assignment before a command applies to that command only.

Where bash creates subshells implicitly

ConstructWhy
( list )explicitly
$( command ) and `command`output is captured from a child
each stage of a pipelinethe stages must run at the same time
command &runs while the parent continues
<( command ), >( command )process substitution

The pipeline row explains the most famous surprise:

count=0
printf 'a\nb\nc\n' | while read -r line; do
  count=$((count + 1))
done
echo "$count"        # prints 0

The while loop is a pipeline stage, so it ran in a subshell and incremented a copy. Fixes: feed the loop with a redirection instead (done < file, or done < <(command)), so it runs in the current shell; or shopt -s lastpipe, which (in scripts, where job control is off) runs the last stage of a pipeline in the current shell.

And exit inside a subshell exits only the subshell:

check() { [ -f "$1" ] || exit 1; }
result=$(check config; echo ok)    # exit ends the $( ) child; the script continues

Groups: { ...; }

Braces group commands in the current shell: no fork, all changes stick.

{ echo "header"; date; cat body.txt; } > report.txt    # one redirection for three commands
[ -r "$f" ] || { echo "cannot read $f" >&2; exit 1; }  # several commands after ||

Syntax rules, because { and } are reserved words, recognized only as separate words at the start of a command: there must be a space after {, and the last command needs a ; or newline before }. {echo hi} is a command named {echo; { echo hi } waits for more input because } was taken as an argument to echo.

( list ){ list; }
processforked childcurrent shell
variable, cd, exit effectslost / exit only the childkept / exit the script
costa forknone
typical useisolationone redirection, or several commands after && or an or-list

Note that a group becomes a subshell anyway if it is part of a pipeline or run with &.

exec, form 1: replace the shell with a program

exec command args skips the fork: bash calls execve directly, so the shell process becomes the program, keeping its PID, its file descriptors and its environment.

  without exec:  bash (PID 40) ─fork─▶ child (41) ─exec─▶ server     bash waits, idle
  with exec:     bash (PID 40) ──────────────── exec ───▶ server (PID 40)

Nothing after a successful exec in a script ever runs. This is the standard ending of wrapper scripts, such as container entrypoints:

#!/usr/bin/env bash
set -euo pipefail
export APP_ENV=production
cd /srv/app
[ -f .env ] && . ./.env
exec ./server --port 8080 "$@"

Why it matters: the server now is the process the container runtime or systemd started, so it receives their SIGTERM directly (an intermediate bash would receive it instead and, while waiting for a foreground child, would not forward it), its exit status is reported directly, and there is no idle bash using memory.

If the program cannot be executed, a non-interactive shell exits with status 126 or 127 and an error such as exec: ./server: not found.

exec, form 2: redirect the shell's own file descriptors

exec with only redirections and no command does not replace anything. It applies the redirections to the current shell permanently, so every later command inherits them.

exec > build.log 2>&1        # from here on, all output of the script goes to the log
echo "this goes to build.log"

File descriptors are just small numbers indexing the process's table of open files (module 5). exec can open, copy and close any of them:

SyntaxMeaning
exec 3> fileopen file for writing as fd 3 (truncate)
exec 3>> fileopen for appending as fd 3
exec 3< fileopen for reading as fd 3
exec 3>&1make fd 3 a copy of fd 1 (wherever it points now)
exec 1>&3make fd 1 a copy of fd 3
exec 3>&-close fd 3

Saving and restoring stdout, step by step:

                          fd 1        fd 3
start                     terminal    -
exec 3>&1                 terminal    terminal     (a saved copy)
exec > run.log            run.log     terminal
echo "step 1"             → run.log
exec 1>&3 3>&-            terminal    -            (restored, spare closed)

Reading a file through a numbered descriptor lets a loop read two inputs at once, or read from a file while still reading the keyboard on stdin:

exec 3< hosts.txt
while IFS= read -r host <&3; do
  read -r -p "Reboot $host? " answer      # from the terminal, fd 0
  [ "$answer" = y ] && echo "would reboot $host"
done
exec 3<&-

You can watch descriptors open and close in /proc/$$/fd (lesson 1): run exec 3> /tmp/x; ls -l /proc/$$/fd; exec 3>&- in the sandbox shell.

Warning: Typing exec > file in your interactive shell sends all output, including what you type next, into the file and your terminal seems dead. Type exec > /dev/tty blind to get it back. And exec bash or exec ls in an interactive shell replaces your shell: when ls finishes, the session ends.

source versus running

The same distinction explains source (or .): bash setup.sh runs the script in a new bash process, so its variables and cd vanish; source setup.sh reads and runs the file in the current shell, so they stick. That is why environment setup files (~/.bashrc, a Python venv/bin/activate) are sourced, never executed.

Common mistakes

Exercises

Two quizzes on what runs where, then two scripts: one keeps its working directory by visiting directories in subshells, the other rewires its own stdout with exec and restores it.

Recap

Going further: help exec, help source, and shopt lastpipe. Read the "COMMAND EXECUTION ENVIRONMENT" section of man bash (once installed) for the exact list of what a subshell inherits.

Exercises

Exercise 1 · quiz

Predict the output

What does this print?

x=1
( x=2; cd /tmp )
echo "$x $PWD"
{ x=3; cd /tmp; }
echo "$x $PWD"

Assume the script starts in /work. Answer as two lines joined with /, for example 1 /work / 1 /work.

Answer

( ... ) runs in a forked child, a subshell: its assignment and its cd change only the copy, which disappears when the subshell exits. { ...; } is just grouping inside the current shell, so both changes stick. Note the syntax of a group: a space after { and a ; (or newline) before }, because { and } are reserved words, not operators like (.

Exercise 2 · quiz

What exec does

A script contains these three lines. What does it print?

echo before
exec echo replaced
echo after
Answer
  • before / replaced

exec command makes the shell call execve itself, without forking first: the bash process is replaced by /usr/bin/echo (exec bypasses the builtin), keeping its PID and open files. When echo exits, the process is gone, so there is no shell left to run echo after. This is used in wrapper scripts that set up an environment and then hand over to the real program, so no idle bash process stays in between and signals reach the program directly.

Exercise 3 · write code

Visit directories without getting lost

script.sh DIR... prints, for each directory argument, the number of entries inside it as DIR: N (use ls -A | wc -l after changing into the directory), or DIR: cannot enter if cd fails. Finally it prints back in: followed by the script's current directory.

The starter uses a plain cd, so after the first directory every relative path is wrong and the final directory is not where the script started. Fix it with a subshell so that the script's own directory never changes. With the checker's directories alpha (2 entries) and beta (1 entry), script.sh alpha beta must print exactly:

alpha: 2
beta: 1
back in: /work

File: script.sh

Starting point

for d in "$@"; do
  if cd "$d" 2>/dev/null; then
    echo "$d: $(ls -A | wc -l)"
  else
    echo "$d: cannot enter"
  fi
done
echo "back in: $(pwd)"

Example with arguments alpha beta

expected output

alpha: 2
beta: 1
back in: /work

Example with arguments beta nope alpha

expected output

beta: 1
nope: cannot enter
alpha: 2
back in: /work
Hint 1

Wrap the body of the loop in ( ... ). The cd then happens in a child process.

Hint 2

Each iteration starts a fresh subshell from the unchanged parent, so relative names work again.

Reference solution
for d in "$@"; do
  (
    if cd "$d" 2>/dev/null; then
      echo "$d: $(ls -A | wc -l)"
    else
      echo "$d: cannot enter"
    fi
  )
done
echo "back in: $(pwd)"

Exercise 4 · write code

Redirect the rest of a script

Write a script that:

  1. prints starting to standard output (the terminal);
  2. then, using exec, saves the current stdout as file descriptor 3 and redirects stdout to the file run.log (overwriting it);
  3. prints step 1 and step 2 with two separate echo commands without any redirection on them (they must land in run.log because of step 2);
  4. restores stdout from fd 3 and closes fd 3, also with exec;
  5. prints done, see run.log to standard output.

So the script's visible output is exactly starting and done, see run.log, and run.log contains step 1 and step 2. A test checks the file.

File: script.sh

Starting point

echo starting
# save stdout as fd 3 and point stdout at run.log
echo "step 1"
echo "step 2"
# restore stdout from fd 3 and close fd 3
echo "done, see run.log"

Example

expected output

starting
done, see run.log
Hint 1

exec 3>&1 makes fd 3 a copy of the current stdout; exec > run.log then repoints fd 1. Both can be one line: exec 3>&1 > run.log.

Hint 2

Restoring is the mirror image: exec 1>&3 3>&- (copy 3 back onto 1, then close 3).

Reference solution
echo starting
exec 3>&1 > run.log
echo "step 1"
echo "step 2"
exec 1>&3 3>&-
echo "done, see run.log"