发现问题
一个小问题:“怎么让一个 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_execve
和make_child
的关键就是execve
和fork
。
到这已经搞清楚了关键的东西:
readlink
的输出它奇怪,是因为 ONESHOT 优化,是因为直接execve
;- ONESHOT 优化的规则是由
should_suppress_fork
函数决定;如果是connection
类型的命令串,再加上can_optimize_connection
和optimize_fork
函数。 - ONESHOT 优化的目的是为了提高效率。
fork/exec
经过有效努力,搞清楚了这问题由bash
的优化引起。对于bash -c 'readlink /proc/$$/exe'
,bash
进程在解析完成后,调用execve
替换进程,成功后当前进程不再是bash
,而是readlink
,进程名和可执行文件路径也分别改成了readlink
和 /usr/bin/readlink
,然而execve
不改变进程号,$$
的值依然有效并指代当前进程,所以效果就是开头的那样。
上面提到的fork
和execve
是什么呢?execve
是exec
函数族的一员,和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;
}
比较有趣的是:
- 一般
fork
函数一次调用两次返回。fork
成功调用后,复制了一个跟原来一样的进程,执行位置都在fork
这行。原进程中的fork
会返回新进程的 PID,而新进程中的fork
会返回 0,这样就能区分父子进程了。 - 一般
exec
函数族一次调用零次返回。exec
函数族之一成功调用后,新的映像替换了旧的映像,CPU 的执行地址已经跳到新的程序入口点,自然没有返回值了。但调用后进程的其它属性并没有改变,如 PID、PPID、优先级以及所属的用户组等。
如果接触过 Windows API 的CreateProcess
函数,会发现它一步到位地创建进程并加载映像,达到了fork
加exec
的效果。
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 的不少东西,把fork
和exec
也学过来了。
优化规则
在文章的开头,咱摸出了一些简单的规则,可是不仅不全,还在不同版本下有区别。问题来了,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;
}
}
简单地列出来是:
- 用
bash -c 'command'
的方式执行命令 parse_and_execute
没有递归调用- 不在
trap
handler 之中 - 已经解析到末尾
- 没有进行别名拓展
- 必须是简单命令
- 没有 EXIT trap
- 没有 ERR trap
- 没有系统信号的 trap
- 没有重定向
- 没有 time
- 没有 !
- 特殊的列表命令
下边展开来说,注意所有的例子都在 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 的执行流程:
- 读取输入
- 将输入分解为
word
和operator
两种token
- 将
token
解析为简单和复合命令 - 处理各种扩展
- 处理各种重定向
- 执行命令
- 等待命令完成并收集退出状态(可选)
前边已经知道,对于bash -c 'command'
,parse_and_execute
解析和执行command
的内容。等到执行command
的时候,步骤已经来到 shell 执行流程的第 6 步了,下一步就是等命令完成然后收集状态了,有没有什么办法在没完成的时候,重新回到开头,比如第 1 步或第 2 步,套娃一波呢?
下边介绍一个有趣的内置命令:
eval [arguments]
eval
把arguments
连起来形成一个命令,然后读取并执行它,它的退出状态就是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
前缀都行arg
是trap
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 里的控制操作符之一,在代码里有分割命令的作用,到运行时表现就是逐行解析和执行了。
来看几个例子:
echo $$
,解析一次,第一次拿到echo $$
转的Command
结构体echo $$\n
,解析一次,第一次拿到echo $$
转的Command
结构体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 有四种伪信号EXIT
、ERROR
、DEBUG
、RETURN
,简单地讲:
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
到这里,伪信号已经写一半了,剩下的DEBUG
和RETURN
,处理了会不会优化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.14
MB((314-300)/100
),没 ONESHOT 的一次要用0.43
MB((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.gz
和dash_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