标签归档:bash

作业控制与前台进程组

这篇文章是对之前的SIGTTIN信号量的疑惑?的解答,对于为何会有这种奇怪的用法,在另一篇shell下精确的定位一个命令 也介绍过了,这里想讨论的重点不在于怎么变通解决那个问题,而是导致SIGTTIN发生的机制是怎么引起的。我的同事对这个问题也产生了好奇,在stackoverflow上发帖,有人给出了解释,解答的人直接给出了bash的源码jobs.c里的initialize_job_control方法片段,指出SIGTTIN正是那里面的逻辑。不过如果你跟我一样对shell和linux系统调用都懂得很肤浅的话,这段代码并不容易懂,所以在这里更详细的解释一下这个问题的来龙去脉。

刚开始碰到这个问题的时候,通过strace看到了是SIGTTIN信号量所致,因为这个信号量默认的行为是让进程STOP(暂停),即通过ps观察到的状态为T。对于SIGTTIN信号量《Linux/UNIX系统编程手册》上是这么说的:

只有前台作业中的进程才能够从控制终端读取输入。这个限制条件避免了多个作业竞争读取终端输入。如果后台作业尝试从终端读取输入,就会接收到一个SIGTTIN信号。SIGTTIN信号的默认处理动作是停止作业。

但我们的脚本里并没有后台进程,那两个进程也没有读取终端,跟上面的解释对不上。也没有在网上搜到其它引发SIGTTIN信号的情况,在这里困惑了很久。不过凭直觉知道这个问题应该跟作业控制有关,在脚本里显式的开启作业控制,是能够正常运行的:

$ cat sleep.sh
#!/bin/bash
set -m
bash -ic 'sleep 3'
bash -ic 'sleep 2'

所以一定是在进程某个状态上的不一致导致的。上周末的时候阅读了一下strace的log,对出问题的脚本:

#!/bin/bash
bash -ic 'sleep 3'
bash -ic 'sleep 2'

使用strace -f -e verbose=all -t ./sleep.sh 2>log 得到更详细的日志

...
03:39:06 clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f997f03ca10) = 9897
...
[pid  9897] 03:39:06 execve("/usr/bin/bash", ["bash", "-ic", "sleep 3"], [/* 30 vars */]) = 0
...
[pid  9897] 03:39:06 open("/dev/tty", O_RDWR|O_NONBLOCK) = 3
[pid  9897] 03:39:06 getrlimit(RLIMIT_NOFILE, {rlim_cur=1024, rlim_max=4*1024}) = 0
[pid  9897] 03:39:06 fcntl(255, F_GETFD) = -1 EBADF (Bad file descriptor)
[pid  9897] 03:39:06 dup2(3, 255)       = 255
[pid  9897] 03:39:06 close(3)           = 0
[pid  9897] 03:39:06 ioctl(255, TIOCGPGRP, [9891]) = 0
[pid  9897] 03:39:06 setpgid(0, 9897)   = 0 //第一个子进程更改了它的进程组ID
...
03:39:09 clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f997f03ca10) = 9922
...
[pid  9922] 03:39:09 execve("/usr/bin/bash", ["bash", "-ic", "sleep 2"], [/* 30 vars */]) = 0
...
[pid  9922] 03:39:09 access("/usr/bin/bash", R_OK) = 0
[pid  9922] 03:39:09 getpgrp()          = 9891
[pid  9922] 03:39:09 ioctl(2, SNDCTL_TMR_TIMEBASE or SNDRV_TIMER_IOCTL_NEXT_DEVICE or TCGETS, 0x7ffd356e49c0) = -1 ENOTTY (Inappropriate ioctl for device)
[pid  9922] 03:39:09 open("/dev/tty", O_RDWR|O_NONBLOCK) = 3
[pid  9922] 03:39:09 getrlimit(RLIMIT_NOFILE, {rlim_cur=1024, rlim_max=4*1024}) = 0
[pid  9922] 03:39:09 fcntl(255, F_GETFD) = -1 EBADF (Bad file descriptor)
[pid  9922] 03:39:09 dup2(3, 255)       = 255
[pid  9922] 03:39:09 close(3)           = 0
[pid  9922] 03:39:09 ioctl(255, TIOCGPGRP, [9897]) = 0
[pid  9922] 03:39:09 rt_sigaction(SIGTTIN, {SIG_DFL, [], SA_RESTORER, 0x7f912a22b650}, {SIG_IGN, [], SA_RESTORER, 0x7f912a22b650}, 8) = 0
[pid  9922] 03:39:09 kill(0, SIGTTIN)   = 0
[pid  9896] 03:39:09 <... wait4 resumed> 0x7ffc5b5e6800, 0, NULL) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
[pid  9922] 03:39:09 --- SIGTTIN {si_signo=SIGTTIN, si_code=SI_USER, si_pid=9922, si_uid=1000} ---
[pid  9896] 03:39:09 --- SIGTTIN {si_signo=SIGTTIN, si_code=SI_USER, si_pid=9922, si_uid=1000} ---
[pid  9922] 03:39:09 --- stopped by SIGTTIN ---
[pid  9896] 03:39:09 --- stopped by SIGTTIN ---

