你用sh -c 'command'时踩过坑吗?

301 阅读13分钟

发现问题

一个小问题:“怎么让一个 shell 执行一行命令输出它自己是什么 shell?”

一般通过sh来用 shell,而且sh是个符号链接,那读出它的源文件路径不就知道是什么 shell 了?咱找到了一条相关的命令:

root@ubuntu:~# sh -c 'readlink /proc/$$/exe'
/usr/bin/readlink

输出结果和想的不一样,为什么是readlink的文件位置?先查一下sh

root@ubuntu:~# readlink $(which sh)
bash

这个默认 shell 被改成 BASH 了。一般 Ubuntu 上有两个 shell,BASH 和 Dash,它们都当过系统默认sh。最开始是 BASH,后来是 Dash。这俩对比一下:

# BASH
root@ubuntu:~# bash -c 'readlink /proc/$$/exe'
/usr/bin/readlink

# Dash
root@ubuntu:~# dash -c 'readlink /proc/$$/exe'
/usr/bin/dash

薛定谔的 shell 吗这是?一种代码,两种结果。

再想一下命令,$$是特殊变量,是当前 shell 的 PID,/proc/$$/exe是当前 shell 的可执行文件路径,这行代码的效果应该是读 BASH 的可执行文件路径吧???

无效试探

试着加上ps -f看看进程:

# 前边的历史
root@ubuntu:~# bash -c 'readlink /proc/$$/exe'
/usr/bin/readlink

# 当前 shell 的 PID
root@ubuntu:~# echo $$
564505

# 现在的尝试
root@ubuntu:~# bash -c 'echo $$; ps -f; readlink /proc/$$/exe; ps -f'
577450
UID          PID    PPID  C STIME TTY          TIME CMD
root      564505  564399  0 17:07 pts/0    00:00:00 -bash
root      577450  564505  0 18:16 pts/0    00:00:00 bash -c echo $$; ps -f; readlink /proc/$$/exe; ps -f
root      577451  577450  0 18:16 pts/0    00:00:00 ps -f
/usr/bin/bash
UID          PID    PPID  C STIME TTY          TIME CMD
root      564505  564399  0 17:07 pts/0    00:00:00 -bash
root      577450  564505  0 18:16 pts/0    00:00:00 bash -c echo $$; ps -f; readlink /proc/$$/exe; ps -f
root      577453  577450  0 18:16 pts/0    00:00:00 ps -f

沃的天啊,它对了,它输出了/usr/bin/bash

再理解下命令的意思:

第一个echo $$拿到当前交互式 shell 的 PID,是 564505,后面用ps会看到两条bash的进程,这样可以区分开了。

当前交互式 shell(PID: 564505)执行bash -c 'echo $$; ps -f; readlink /proc/$$/exe; ps -f'时,开了一个非交互式 shell(PID: 577450),把-c之后单引号之间的内容交给它来执行。

这个非交互式 shell(PID: 577450) 开了三个子进程,第一个是ps(PID: 577451)、第二个是readlink(PID: 577452),第三个是另一个ps(PID: 577453)。命令里两个ps夹着readlink,主要是想看看readlink执行的前后。

虽然前后的ps都没看到readlink信息,但看输出知道readlink读了所在 shell(PID: 577450) 的可执行文件路径后就退出了,两个ps的 PID 之间缺的那个 577452 就是它的 PID。

上节知道readlink它不对,现在知道了有时对有时不对。它为什么这么精分呢?把命令简化一下:

root@ubuntu:~# bash -c 'echo $$; readlink /proc/$$/exe'
772062
/usr/bin/bash
root@ubuntu:~# bash -c '; readlink /proc/$$/exe'
bash: -c: line 0: syntax error near unexpected token `;'
bash: -c: line 0: `; readlink /proc/$$/exe'

发现这里有个;它就对了,这啥?这是 BASH 的命令列表啊,而上节没;的是简单命令!可能接近真相了,可当咱换了一台机器:

root@ubuntu-2:~# bash -c 'echo $$; readlink /proc/$$/exe'
12405
/usr/bin/readlink
root@ubuntu-2:~# bash -c '; readlink /proc/$$/exe'
bash: -c: line 1: syntax error near unexpected token `;'
bash: -c: line 1: `; readlink /proc/$$/exe'

看来不是非简单命令就能正常。查了一下版本:

root@ubuntu:~# bash --version
GNU bash, version 5.0.17(1)-release (x86_64-pc-linux-gnu)
...

root@ubuntu-2:~# bash --version
GNU bash, version 5.1.0(1)-release (aarch64-unknown-linux-gnu)
...

还和版本有关系。这里面有什么规则呢?

有效努力

粗略地看了看 BASH 5.1 的源码,发现了一些有意思的东西。 BASH 源码不是本文的重点,所以省略了许多细节,只贴出主流程的简化代码:

// 文件:config-top.h

// ...
/* Define ONESHOT if you want sh -c 'command' to avoid forking to execute
   `command' whenever possible.  This is a big efficiency improvement. */
