Why is there no apparent clone or fork in simple bash command and how it's done?

The sh -c 'command line' are typically used by things like system("command line"), ssh host 'command line', vi's !, cron, and more generally anything that is used to interpret a command line, so it's pretty important to make it as efficient as possible.

Forking is expensive, in CPU time, memory, allocated file descriptors... Having a shell process lying about just waiting for another process before exiting is just a waste of resources. Also, it makes it difficult to correctly report the exit status of the separate process that would execute the command (for instance, when the process is killed).

Many shells will generally try to minimize the number of forks as an optimisation. Even non-optimised shells like bash do it in the sh -c cmd or (cmd in subshell) cases. Contrary to ksh or zsh, it doesn't do it in bash -c 'cmd > redir' or bash -c 'cmd1; cmd2' (same in subshells). ksh93 is the process that goes the furthest in avoiding forks.

There are cases where that optimisation cannot be done, like when doing:

sh < file

Where sh can't skip the fork for the last command, because more text could be appended to the script whilst that command is running. And for non-seekable files, it can't detect the end-of-file as that could mean reading too much too early from the file.

Or:

sh -c 'trap "echo Ouch" INT; cmd'

Where the shell may have to run more commands after the "last" command has been executed.


By digging through bash source code, I was able to figure out that bash in fact will ignore forking if there's no pipes or redirections. From line 1601 in execute_cmd.c:

  /* If this is a simple command, tell execute_disk_command that it
     might be able to get away without forking and simply exec.
     This means things like ( sleep 10 ) will only cause one fork.
     If we're timing the command or inverting its return value, however,
     we cannot do this optimization. */
  if ((user_subshell || user_coproc) && (tcom->type == cm_simple || tcom->type == cm_subshell) &&
      ((tcom->flags & CMD_TIME_PIPELINE) == 0) &&
      ((tcom->flags & CMD_INVERT_RETURN) == 0))
    {
      tcom->flags |= CMD_NO_FORK;
      if (tcom->type == cm_simple)
    tcom->value.Simple->flags |= CMD_NO_FORK;
    }

Later those flags go to execute_disk_command() function, which sets up nofork integer variable, which then later is checked before attempting forking. The actual command itself would be run by execve() wrapper function shell_execve() from either forked or parent process, and in this case it's the actual parent.

The reason for such mechanic is well explained in Stephane's answer.


Side note outside the scope of this question: should be noted that apparently it matters whether the shell is interactive or running via -c. Prior to executing the command there will be a fork. This is evident from running strace on interactive shell (strace -e trace=process -f -o test.trace bash) and checking the output file:

19607 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_t
idptr=0x7f2d35e93a10) = 19628
19607 wait4(-1,  <unfinished ...>
19628 execve("/bin/true", ["/bin/true"], [/* 47 vars */]) = 0

See also Why bash does not spawn a subshell for simple commands?