确认这个SIGTTIN信号是第二个bash -ic 'sleep 2'进程发出的,kill(0, SIGTTIN)表示它把这个信号发送到自己所在的进程组,整个进程组的进程都接收到这个信号,所以它和它的父进程sleep.sh都变成了stop状态。

脚本里两次执行的bash -ic子进程也都是shell,它们在初始化的时候会有作业控制的逻辑,结合jobs.cinitialize_job_control方法里的代码(shell初始化时调用到这里):

/* We can only have job control if we are interactive. */
if (interactive == 0)
{
  job_control = 0;
  original_pgrp = NO_PID;
  shell_tty = fileno (stderr);
}
else
{
  shell_tty = -1;

  /* If forced_interactive is set, we skip the normal check that stderr
    is attached to a tty, so we need to check here.  If it's not, we
    need to see whether we have a controlling tty by opening /dev/tty,
    since trying to use job control tty pgrp manipulations on a non-tty
    is going to fail. */ 
    // bash "-i" 参数会启用 forced_interactive
  if (forced_interactive && isatty (fileno (stderr)) == 0)
    shell_tty = open ("/dev/tty", O_RDWR|O_NONBLOCK);

  /* Get our controlling terminal.  If job_control is set, or
    interactive is set, then this is an interactive shell no
     matter where fd 2 is directed. */
   if (shell_tty == -1)
    shell_tty = dup (fileno (stderr));/* fd 2 */

  shell_tty = move_to_high_fd (shell_tty, 1, -1);

  /* Compensate for a bug in systems that compiled the BSD
 rlogind with DEBUG defined, like NeXT and Alliant. */
  if (shell_pgrp == 0)
{
  shell_pgrp = getpid ();
  setpgid (0, shell_pgrp);
  tcsetpgrp (shell_tty, shell_pgrp);
}

  while ((terminal_pgrp = tcgetpgrp (shell_tty)) != -1)
{
  if (shell_pgrp != terminal_pgrp)
    {
      SigHandler *ottin;

      ottin = set_signal_handler(SIGTTIN, SIG_DFL);
      kill (0, SIGTTIN); // 第二次执行bash -ic时触发了这里
      set_signal_handler (SIGTTIN, ottin);
      continue;
    }
  break;
}
if (terminal_pgrp == -1)
t_errno = errno;

  /* Make sure that we are using the new line discipline. */
  if (set_new_line_discipline (shell_tty) < 0)
{
  sys_error (_("initialize_job_control: line discipline"));
  job_control = 0;
}
  else
{
  original_pgrp = shell_pgrp;
  shell_pgrp = getpid ();

  // 第一次bash -ic 'sleep 3'触发了这里的 setpgid 修改了当前进程组
  if ((original_pgrp != shell_pgrp) && (setpgid (0, shell_pgrp) < 0))
    {
      sys_error (_("initialize_job_control: setpgid"));
      shell_pgrp = original_pgrp;
    }

  job_control = 1;

  /* If (and only if) we just set our process group to our pid,
     thereby becoming a process group leader, and the terminal
     is not in the same process group as our (new) process group,
     then set the terminal's process group to our (new) process
     group.  If that fails, set our process group back to what it
     was originally (so we can still read from the terminal) and
     turn off job control.  */
  if (shell_pgrp != original_pgrp && shell_pgrp != terminal_pgrp)
    {
      if (give_terminal_to (shell_pgrp, 0) < 0)
    {
      t_errno = errno;
      setpgid (0, original_pgrp);
      shell_pgrp = original_pgrp;
      job_control = 0;
    }
   }
...

关键点就在于shell_pgrpterminal_pgrp这两个变量,shell_pgrp是当前进程组,而terminal_pgrp是占用当前控制终端的进程所在的进程组(前台进程组),这些状态都是可以通过ps观察到的,可以跟踪一下:

$ ps xfo pid,ppid,pgid,sid,tpgid,stat,tty,command | awk "NR==1||/pts\/2/"
  PID  PPID  PGID   SID TPGID STAT TT       COMMAND
12413 12410 12410 12410    -1 S    ?        sshd: hongjiang@pts/0
12414 12413 12414 12414 12580 Ss   pts/0     \_ -bash
12579 12414 12579 12414 12580 S    pts/0         \_ /bin/bash ./sleep.sh
12580 12579 12580 12414 12580 S+   pts/0             \_ sleep 3

$ ps xfo pid,ppid,pgid,sid,tpgid,stat,tty,command | awk "NR==1||/pts\/2/"
  PID  PPID  PGID   SID TPGID STAT TT       COMMAND
12413 12410 12410 12410    -1 S    ?        sshd: hongjiang@pts/0
12414 12413 12414 12414 12414 Ss+  pts/0     \_ -bash
12579 12414 12579 12414 12414 T    pts/0         \_ /bin/bash ./sleep.sh
12607 12579 12579 12414 12414 T    pts/0             \_ bash -ic sleep 2

在第一次执行bash -ic 'sleep 3'的时候,sleep.sh父进程先clone出bash子进程(pid 12580),因为-i参数强制这个bash子进程用交互式运行,它会加载$HOME下的.bashrc等文件,这个过程可能会fork/clone出若干子进程(所以会看到第二次bash -ic sleep 2进程的ID跟第一次不是连续的),等这些配置文件加载完之后,它并不是fork/clone的形式执行sleep 3而是使用当前进程(12580)执行的sleep 3,这里很关键的信息是"PGID"和"TPGID"都是本身进程ID,而非父进程ID,跟第二次的状态不一样。

因为脚本默认是关闭作业控制的,本来每个子进程并不会设置为独立的进程组,比如下面这个脚本:

$ cat a.sh
#!/bin/bash
/usr/bin/sleep 10

$ ps xfo pid,ppid,pgid,sid,tpgid,stat,tty,command | awk "NR==1||/pts\/2/"
  PID  PPID  PGID   SID TPGID STAT TT       COMMAND
12668 12665 12665 12665    -1 S    ?        sshd: hongjiang@pts/2
12669 12668 12669 12669 12736 Ss   pts/2     \_ -bash
12736 12669 12736 12669 12736 S+   pts/2         \_ /bin/bash ./a.sh
12737 12736 12736 12669 12736 S+   pts/2             \_ /usr/bin/sleep 10

上面脚本执行时sleep子进程"PGID"和"TPGID"都是进程父进程a.sh的,并没有被设置为一个独立的进程组。

sleep.sh脚本里之所以会对子进程设置一个独立的进程组,是因为"-i"参数使得bash -ic 'sleep 3'在非交互式脚本里运行时进程被强制设置成了独立的进程组(见initializejobcontroll里的setpgid),同时"TPGID"这个表示前台进程组的状态也被改为了bash -ic 'sleep 3'的进程组ID。

那为什么在接下来的bash -ic 'sleep 2'子进程执行时却不像前面的那样呢?这正是最诡异的地方。它们所在的sleep.sh脚本是非交互式运行的,它本来预期脚本执行过程不应该产生与脚本进程组不一致的前台进程组,所以前台子进程组结束的时候,不会去更新"TPGID",可以用下面脚本来验证:

$ cat wait.sh
#!/bin/bash
bash -ic 'sleep 5'
sleep 4
sleep 3 &  #不让wait.sh进程立即退出
wait

上面wait.sh脚本里第一个子进程强制修改了"TPGID",子进程退出,以及后续再执行前台进程都不会去更新这个状态

$ ps xfo pid,ppid,pgid,sid,tpgid,stat,tty,command | awk "NR==1||/pts\/0/"
  PID  PPID  PGID   SID TPGID STAT TT       COMMAND
13867 13864 13864 13864    -1 S    ?        sshd: hongjiang@pts/0
13868 13867 13868 13868 14138 Ss   pts/0     \_ -bash
14137 13868 14137 13868 14138 S    pts/0         \_ /bin/bash ./wait.sh
14138 14137 14138 13868 14138 S+   pts/0             \_ sleep 5

$ ps xfo pid,ppid,pgid,sid,tpgid,stat,tty,command | awk "NR==1||/pts\/0/"
  PID  PPID  PGID   SID TPGID STAT TT       COMMAND
13867 13864 13864 13864    -1 S    ?        sshd: hongjiang@pts/0
13868 13867 13868 13868 14138 Ss   pts/0     \_ -bash
14137 13868 14137 13868 14138 S    pts/0         \_ /bin/bash ./wait.sh
14165 14137 14137 13868 14138 S    pts/0             \_ sleep 4

$ ps xfo pid,ppid,pgid,sid,tpgid,stat,tty,command | awk "NR==1||/pts\/0/"
  PID  PPID  PGID   SID TPGID STAT TT       COMMAND
13867 13864 13864 13864    -1 S    ?        sshd: hongjiang@pts/0
13868 13867 13868 13868 14138 Ss   pts/0     \_ -bash
14137 13868 14137 13868 14138 S    pts/0         \_ /bin/bash ./wait.sh
14168 14137 14137 13868 14138 S    pts/0             \_ sleep 3

回到sleep.sh脚本里,第二行bash -ic 'sleep 2'子进程初始化时,"TPGID"仍是上个进程bash -ic 'sleep 3'修改过的值。而bash -ic 'sleep 2'子进程也因为"-i"参数让自己以交互式运行,但是在还没有执行到setpgid之前,就先触发了SIGTTIN的逻辑:

if (shell_pgrp != terminal_pgrp)
{
    SigHandler *ottin;

    ottin = set_signal_handler(SIGTTIN, SIG_DFL);
    kill (0, SIGTTIN);
    set_signal_handler (SIGTTIN, ottin);
    continue;
}

因为这段代码会认为终端被其他前台进程占用,对当前进程组发出SIGTTIN信号。在这个场景里,这恰好是一种误会!

当我们显式的对sleep.sh脚本设置开启作业控制时:

$ cat sleep.sh
#!/bin/bash
set -m
bash -ic 'sleep 3'
bash -ic 'sleep 2'

$ ps xfo pid,ppid,pgid,sid,tpgid,stat,tty,command | awk "NR==1||/pts\/2/"
  PID  PPID  PGID   SID TPGID STAT TT       COMMAND
12668 12665 12665 12665    -1 S    ?        sshd: hongjiang@pts/2
12669 12668 12669 12669 12874 Ss   pts/2     \_ -bash
12873 12669 12873 12669 12874 S    pts/2         \_ /bin/bash ./sleep.sh
12874 12873 12874 12669 12874 S+   pts/2             \_ sleep 3

$ ps xfo pid,ppid,pgid,sid,tpgid,stat,tty,command | awk "NR==1||/pts\/2/"
  PID  PPID  PGID   SID TPGID STAT TT       COMMAND
12668 12665 12665 12665    -1 S    ?        sshd: hongjiang@pts/2
12669 12668 12669 12669 12901 Ss   pts/2     \_ -bash
12873 12669 12873 12669 12901 S    pts/2         \_ /bin/bash ./sleep.sh
12901 12873 12901 12669 12901 S+   pts/2             \_ sleep 2

它对每个子进程都设置为独立的进程组,并在每个进程(前台)结束的时候更新"TPGID"为父进程组ID,避免了initialize_job_controll里发送SIGTTIN的逻辑。

有很多shell的问题都是跟作业控制相关的,另一个例子参考tomcat进程意外退出的问题分析;作业控制可以玩出很多高阶花样,但它也大大增加了shell的复杂度,这个例子是一个典型的反面教材,最好不要在非交互式脚本里调用bash -ic来执行命令。

shell下精确的定位一个命令

之前在这篇SIGTTIN? 里提出了问题,但没有交代背景,为什么用交互式shell执行”which mvn”命令,是为了更准确的获取用户当前环境里所用的mvn命令到底是哪个。若用户对mvn做过alias,优先使用alias过的指令,然后再选择$PATH路径下的命令;这个函数的完整内容如下:

function get_mvn_cmd() {
  if [[ "$OSTYPE" == *cygwin* ]];then
    ppid=$( ps -ef -p $$ | awk 'NR==2{print $3}' )
    user_shell=$( ps -p $ppid | awk 'NR==2{print $8}' )
    #has some trouble with cygwin, while Ctrl-c cannot terminal
    set -m
  else
    ppid=$( ps -oppid= $$ )
    user_shell=$( ps -ocomm= -p $ppid )
  fi

  # while as login shell, it's -bash not bash
  if [[ "$user_shell" == "-"* ]];then
    user_shell=${user_shell:1}
  fi

  mvn=$( $user_shell -ic "alias mvn" 2>/dev/null | cut -d'=' -f2 | sed "s/'//g" )

  if [ -z "$mvn" ];then
    $user_shell -ic "which mvn" >/dev/null
    if [ $? -eq 0 ];then
      mvn=$( $user_shell -ic "which mvn" | head -1 )
    fi
  fi

  if [ -z "$mvn" ]; then
    echo "mvn command not found" >&2
    kill -s TERM $TOP_PID
  else
    echo $mvn
  fi
}

函数里先获取用户所使用的shell(即当前脚本的父进程),然后以交互式执行alias mvn看看是否又被用户别名过,没有的话再使用which mvn获取mvn命令。

为什么写的那么啰嗦是想兼容好几种用户环境,如果是linux或者使用的gnu-which,可以利用gnu-which的一些参数一次性按优先级依次从”alias, functions, commands”里查找命令

$ (alias; declare -f) | gwhich --read-alias --read-functions mvn

上面利用gnu-which的--read-alias--read-functions参数优先从前边aliasdeclare -f输出的结果里查找,最后再从$PATH里查找。实际上centos7的bash下which命令就被alias过了:

alias which='alias | /usr/bin/which --tty-only --read-alias --show-dot --show-tilde'    

bash的陷阱(3): return

bash里的return

当函数hook是一个只有一行return的空函数,并且hook函数被另一个test函数用在结尾的时候,test的返回结果很有趣:

#!/bin/bash

function test {
  ls /notexist >/dev/null 2>&1
  hook
}

function hook() {
    return
}

hook
echo "hook execute result "$?

test
echo "test execute result "$?

如果没有给return语句一个显式的code,它返回的是上一次命令的结果。hook函数这里应该用return 0更严谨一下。

bash debug

便于调试bash的输出效果:

#!/usr/bin/env bash

if [ -z "$1" ];then
  echo "usage: bashdebug your_script"
  exit 1
fi

PS4='+[$(date "+%Y-%m-%d %H:%M:%S") +${SECONDS}s ${BASH_SOURCE} ${LINENO}] ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'

ACK=""
which ack &>/dev/null
[ $? -eq 0 ] && ACK=$(which ack)

if [ -z "$ACK" ];then
  bash -x $1
else
  bash -x $1 |& $ACK --passthru "^\+.*:"
fi

sh script.sh与./script.sh的差异

这两种方式差异是进程名称的不同,假设有以下内容的脚本:

$ cat script.sh
#!/bin/bash
sleep 4400

sh script.sh执行,它是明确的启动一个sh命令,把脚本名称做参数传递给它,进程名称是sh:

$ pstree  
 ...
 |-sshd-+...
 |      |-sshd---sshd---bash---sh---sleep

而以./script.sh方式执行,操作系统负责脚本加载(execve系统调用),进程名称是脚本名

$ pstree  
 ...
 |-sshd-+...
 |      |-sshd---sshd---bash---script.sh---sleep

shell前边的连字符含义

在一个脚本里,要获取其父shell时,使用了下面的方式:

#!/bin/bash 
ps -ocomm= -p $(ps -oppid= $$)

它在某些环境下,父shell会显示为 “/usr/local/bin/zsh” 或者 “bash” ,而某些环境下却会显示为”-bash”或”-zsh”;这个开头多出来的连字符是怎么回事?查了一下,原来是表示的是”login shell”。

linux下可以在”/etc/passwd”里看到用户的login shell,而在mac下要确认当前用户的login shell,要通过下面的命令:

$ dscl . read /users/$USER UserShell
UserShell: /bin/bash

在mac下,当打开终端程序(Terminal.app)时,终端shell是login进程的子进程(不管你配置那种command):

$ pstree
 ...
 |-+= 01272 hongjiang /Applications/Utilities/Terminal.app/Contents/MacOS/Terminal
 | \-+= 01275 root login -pf hongjiang
 |   \-+= 01276 hongjiang -bash
 ...

$ ps -ef | grep login
0  1275  1272   0  2:05PM ttys000    0:00.05 login -pf hongjiang 

# 把Terminal的启动命令修改为zsh也一样

 |-+= 01988 hongjiang /Applications/Utilities/Terminal.app/Contents/MacOS/Terminal
     | \-+= 01991 root login -pf hongjiang /bin/zsh  |   \-+= 01992 hongjiang -zsh

而在mac的 iTerm.app 下,不管如果你配置的command是”Login shell”还是修改为其他shell,启动后shell没有再挂在login进程下:

$ pstree
 |-+= 00574 hongjiang /Volumes/Data/program/iTerm.app/Contents/MacOS/iTerm
 | \-+= 02177 hongjiang /usr/local/bin/zsh  

但在iTerm下如果使用的是“Login shell”显示的名称前边却是有连字符的,而其他shell则没有

$ ps $$
  PID   TT  STAT      TIME COMMAND
 2220 s001  Ss     0:00.01 -/bin/bash

# 修改启动shell为zsh

➜  ps $$
  PID   TT  STAT      TIME COMMAND
 2524 s000  Ss     0:00.16 /usr/local/bin/zsh

要想在iTerm下保持跟Terminal一致也用login来启动,应该在配置里修改启动命令为:

login -pf $username /usr/local/bin/zsh   

在linux下,从tty登录shell也是一样由login进程启动的

$ ps -ef | grep bash
 hongjia+  2241   467  0 14:09 tty1     00:00:00 -bash

$ pstree 
systemd─┬─NetworkManager─┬─2*[dhclient]
    │                └─3*[{NetworkManager}]
    ├─...
    ├─login───bash
    ...   

$ ps -ef | grep login
root       467  0.0  0.2  84584  2328 ?        Ss   14:08   0:00 login -- hongjiang    

使用su命令切换到一个用户shell下,默认情况这个shell并不是”login shell”不会去执行/etc/profile和home目录下相关配置:

$ sudo su hongjiang

$ ps -ef | grep $$
hongjia+  2796  2795  0 14:57 pts/0    00:00:00 bash

要以”login shell”方式启动,需要对su指定一个参数“-“

$ sudo su - hongjiang

$ ps -ef | grep $$
hongjia+  3188  3187  0 15:35 pts/0    00:00:00 -bash

从bash文档里可以看到,要以”login shell”方式启动一个shell,要么第一个参数给一个特定的连字符“-”,要么显式的对bash设定”–login”参数。

bash的一般性诊断

如果脚本执行的时候报“找不到文件或目录”,但有没有提示任何文件或名录名称,比如:

$ ./service.sh
: No such file or directory

这种情况下,很可能是脚本里的特殊字符引起的,可以通过开启bash的debug选项来查看,为了输出行号可以设置PS4环境变量:

$ export PS4='+{$LINENO:${FUNCNAME[0]}} ' && bash -x service.sh
set: usage: set [--abefhkmnptuvxBCHP] [-o option-name] [arg ...]
+{3:} $'\r'
: command not found
+{23:} $'\r'
: command not found:
...

可以看到提示在底3行和23行出现了windows下的换行符\r导致脚本没能正确识别。通过cat -A可以看到行尾的windows换行符^M$

解决方法:

$ sed -i 's/\r//g' service.sh

bash的几个细节

1) 使用 /bin/bash 还是 /usr/bin/env bash

bash脚本的开头,通常声明为#!/bin/bash,但一些严谨一些(为了更好的跨平台)的脚本里,声明为#!/usr/bin/env bash

并不是所有的操作系统上bash都是/bin路径下的,有些unix(比如bsd系列),bash不是它们的默认shell,安装路径可能是在/usr/local/bin/bash,所以通过env方式在path下选择bash兼容性更好一些。不过,如果确定脚本只是为linux和mac使用,也无所谓。

2) 标准输入使用”-” ,还是”/dev/fd/0″