#define ONESHOT
// ...

上面是ONESHOT的定义,这个宏的定义和解除定义,控制着 BASH fork优化的开和关。

// 文件:shell.c

// ...
int
main (argc, argv, env)
     int argc;
     char **argv, **env;
// ...
{
// ...
  if (command_execution_string)
    {
      startup_state = 2;
// ...
#if defined (ONESHOT)
      executing = 1;
      run_one_command (command_execution_string);
      exit_shell (last_command_exit_value);
#else /* ONESHOT */
      with_input_from_string (command_execution_string, "-c");
      goto read_and_execute;
#endif /* !ONESHOT */
    }
// ...
#if !defined (ONESHOT)
 read_and_execute:
#endif /* !ONESHOT */
// ...
  reader_loop ();
  exit_shell (last_command_exit_value);
}
// ...
#if defined (ONESHOT)
// ...
static int
run_one_command (command)
     char *command;
{
// ...
   return (parse_and_execute (savestring (command), "-c", SEVAL_NOHIST|SEVAL_RESETLINE));
}
#endif /* ONESHOT */
// ...

上面是用bash -c 'command'这种方式执行时的主要流程。当 BASH 解析完了命令行参数,指针command_execution_string就指向'command',如果 ONESHOT 是关的,那跟bash 'filename'一样用reader_loop逐行读取和执行,不然就用parse_and_execute解析和执行'command'的内容。

// 文件:evalstring.c

// ...
int
parse_and_execute (string, from_file, flags)
     char *string;
     const char *from_file;
     int flags;
{
// ...
#if defined (ONESHOT)
// ...
	      if (should_suppress_fork (command))
		{
		  command->flags |= CMD_NO_FORK;
		  command->value.Simple->flags |= CMD_NO_FORK;
		}
// ...
	      else if (command->type == cm_connection && can_optimize_connection (command))
		{
		  command->value.Connection->second->flags |= CMD_TRY_OPTIMIZING;
		  command->value.Connection->second->value.Simple->flags |= CMD_TRY_OPTIMIZING;
		}
#endif /* ONESHOT */
// ...
}
// ...

上面是 BASH 把命令字符串解析成command结构体后,用should_suppress_fork函数判断做不做fork优化,如果能做,就打上CMD_NO_FORK标志,如果不能做,else if分支还给一次机会。如果是命令列表且can_optimize_connection函数判断能做,那打上CMD_TRY_OPTIMIZING标志,当执行到命令列表的最后一个,会继续用should_suppress_fork函数判断做不做fork优化,如果能做,也打上CMD_NO_FORK标志。

// 文件:execute_cmd.c

// ...
static int
execute_connection (command, asynchronous, pipe_in, pipe_out, fds_to_close)
     COMMAND *command;
     int asynchronous, pipe_in, pipe_out;
     struct fd_bitmap *fds_to_close;
{
// ...
  switch (command->value.Connection->connector)
    {
// ...
    case ';':
// ...
      optimize_fork (command);
      exec_result = execute_command_internal (command->value.Connection->second,
				      asynchronous, pipe_in, pipe_out,
				      fds_to_close);
      executing_list--;
      break;
// ...
    case AND_AND:
    case OR_OR:
// ...
      if (((command->value.Connection->connector == AND_AND) &&
	   (exec_result == EXECUTION_SUCCESS)) ||
	  ((command->value.Connection->connector == OR_OR) &&
	   (exec_result != EXECUTION_SUCCESS)))
	{
	  optimize_fork (command);

	  second = command->value.Connection->second;
	  if (ignore_return && second)
	    second->flags |= CMD_IGNORE_RETURN;

	  exec_result = execute_command (second);
	}
      executing_list--;
      break;
// ...
    }
// ...
}
// ...

中间进了execute_command_internal函数,然后看命令的类型执行execute_simple_command, execute_connection之类的函数,过程中的代码比较多所以不贴了。要注意的是上面的execute_connection函数,connection右边的命令总有被optimize_fork决定做不做优化的机会,方法和can_optimize_connection差不多。

现在快进到执行外部命令的execute_disk_command函数:

// 文件:execute_cmd.c

// ...
static int
execute_disk_command (words, redirects, command_line, pipe_in, pipe_out,
		      async, fds_to_close, cmdflags)
     WORD_LIST *words;
     REDIRECT *redirects;
     char *command_line;
     int pipe_in, pipe_out, async;
     struct fd_bitmap *fds_to_close;
     int cmdflags;
{
// ...
  nofork = (cmdflags & CMD_NO_FORK);	/* Don't fork, just exec, if no pipes */
// ...
  if (nofork && pipe_in == NO_PIPE && pipe_out == NO_PIPE)
    pid = 0;
  else
    {
      fork_flags = async ? FORK_ASYNC : 0;
      pid = make_child (p = savestring (command_line), fork_flags);
    }

  if (pid == 0)
    {
// ...
      args = strvec_from_word_list (words, 0, 0, (int *)NULL);
      exit (shell_execve (command, args, export_env));
    }
// ...
}
// ...

