这篇文章是对之前的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
...
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状态。
结合jobs.c
的initialize_job_control
方法里的代码:
/* 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. */
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);
set_signal_handler (SIGTTIN, ottin);
continue;
}
break;
}
...
关键点就在于shell_pgrp
和terminal_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的,并没有被设置为一个独立的进程组。也就是说因为"-i"参数使得bash -ic 'sleep 3'
在非交互式脚本里运行时进程被设置成了独立的进程组,所以"TPGID"这个表示前台进程组的状态也被改为了bash -ic 'sleep 3'
的进程组ID。
那为什么在接下来的bash -ic 'sleep 2'
子进程执行时却不像前面的那样呢?这正是最诡异的地方。它们所在的sleep.sh
脚本是非交互式运行的,在每个子进程结束的时候,"TPGID"变量并不做修改,即它仍是上个进程bash -ic 'sleep 3'
修改过的值。而bash -ic 'sleep 2'
子进程也因为"-i"参数让自己以交互式运行,结果正好触发了jobs.c
里的:
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,避免了jobs.c
里发送SIGTTIN
的逻辑。
有很多shell的问题都是跟作业控制相关的,另一个例子参考tomcat进程意外退出的问题分析;作业控制可以玩出很多高阶花样,但它也大大增加了shell的复杂度,这个例子是一个典型的反面教材,最好不要在非交互式脚本里调用bash -ic
来执行命令。