opscape

Bash scripting guide · part 25 of 32

Subshells and command grouping

The difference between ( ... ) and { ...; }, what a subshell inherits and what it cannot change, which constructs create one, and what that costs.

A subshell is a copy of the current shell running in a child process. Bash creates it with fork(), so the child starts with everything the parent had: all variables (exported or not), functions, aliases, options, the current directory and open file descriptors. Traps are the exception: handlers are reset to the default, and only signals set to be ignored stay ignored. Whatever the child changes stays in the child. When it exits, the parent continues exactly as it was.

Two pieces of syntax group commands:

  • ( list ) runs the list in a subshell.
  • { list; } runs the list in the current shell; it only groups.

Changes inside ( ) do not leak out

Variables and the directory are restored

x=1
( x=2; cd /tmp; echo "inside:  x=$x pwd=$PWD" )
echo "outside: x=$x pwd=$PWD"
{ x=3; cd data; }
echo "after { }: x=$x pwd=$PWD"
output
inside:  x=2 pwd=/tmp
outside: x=1 pwd=/home/dev/demo
after { }: x=3 pwd=/home/dev/demo/data

The parentheses changed x and the directory in the child only; the parent still has x=1 and its old directory. The braces ran in the current shell, so their changes stuck.

That isolation is the main reason to use a subshell on purpose: run a few commands in another directory, or with different options, without having to undo anything afterwards.

Work in another directory without cd back

( cd logs && wc -l *.log )
pwd
output
 12 app.log
  2 error.log
 14 total
/home/dev/demo

cd logs applied only inside the parentheses, and && means the wc would not run in the wrong place if the cd failed. The script stays in /home/dev/demo with no cd - needed.

Subshell versus child process

A subshell is not the same as running bash again. The subshell is a fork of the current shell and inherits even unexported variables. A new bash (or any script you start) only receives the environment, which is the exported variables.

What a subshell sees that a new bash does not

secret=42
( echo "subshell sees: [$secret]" )
bash -c 'echo "new bash sees: [$secret]"'
export secret
bash -c 'echo "after export:  [$secret]"'
output
subshell sees: [42]
new bash sees: []
after export:  [42]

The subshell saw the plain shell variable; the separate bash process did not until the variable was exported. See Environment variables.

Telling them apart: $$, BASHPID and BASH_SUBSHELL

$$ is the PID of the main shell and keeps that value in subshells, by design, so that temp-file names built from it stay consistent. $BASHPID is the PID of the process actually running, and $BASH_SUBSHELL counts how deeply nested you are.

The PID variables inside nested subshells

echo "main:   \$\$=$$ BASHPID=$BASHPID level=$BASH_SUBSHELL"
( echo "sub:    \$\$=$$ BASHPID=$BASHPID level=$BASH_SUBSHELL"
  ( echo "subsub: \$\$=$$ BASHPID=$BASHPID level=$BASH_SUBSHELL" ) )
output
main:   $$=5130 BASHPID=5130 level=0
sub:    $$=5130 BASHPID=5131 level=1
subsub: $$=5130 BASHPID=5132 level=2

$$ is the same on every line; BASHPID changes in each new process, and the level goes 0, 1, 2. The PIDs vary from run to run.

exit in a subshell exits only the subshell

exit inside ( ) ends the child, and its status becomes the status of the parentheses. Inside { } it ends the whole script. The same holds for set -e: a failure inside a subshell ends the subshell, and the parent decides what to do with its status.

exit inside ( ) and inside { }

( echo "in subshell"; exit 3 )
echo "subshell returned $?"
{ echo "in group"; exit 4; }
echo "never printed"
outputexit 4
in subshell
subshell returned 3
in group

The subshell's exit 3 only set $? for the parent, which carried on. The braces ran in the main shell, so exit 4 ended the script with status 4. A common idiom relies on this: cmd || { echo "failed" >&2; exit 1; }.

The { } syntax is picky

{ and } are reserved words, not operators like (. They must be separate words, and the closing brace must come where a new command could start, which means after a newline or ;.

Missing space or semicolon

{echo hi}
echo "---"
{ echo a; echo b }
echo "this line is never reached"
outputexit 2
/bin/bash: line 1: {echo: command not found
---
/bin/bash: -c: line 5: syntax error: unexpected end of file

{echo is a single word, so bash looked for a command called {echo and failed. In the second group, } without a preceding ; is just an argument to echo, the group is never closed, and bash hits end of input with a syntax error. Write { echo a; echo b; }.

Grouping for redirection and control flow

Both forms take redirections that apply to everything inside, and both can be the target of &&, || or a pipe. Prefer braces when you do not need isolation: they do not cost a fork.

Redirect a group and handle failure in one place

{ echo "# hosts"; sort data/hosts.txt; } > hosts.sorted
cat hosts.sorted
grep -q db01 data/hosts.txt && { echo "db01 is listed"; echo "checking it next"; }
output
# hosts
cache01 10.0.0.31
db01 10.0.0.21
web01 10.0.0.11
web02 10.0.0.12
db01 is listed
checking it next

The header and the sorted lines land in one file through a single redirection. After &&, the braces make both echo commands conditional; without them only the first would be.

Constructs that create a subshell without parentheses

  • Every command of a pipeline except, with shopt -s lastpipe, the last (Pipes and pipelines).
  • Command substitution $(...) and backticks (Command substitution).
  • Process substitution <(...) and >(...) (Process substitution).
  • A command run in the background with &, including { ...; } &.
  • Coprocesses (coproc).

Everything said above applies to them: a variable assigned inside $(...) or on the right side of a pipe is gone afterwards.

An assignment inside $( ) is lost

count=0
result=$(count=5; echo "computed")
echo "result=$result count=$count"
output
result=computed count=0

The command substitution printed computed, which was captured, but its count=5 happened in a subshell, so the parent's count is still 0. A function that must both set a variable and return text cannot be called as $(func); have it set a global or use printf -v instead (see Functions).

What forking costs

A fork is cheap by process standards but expensive compared to the shell's own work. In a loop that runs thousands of times, replacing $(...) with a builtin or parameter expansion can make a script many times faster.

1000 command substitutions versus none

time for i in $(seq 1000); do x=$(echo "$i"); done
time for i in $(seq 1000); do x=$i; done
output

real	0m0.518s
user	0m0.275s
sys	0m0.303s

real	0m0.002s
user	0m0.001s
sys	0m0.001s

time reports the elapsed (real) and CPU times for each loop. The first forks a subshell on every iteration and takes far longer than the second, which never leaves the current shell. The exact numbers depend on the machine and vary between runs.

Related