上面表示 BASH 执行命令时会根据解析时有没打上CMD_NO_FORK标志,来决定调用shell_execve还是make_child。其中shell_execvemake_child的关键就是execvefork

到这已经搞清楚了关键的东西:

  • readlink的输出它奇怪,是因为 ONESHOT 优化,是因为直接execve
  • ONESHOT 优化的规则是由should_suppress_fork函数决定;如果是connection类型的命令串,再加上can_optimize_connectionoptimize_fork函数。
  • ONESHOT 优化的目的是为了提高效率。

fork/exec

经过有效努力,搞清楚了这问题由bash的优化引起。对于bash -c 'readlink /proc/$$/exe'bash进程在解析完成后,调用execve替换进程,成功后当前进程不再是bash,而是readlink,进程名和可执行文件路径也分别改成了readlink/usr/bin/readlink,然而execve不改变进程号,$$的值依然有效并指代当前进程,所以效果就是开头的那样。

上面提到的forkexecve是什么呢?execveexec函数族的一员,和fork一样,都是 POSIX 中的标准 API,在 UNIX 和 Linux 中完成替换和创建进程的工作。

// fork 函数,复制当前进程
pid_t fork (void);

// exec 函数族,替换当前进程
int execve(const char *path, char *const argv[], char *const envp[]);
int execv (const char *path, char *const argv[]);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execl (const char *path, const char *arg, ...);
int execvp(const char *file, char *const argv[]);
int execlp(const char *file, const char *arg, ...);
#ifdef USE_GNU
int execvpe(const char *file, char *const argv[],char *const envp[]);
#endif

一条进程要执行其它程序,一般是先调用fork复制一份进程作为子进程,然后在子进程里调用exec完成映像覆盖。

下边是fork的例子:

#include <stddef.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

/* Execute the command using this shell program.  */
#define SHELL "/bin/sh"

int
my_system (const char *command)
{
  int status;
  pid_t pid;

  pid = fork ();
  if (pid == 0)
    {
      /* This is the child process.  Execute the shell command. */
      execl (SHELL, SHELL, "-c", command, NULL);
      _exit (EXIT_FAILURE);
    }
  else if (pid < 0)
    /* The fork failed.  Report failure.  */
    status = -1;
  else
    /* This is the parent process.  Wait for the child to complete.  */
    if (waitpid (pid, &status, 0) != pid)
      status = -1;
  return status;
}

比较有趣的是:

  1. 一般fork函数一次调用两次返回。fork成功调用后,复制了一个跟原来一样的进程,执行位置都在fork这行。原进程中的fork会返回新进程的 PID,而新进程中的fork会返回 0,这样就能区分父子进程了。
  2. 一般exec函数族一次调用零次返回。exec函数族之一成功调用后,新的映像替换了旧的映像,CPU 的执行地址已经跳到新的程序入口点,自然没有返回值了。但调用后进程的其它属性并没有改变,如 PID、PPID、优先级以及所属的用户组等。

如果接触过 Windows API 的CreateProcess函数,会发现它一步到位地创建进程并加载映像,达到了forkexec的效果。

BOOL CreateProcessA(
  [in, optional]      LPCSTR                lpApplicationName,
  [in, out, optional] LPSTR                 lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCSTR                lpCurrentDirectory,
  [in]                LPSTARTUPINFOA        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);

下边是用CreateProcess的例子:

#include <windows.h>
#include <stdio.h>
#include <tchar.h>

void _tmain( int argc, TCHAR *argv[] )
{
    STARTUPINFO si;
    PROCESS_INFORMATION pi;
    
    ZeroMemory( &si, sizeof(si) );
    si.cb = sizeof(si);
    ZeroMemory( &pi, sizeof(pi) );
    
    if( argc != 2 )
    {
        printf("Usage: %s [cmdline]\n", argv[0]);
        return;
    }
    
    // Start the child process. 
    if( !CreateProcess( NULL, argv[1], NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi) )
    {
        printf( "CreateProcess failed (%d).\n", GetLastError() );
        return;
    }
    
    // Wait until child process exits.
    WaitForSingleObject( pi.hProcess, INFINITE );
    
    // Close process and thread handles. 
    CloseHandle( pi.hProcess );
    CloseHandle( pi.hThread );
}

为什么 Linux 不设计成CreateProcess这样的更符合直觉的函数呢?主要是历史原因。

最早的 UNIX 出现时,没有多进程的概念,要执行其它程序怎么办呢?用不了子进程,但可以把当前映像换成其它程序呀,于是这种方法出现了:如果整个系统只能运行一个映像,现在已经运行了一条 shell,突然要执行其它程序,就加载这个程序到内存中,下个执行位置设成这个程序的程序入口,这样就开始执行这个程序了,只是 shell 自身没了,等于是用其它程序覆盖掉自身,这就是exec的思想。等这个程序完了重复之前流程,把 shell 加载进来覆盖这个程序。