相对于使用”-“表示标准输入,”/dev/fd/0″的一个好处是,在脚本里更明确一些,因为有时很难区分”-“到底代表标准输入,还是命令的参数。

/dev/fd/0  标准输入 
/dev/fd/1  标准输出 
/dev/fd/2  标准错误 

3) 获取当前目录

有好几种方式可以获取当前运行的脚本所在的目录,最严谨的一种方式是:

CUR_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd -P )"

4) set -e 要不要设置

有些情况下,我们需要判断上一条命令是否执行成功,比如kill -0 pid检查某个进程是否存在,这个时候如果设置了set -e,导致进程不存在的kill -0执行失败脚本退出。如果进程不存在的也有一个分支逻辑的话,那个分支就不可能执行到了,这种情况下显然不能使用set -e

5) exit与fork

exit通常是当某个执行失败或条件不符合时希望真个脚本退出时使用的,但需要注意的一点是,当使用”$()”执行一个函数时,是fork方式,这个函数里定义的exit是让fork的那个进程退出,而不是当前脚本进程,需要当前脚本退出的话,一个变通的方式是定义个类似exit的方法给最顶层的进程发信号:

TOP_PID=$$

trap "exit 1" TERM

quit() {
  local msg=$1
  [ ! -z "$msg" ] && echo $msg >&2
  kill -s TERM $TOP_PID
}

