Unix & Linux
bash shell process subshell
Updated Fri, 20 May 2022 22:09:06 GMT

Example of subshell without child-shell process


I just recently learned that "subshell" is not the same as "child shell process" (see for example What is the exact difference between a "subshell" and a "child process"? and the POSIX definitions of subshell and child process).

To convince myself of this, I am looking for a command that illustrates (proves) that a subshell is created without a child-shell being spawned.

For now, everything I tried seemed to spawn a child-shell whenever a subshell is created:

$ echo $BASHPID; (pwd; cd ..; echo $BASHPID; pwd); pwd      # `( ...)` executed in a subshell
                                                            # and in a child-shell process
$ >&2 ps | ps       # Theoretically executed in two subshells and apparently without child-shells
                    # but I cannot be sure due to the outcome of the next example
$ $ >&2 echo $BASHPID | ps      # `ps` doesn't display a child-shell for the execution of `echo`
953790                          # but `echo $BASHPID` shows a new process that is necessarily
    PID TTY         TIME CMD    # a child-shell since echo is a built-in 
 948538 pts/2   00:00:00 bash
 953791 pts/2   00:00:00 ps

I am looking for a way to demonstrate that having a subshell doesn't necessarily imply having a child-shell...

Bash 5.0.17




Solution

In the bash shell, subshells are implemented by forking a child process, so you won't see a case of a subshell not running in a child process in that shell.

ksh93 is the only shell that I know that skips the forking when possible for subshells (an optimisation that is still quite buggy and that the successive people that have tried to maintain it after AT&T disbanded the team that had written it have considered removing).

If you do for instance:

 strace ksh93 -c 'pwd; (cd /; umask 0; pwd; exit 2); pwd'

You'll see ksh93 not forking any process but do something like this instead:

openat(AT_FDCWD, ".", O_RDONLY|O_PATH)  = 3
fcntl(3, F_DUPFD, 10)                   = 10
close(3)                                = 0
fcntl(10, F_SETFD, FD_CLOEXEC)          = 0
[...]

Which saves the current directory on fd 10. Then:

chdir("/")                              = 0
umask(000)                              = 002

Which changes the current directory and umask in the subshell. And upon termination of the subshell (the exit 2 not calling the _exit() system call):

fchdir(10)                              = 0
close(10)                               = 0

To restore the current working directory and:

umask(002)                              = 000

To restore the umask.

Some shells like FreeBSD's sh can skip the fork in very specific cases, like in:

var=$(printf %04d "$n")

(here with a printf builtin, and no change to the environment is being done in there).

In a pipeline, all components have to run concurrently, so they have to run in separate processes, even in ksh93.

In bash, they all run in child processes. In AT&T ksh or zsh, or with bash -O lastpipe (when non-interactive), the rightmost one doesn't (of course, you still need to fork a child process to run external commands such as ps).

You don't see an extra bash process in ps >&2 | ps or (ps) because ps is executed directly in that child process, which before executing ps was bash interpreting the pipeline component: the subshell. For instance, in:

n=0; /bin/true "$((n=1))" | /bin/echo "$((n=2))"; echo "$n"

You'll see 2 and 0 in bash, and 2 and 2 in zsh/ksh93. /bin/true and /bin/echo are executed in child processes, /bin/true directly in the subshell process that had done n=1 earlier, same in bash for /bin/echo (and n=2), but in zsh/ksh/bash -O lastpipe, the n=2 was done in the main shell process, and a child only forked to execute that external utility, just like when you run /bin/echo "$((n=2))" not as part of a pipeline.

In bash (contrary to zsh/ksh), you do see an extra bash process in (: anything; ps), the optimisation is only done if the subshell has only one external command, you'd need to use exec to do that optimisation by hand there: (: anything; exec ps).

Same goes for { ps; } | cat.





Comments (2)

  • +0echo "$$" did you mean echo "$n"? In bash -0 lastpipe I still see 2 ad 0 (assuming echo "$n"), even when using echo instead of/bin/echo. But also in man bash it only says that "[with lastpipe] the last element of a pipeline may be run by the shell process", so it could also be expected to have the same outcome here even with lastpipe. The effect of exec is quite illustrative I find. — Nov 12, 2021 at 22:28  
  • +0 – Yes, sorry, was meant to be $n. Edited now. — Nov 12, 2021 at 22:29