覆盖意味着 shell 每次都重新初始化,一些资源既不能保留到下次也不能和其它程序共享,怎么办呢?能不能先存起来再覆盖?于是这种方法出现了:在 shell 被覆盖之前,先对 shell 执行复制操作得到 shell 复制品,然后把 shell 复制品存到磁盘上,再用其它程序覆盖 shell,等这个程序执行完了,就加载磁盘上的 shell 复制品再次覆盖这个程序,这样 shell 避免了重复初始化,还能以之前的状态继续运行,这就是fork的思想。

后面的 Linux 学了 UNIX 的不少东西,把forkexec也学过来了。

优化规则

在文章的开头,咱摸出了一些简单的规则,可是不仅不全,还在不同版本下有区别。问题来了,ONESHOT 优化在什么情况下被使用呢?有什么规律呢?

0 关键函数

BASH 版本太多了,下边只拿几个常见的版本来研究一下规律和变化:

BASH 4.4 (取自 bash-4.4.tar.gz):

int
should_suppress_fork (command)
     COMMAND *command;
{
  return (startup_state == 2 && parse_and_execute_level == 1 &&
	  running_trap == 0 &&
	  *bash_input.location.string == '\0' &&
	  command->type == cm_simple &&
#if 0
	  signal_is_trapped (EXIT_TRAP) == 0 &&
	  signal_is_trapped (ERROR_TRAP) == 0 &&
#else
	  any_signals_trapped () < 0 &&
#endif
	  command->redirects == 0 && command->value.Simple->redirects == 0 &&
	  ((command->flags & CMD_TIME_PIPELINE) == 0) &&
	  ((command->flags & CMD_INVERT_RETURN) == 0));
}

void
optimize_fork (command)
     COMMAND *command;
{
  if (command->type == cm_connection &&
      (command->value.Connection->connector == AND_AND || command->value.Connection->connector == OR_OR) &&
      should_suppress_fork (command->value.Connection->second))
    {
      command->value.Connection->second->flags |= CMD_NO_FORK;
      command->value.Connection->second->value.Simple->flags |= CMD_NO_FORK;
    }
}

BASH 5.0 (取自 bash-5.0.tar.gz):

int
should_suppress_fork (command)
     COMMAND *command;
{
  return (startup_state == 2 && parse_and_execute_level == 1 &&
	  running_trap == 0 &&
	  *bash_input.location.string == '\0' &&
	  command->type == cm_simple &&
	  signal_is_trapped (EXIT_TRAP) == 0 &&
	  signal_is_trapped (ERROR_TRAP) == 0 &&
	  any_signals_trapped () < 0 &&
	  command->redirects == 0 && command->value.Simple->redirects == 0 &&
	  ((command->flags & CMD_TIME_PIPELINE) == 0) &&
	  ((command->flags & CMD_INVERT_RETURN) == 0));
}

void
optimize_fork (command)
     COMMAND *command;
{
  if (command->type == cm_connection &&
      (command->value.Connection->connector == AND_AND || command->value.Connection->connector == OR_OR) &&
      should_suppress_fork (command->value.Connection->second))
    {
      command->value.Connection->second->flags |= CMD_NO_FORK;
      command->value.Connection->second->value.Simple->flags |= CMD_NO_FORK;
    }
}

BASH 5.1 (取自 bash-5.1.tar.gz):

int
should_suppress_fork (command)
     COMMAND *command;
{
#if 0 /* TAG: bash-5.2 */
  int subshell;

  subshell = subshell_environment & SUBSHELL_PROCSUB;	/* salt to taste */
#endif
  return (startup_state == 2 && parse_and_execute_level == 1 &&
	  running_trap == 0 &&
	  *bash_input.location.string == '\0' &&
	  parser_expanding_alias () == 0 &&
	  command->type == cm_simple &&
	  signal_is_trapped (EXIT_TRAP) == 0 &&
	  signal_is_trapped (ERROR_TRAP) == 0 &&
	  any_signals_trapped () < 0 &&
#if 0 /* TAG: bash-5.2 */
	  (subshell || (command->redirects == 0 && command->value.Simple->redirects == 0)) &&
#else
	  command->redirects == 0 && command->value.Simple->redirects == 0 &&
#endif
	  ((command->flags & CMD_TIME_PIPELINE) == 0) &&
	  ((command->flags & CMD_INVERT_RETURN) == 0));
}

int
can_optimize_connection (command)
     COMMAND *command;
{
  return (*bash_input.location.string == '\0' &&
	  parser_expanding_alias () == 0 &&
	  (command->value.Connection->connector == AND_AND || command->value.Connection->connector == OR_OR || command->value.Connection->connector == ';') &&
	  command->value.Connection->second->type == cm_simple);
}