foo() {
    command || quit 
}

result=$(foo)

bash的陷阱(2): 函数返回值问题

一个脚本里遇到的,这个函数的写法是:

inner_stop(){
  local pid=$( get_service_pid )
  [ ! -z "$pid" ] && kill $pid
}

在调用完这个函数的地方需要检查一下它的执行结果,通过 $? 来判断,结果当前一句pid为空时,inner_stop 总会返回1。本来的意图是,判断当pid存在才执行kill,否则pid为空直接返回成功,而不是返回错误。这里忘了bash函数的返回值是上最后一条语句的执行结果 [ xxx ] 测试语句不满足时返回1

在返回值这点上要注意,不同于其他语言,bash属于弱类型语言,并且返回值总是最后一个命令的执行结果,if condition; then doSomething; fi 语句即可以返回if condition 的执行结果,也可以返回doSomething的结果。(注意这里说的结果是指return value,不是标准输出的内容)

#!/bin/bash

foo() {
  [ ! -z "$x" ] && echo "x not empty"
}

foo || echo "foo function return $?"

上面脚本执行时,如果没有全局声明过x变量,foo函数的返回总是1(错误),需要对另一个分支显式的return 0才行。

bash的陷阱(1): 逻辑或碰到local修饰符的情况

bash的函数如果有返回值,并要赋值给一个变量,跟普通的命令调用没有差异,都是: v=$(func) 的方式。需要注意的是,这里执行了fork操作,如果函数里有调用exit的话,只是让子进程退出,当前进程并不会退出。如果想要让当前进程也退出,可以使用set -e或在函数调用结束的时候再判断一次,比如:

v=$(func) || exit $?

如果函数调用失败,当前进程也退出。

今天遇到一个跟这个相关的陷阱,但并不是fork的问题,而是bash语法解析的问题,先看看我当时的写法,脚本简化后:

$ cat a.sh
#!/bin/bash

foo() {
    echo "error" && exit 1
}

bar() {
    local r=$( foo ) || exit $?
    echo $r # 执行了这里
}

bar

如上,函数bar以fork的方式调用foo,我们预期是foo返回值不为0时,bar函数也退出。所以上面bar函数里最后的echo $r不应该被执行到。但实际运行的时候发现这段代码确实可以执行到bar函数的最后一行。

后来,去掉了local修饰符,执行符合预期了:

bar() {
    r=$( foo ) || exit $?
    echo $r # 不会执行到这里
}

看来是 r=$( foo ) || exit $? 里的逻辑或||被解析器作为等号后边两个命令的逻辑运算;而前边加了local修饰符之后,逻辑或||被当成表达式local r=$(foo) 与 命令 exit 之间的逻辑运算。