void
optimize_fork (command)
     COMMAND *command;
{
  if (command->type == cm_connection &&
      (command->value.Connection->connector == AND_AND || command->value.Connection->connector == OR_OR || command->value.Connection->connector == ';') &&
      (command->value.Connection->second->flags & CMD_TRY_OPTIMIZING) &&
      should_suppress_fork (command->value.Connection->second))
    {
      command->value.Connection->second->flags |= CMD_NO_FORK;
      command->value.Connection->second->value.Simple->flags |= CMD_NO_FORK;
    }
}

简单地列出来是:

  1. bash -c 'command'的方式执行命令
  2. parse_and_execute没有递归调用
  3. 不在trap handler 之中
  4. 已经解析到末尾
  5. 没有进行别名拓展
  6. 必须是简单命令
  7. 没有 EXIT trap
  8. 没有 ERR trap
  9. 没有系统信号的 trap
  10. 没有重定向
  11. 没有 time
  12. 没有 !
  13. 特殊的列表命令

下边展开来说,注意所有的例子都在 BASH 5.1 上运行。

1. 用bash -c 'command'的方式执行命令

对应的是startup_state == 2

启动的 BASH 有四种模式:

  • 命令内容从文件里读取(非交互式)
  • 命令内容从键盘上输入(交互式)
  • 命令内容被-c command指定
  • 输入内容用作wordexp计算(一般 BASH 不把wordexp内置,忽略)

虽然 BASH 执行命令内容有好几种方式,但只有bash -c 'command'这种会被优化。

# 从文件
root@ubuntu:~# echo 'readlink /proc/$$/exe' > test.sh
root@ubuntu:~# bash test.sh
/usr/local/bin/bash
# 从管道
root@ubuntu:~# echo 'readlink /proc/$$/exe' | bash
/usr/local/bin/bash
# bash -c 'command'
root@ubuntu:~# bash -c 'readlink /proc/$$/exe'
/usr/bin/readlink

2. parse_and_execute没有递归调用

对应的是parse_and_execute_level == 1

这一节挺有意思的。回想 shell 的执行流程:

  1. 读取输入
  2. 将输入分解为wordoperator两种token
  3. token解析为简单和复合命令
  4. 处理各种扩展
  5. 处理各种重定向
  6. 执行命令
  7. 等待命令完成并收集退出状态(可选)

前边已经知道,对于bash -c 'command'parse_and_execute解析和执行command的内容。等到执行command的时候,步骤已经来到 shell 执行流程的第 6 步了,下一步就是等命令完成然后收集状态了,有没有什么办法在没完成的时候,重新回到开头,比如第 1 步或第 2 步,套娃一波呢?

下边介绍一个有趣的内置命令:

eval [arguments]

evalarguments连起来形成一个命令,然后读取并执行它,它的退出状态就是eval的退出状态。

对于 BASH 这种解释型语言来说,eval和普通的代码用同样的解释器,而且eval要支持跟解释器同样的功能,显然它很适合用递归的方式去实现。

因为 BASH 的输入可以是文件也可以是-c指定,所以eval的实现有点不一样,简单地讲:

  • 如果输入是文件,那就间接地递归调用execute_command_internal
  • 如果输入是-c指定, 那就间接地递归调用parse_and_execute

因为eval会让parse_and_execute递归调用,所以要是有eval,BASH 不会优化fork

root@ubuntu:~# bash -c 'eval readlink /proc/$$/exe'
/usr/local/bin/bash

3. 不在trap handler 之中

对应的是running_trap == 0

trap命令是用来捕获信号和事件的,用法是trap [-lp] [arg] [sigspec …],效果是 shell 收到sigspec ...就会执行arg

  • sigspec可以是伪信号或信号;可以是名称或数字;如果是信号名称,带或不带SIG前缀都行
  • argtrap handler,可以是命令或函数;-表示使用默认 handler, ""表示忽略信号或事件

trap命令在后边举了EXIT, ERR, INT三个例子,这里不多写了。

如果命令放在trap handler 里,那就不会优化fork,例如:

root@ubuntu:~# bash -c 'trap "readlink /proc/$$/exe" exit'
/usr/local/bin/bash

4. 已经解析到末尾

对应的是*bash_input.location.string == '\0'

换行符是 BASH 里的控制操作符之一,在代码里有分割命令的作用,到运行时表现就是逐行解析和执行了。

来看几个例子:

  1. echo $$,解析一次,第一次拿到echo $$转的Command结构体
  2. echo $$\n,解析一次,第一次拿到echo $$转的Command结构体
  3. echo $$\n\n,解析两次。第一次拿到echo $$转的Command结构体,第二次拿到NULL

第 3 个例子有两个连着的\n,可以发现 BASH 没有消掉它们,而是从\n夹起来的空串拿到NULL,所以执行echo $$的时候,整体还没解析完。

如果还没结束解析,BASH 不会优化fork。很自然地,造出了下边这个例子:

root@ubuntu:~# bash -c 'readlink /proc/$$/exe
>
>'
/usr/local/bin/bash

也可以写成bash -c $'readlink /proc/$$/exe\n\n'

5. 没有进行别名拓展

对应的是parser_expanding_alias () == 0

这个咱不理解,想不到例子,因为别名拓展和should_suppress_fork两个不在同一个阶段,前一个在词法解析时,后一个在执行命令时。坐等路过的大佬讲解。

6. 必须是简单命令

对应的是command->type == cm_simple

简单命令应该是 BASH 里用得最勤的类型吧,形式就像command_name arg1 arg2 ...这样,一般第一个单词是要执行的命令名,剩下的都是命令参数,之间用空格分开,不带控制操作符。

不是简单命令不优化fork,例如:

root@ubuntu:~# bash -c 'readlink /proc/$$/exe | cat'
/usr/local/bin/bash

7. 没有 EXIT trap

对应的是signal_is_trapped (EXIT_TRAP) == 0

在展开之前,先提下 BASH 的伪信号(或者说特殊事件)。被叫做“伪信号”是因为这些信号是由 BASH 产生的,而其它的信号是由操作系统产生的。

BASH 有四种伪信号EXITERRORDEBUGRETURN,简单地讲:

  • EXIT,从一个函数中退出或整个脚本执行完毕;
  • ERR,当一条命令返回非零状态时;
  • DEBUG,脚本中每一条命令执行之前;
  • RETURN,函数或source返回时。

下边是一个EXIT伪信号的使用例子:

#!/bin/bash

LOCKFILE=/var/lock/makewhatis.lock

# Previous makewhatis should execute successfully:

[ -f $LOCKFILE ] && exit 0

# Upon exit, remove lockfile.

trap "{ rm -f $LOCKFILE ; exit 255; }" EXIT

touch $LOCKFILE
makewhatis
exit 0

如果处理了EXIT伪信号,不会优化fork。例如:

root@ubuntu:~# bash -c 'trap true EXIT; readlink /proc/$$/exe'
/usr/local/bin/bash

8. 没有 ERR trap

对应的是signal_is_trapped (ERROR_TRAP) == 0

下边是一个使用ERR伪信号的例子:

error() {
  local parent_lineno="$1"
  local message="$2"
  local code="${3:-1}"

  if [[ -n "$message" ]] ; then
    echo "Error on or near line ${parent_lineno}: ${message}; exiting with status ${code}"
  else
    echo "Error on or near line ${parent_lineno}; exiting with status ${code}"
  fi

  exit "${code}"
}

trap 'error ${LINENO}' ERR

badcommand

如果处理了ERR伪信号,不会优化fork。例如:

root@ubuntu:~# bash -c 'trap true ERR; readlink /proc/$$/exe'
/usr/local/bin/bash

到这里,伪信号已经写一半了,剩下的DEBUGRETURN,处理了会不会优化fork呢?

root@ubuntu:~# bash -c 'trap true DEBUG; readlink /proc/$$/exe'
/usr/bin/readlink
root@ubuntu:~# bash -c 'trap true RETURN; readlink /proc/$$/exe'
/usr/bin/readlink

显然是会的。

9. 没有系统信号的 trap

对应的是any_signals_trapped () < 0

这里的信号不是上边提过的由 shell 支持的四种伪信号,而是由 Linux 支持的系统信号,它是进程间通讯的一种方式。一个信号来了,就表示一个事件发生了,如果进程注册了这个信号的处理函数,就执行注册的处理函数,不然就执行默认的处理函数。系统里支持的信号可以用kill -l列出来,Linux 系统有下边这些:

 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

下边是一个使用 SIGINT 信号的例子:

#!/bin/bash

trap ctrl_c INT

function ctrl_c() {
    echo "** Trapped CTRL-C"
}

for i in {1..10}; do
    sleep 1
    echo -n "."
done

如果处理了上面列出的系统信号,BASH 也不会优化fork的,例如:

root@ubuntu:~# bash -c 'trap true USR1; readlink /proc/$$/exe'
/usr/local/bin/bash

10. 没有重定向

对应的是command->redirects == 0 && command->value.Simple->redirects == 0

重定向可以改变 BASH 命令的输入和输出。BASH 的重定向有下边这些:

  • [n]<word,输入重定向
  • [n]>[|]word,输出重定向
  • [n]>>word,追加输出重定向
  • &>word,等于>word 2>&1,stdin 和 stderr 重定向
  • &>>word,等于>>word 2>&1,stdin 和 stderr 追加重定向
  • [n]<<[-]word,here-documents
  • [n]<<< word,here-strings
  • [n]<&word[n]>&word,复制输入和输出文件句柄
  • [n]<&digit-[n]>&digit-,移动输入和输出文件句柄
  • [n]<>word,以文件句柄的方式打开文件

举一个重定向的例子:

ex output.txt <<'x23LimitStringx23'
a
This is line 1 of the example file.
This is line 2 of the example file.
.
wq
x23LimitStringx23

如果有重定向,BASH 不会优化fork。例如:

root@ubuntu:~# bash -c 'readlink /proc/$$/exe 2>&1'
/usr/local/bin/bash

11. 没有 time

对应的是((command->flags & CMD_TIME_PIPELINE) == 0)

BASH 管道的用法是[time [-p]] [!] command1 [ | or |& command2 ] …,所以关键字time算是 BASH 管道的一部分(time command这种形式算是管道的特例),它能在管道完成之后输出命令的执行时间。

time输出三个统计时间:

  • real是开始到结束花费的 CPU 总时间,可能大于user + sys
  • user是用户态花的 CPU 时间
  • sys是内核态花的 CPU 时间

如果有关键字time,BASH 不会优化fork,例如:

root@ubuntu:~# bash -c 'time readlink /proc/$$/exe'
/usr/local/bin/bash

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

要是把关键字time改成命令time呢?答案是会的。

root@ubuntu:~# bash -c '\time readlink /proc/$$/exe'
/usr/bin/time
0.00user 0.00system 0:00.00elapsed ?%CPU (0avgtext+0avgdata 1976maxresident)k
0inputs+0outputs (0major+133minor)pagefaults 0swaps

12. 没有 !

对应的是((command->flags & CMD_INVERT_RETURN) == 0))

从上一节可以知道,关键字!也是 BASH 管道的一部分(! command这种形式也是管道的特例),它在逻辑上否定管道的退出状态。

  • 如果管道的退出码是非零值(失败状态),用!否定后,退出码是零值(成功状态)
  • 如果管道的退出码是零值(成功状态),用!否定后,退出码是非零值 1(失败状态)

如果有关键字!,BASH 不会优化fork,例如:

root@ubuntu:~# bash -c '! readlink /proc/$$/exe'
/usr/local/bin/bash

13. 特殊的列表命令

对应的是optimize_fork (command)

它的意思是满足下两个条件的话,末尾的命令会做fork优化:

  • 是由指定的连接符号连接起来的命令列表
  • 命令列表的末尾命令满足前边提到的 12 个条件

在 BASH 4.4 和 5.0 上,这些连接符号是&&||,在 BASH 5.1 上,这些连接符号&&||;

这正好解释了开头的echo $$; readlink /proc/$$/exe的运行结果为什么在 BASH 5.0 和 5.1 上不一样的疑惑。

在 BASH 5.0 上:

root@ubuntu:~# bash -c 'echo $$; readlink /proc/$$/exe'
772062
/usr/bin/bash

在 BASH 5.1 上:

root@ubuntu-2:~# bash -c 'echo $$; readlink /proc/$$/exe'
12405
/usr/bin/readlink

优化效果

按上边的说法,这个特性能提高效率,那提高了什么,又提高了多少?

先来准备一个测试的环境。

为了方便,用了 Ubuntu 20.04 的 Docker 镜像:

docker run -it --rm --name BASH ubuntu:20.04 bash

准备编译环境:

apt update
apt -y install build-essential
apt -y install automake
apt -y install wget

下载源码解压:

wget "https://mirrors.aliyun.com/gnu/bash/bash-5.1.16.tar.gz"
tar xf "bash-5.1.16.tar.gz"

先编个有 ONESHOT 的 BASH:

pushd "bash-5.1.16"
sed -i 's!^//\s*#define ONESHOT!#define ONESHOT!g' config-top.h
./configure --prefix "build/oneshot"
make
make install-strip
popd

再编个没 ONESHOT 的 BASH:

pushd "bash-5.1.16"
sed -i 's!^#define ONESHOT!// #define ONESHOT!g' config-top.h
./configure --prefix "build/no-oneshot"
make
make install-strip
popd

走完上边几步,就拿到了两个版本的 BASH,一个有 ONESHOT 优化,一个没 ONESHOT 优化,除了这点,别的都一样。写一个简单的 native 程序:

#include <stdio.h>
#include <string.h>
#include <unistd.h>

void test1() {
    printf("do nothing\n");
}

void test2() {
    printf("sleep 24h\n");
    sleep(24 * 60 * 60);
}

int main(int argc, char *argv[]) {
    if (argc < 2) {
        return 1;
    }
    if (strcmp("1", argv[1]) == 0) {
        test1();
    }
    else if (strcmp("2", argv[1]) == 0) {
        test2();
    }
    return 0;
}

用下边的命令编译:

make test LDFLAGS+=-s

之后跑了这样的代码测试:

function test_running_speed {
    local version=${1:-"5.1.16"}; shift
    local count=${1:-10}; shift
    local dirname="bash-$version"

    pushd "$dirname"
    echo "ONESHOT"
    local exepath="build/$dirname/bin/bash"
    time for (( i=0; i<count; i++ )); do
        $exepath -c '../test 1' > /dev/null
    done

    echo "NO ONESHOT"
    local exepath="build/$dirname-no-oneshot/bin/bash"
    time for (( i=0; i<count; i++ )); do
        $exepath -c '../test 1' > /dev/null
    done
    popd
}

function test_memory_usage {
    local version=${1:-"5.1.16"}; shift
    local count=${1:-100}; shift
    local dirname="bash-$version"

    pushd "$dirname"
    echo "ONESHOT"
    free -m
    local exepath="build/$dirname/bin/bash"
    for (( i=0; i<count; i++ )); do
        $exepath -c '../test 2' > /dev/null &
    done
    sleep 30
    free -m
    pkill -f '[/]test'

    echo "NO ONESHOT"
    free -m
    local exepath="build/$dirname-no-oneshot/bin/bash"
    for (( i=0; i<count; i++ )); do
        $exepath -c '../test 2' > /dev/null &
    done
    sleep 30
    free -m
    pkill -f '[/]test'
    popd
}

test_running_speed 5.1.16 1000000
test_memory_usage 5.1.16 100

结果放在下边:

RUNNING SPEED
~/bash-5.1.16 ~
ONESHOT

real    15m15.430s
user    5m54.525s
sys     10m7.360s
NO ONESHOT

real    24m42.291s
user    10m19.114s
sys     11m21.529s
~
MEMORY USAGE
~/bash-5.1.16 ~
ONESHOT
              total        used        free      shared  buff/cache   available
Mem:           1988         300         571         394        1116        1216
Swap:          1023           0        1023
              total        used        free      shared  buff/cache   available
Mem:           1988         314         558         394        1116        1202
Swap:          1023           0        1023
NO ONESHOT
              total        used        free      shared  buff/cache   available
Mem:           1988         301         570         394        1116        1215
Swap:          1023           0        1023
              total        used        free      shared  buff/cache   available
Mem:           1988         344         527         394        1116        1172
Swap:          1023           0        1023
~

为了看运行速度的优化效果,跑了 100 万次的test 1,有 ONESHOT 的一次要花0.915毫秒((15*60+15.430)/1000000*1000),没 ONESHOT 的一次要花1.482毫秒((24*60+42.291)/1000000*1000),个优化提高了38.26%的效率((1.482-0.915)/1.482*100),效果不错。

为了看内存使用的优化效果,跑了 100 次的test 2,有 ONESHOT 的一次要用0.14MB((314-300)/100),没 ONESHOT 的一次要用0.43MB((344-301)/100),这个优化提高了67.44%的效率((0.43-0.14)/0.43*100),效果很好。

其实上面的数据并不准确,因为系统的内部有许多方法提高应用层的执行效率,咱这个测试必定不能算出准确的优化数据,但是可以得到一个定性的结论,那就是这个 ONESHOT 它确实有效果,特别是内存使用上。

填坑关于 DASH 的疑惑

这个优化至少从 BASH 1.x 版本就有了,时间就是 2000 年之前,到现在快三十年了。既然这是一个几十年前就有的优化,可现在的 Ubuntu 上的 Dash 为啥没有这个优化?

查了一下 Dash 的提交历史,发现作者在 2011 年 7 月 7 日加上了这个优化,Dash 从 v0.5.7 开始,dash -c "command"这样用就可以不fork了。

可是文章开头的测试明明是发现 BASH 有而 Dash 没,Ubuntu 上的 Dash 是 v0.5.10.2-6 也挺高,那肯定是 Ubuntu 改了东西。

于是去 Launchpad 上翻出了 Ubuntu 改过的 Dash,下到了dash_0.5.10.2.orig.tar.gzdash_0.5.10.2-6.debian.tar.xz,在解压后的debian/patches里找到一条 patch,名字是0004-SHELL-Disable-sh-c-command-sh-c-exec-command-optimiza.diff,就是它把 Ubuntu 内置的 Dash 的这个优化禁用了,里面还有禁用的原因,概括一下就是”虽然不知道啥 bug,但是因为有 bug,所以不用了“。

0004-SHELL-Disable-sh-c-command-sh-c-exec-command-optimiza.diff内容:

From dc3e43ef32309782d0ef5ce72d0b0c89efd1c70f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?St=C3=A9phane=20Glondu?= <glondu@debian.org>
Date: Sun, 25 Sep 2011 19:28:27 +0200
Subject: [PATCH 4/6] [SHELL] Disable sh -c "command" -> sh -c "exec command"
 optimization

Bugs #642706 (bin-prot FTBFS) and #642835 (sexplib310 FTBFS) can be
fixed by reverting the patch submitted at [1]. I don't understand why.

[1] http://thread.gmane.org/gmane.comp.shells.dash/556

While investigating #642706, in the failing case, I observed that a
cpp process called with "sh -c" gets SIGPIPE while writing to
stderr. In the succeeding case, the write is successful, and is read
by the ocamlbuild process that started "sh -c cpp ...".

Signed-off-by: Jonathan Nieder <jrnieder@gmail.com>
---
 src/main.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main.c b/src/main.c
index efd7da8..e1418a9 100644
--- a/src/main.c
+++ b/src/main.c
@@ -167,7 +167,7 @@ state2:
 state3:
        state = 4;
        if (minusc)
-               evalstring(minusc, sflag ? 0 : EV_EXIT);
+               evalstring(minusc, 0);

        if (sflag || minusc == NULL) {
 state4:        /* XXX ??? - why isn't this before the "if" statement */
--
2.1.0