如果分配了伪 tty,为啥通过 ssh 运行后台任务会失败?

Posted

技术标签:

【中文标题】如果分配了伪 tty,为啥通过 ssh 运行后台任务会失败?【英文标题】:Why does running a background task over ssh fail if a pseudo-tty is allocated?如果分配了伪 tty,为什么通过 ssh 运行后台任务会失败? 【发布时间】:2015-11-29 18:59:23 【问题描述】:

我最近在通过 ssh 运行命令时遇到了一些奇怪的行为。我很想听听对以下行为的任何解释。

运行ssh localhost 'touch foobar &'会按预期创建一个名为foobar的文件:

[bob@server ~]$ ssh localhost 'touch foobar &'
[bob@server ~]$ ls foobar
foobar

但是运行相同的命令但使用-t 选项强制伪tty 分配无法创建foobar

[bob@server ~]$ ssh -t localhost 'touch foobar &'
Connection to localhost closed.
[bob@server ~]$ echo $?
0
[bob@server ~]$ ls foobar
ls: cannot access foobar: No such file or directory

我目前的理论是,因为触摸进程正在后台运行,所以在进程有机会运行之前分配和取消分配伪 tty。当然,添加一秒钟的睡眠可以让触摸按预期运行:

[bob@pidora ~]$ ssh -t localhost 'touch foobar & sleep 1'
Connection to localhost closed.
[bob@pidora ~]$ ls foobar
foobar

如果有人有明确的解释,我会很感兴趣。谢谢。

【问题讨论】:

我想就是这样。后台进程连接到 tty 并且 tty 死亡将其杀死。试试nohup touch foobar & 看看是否可行和/或touch foobar </dev/null >/dev/null 2>&1 ssh -t localhost 'touch foobar < /dev/null > /dev/null 2>&1 & 'ssh -t localhost 'nohup touch foobar & ' 产生相同的行为。 他们无法创建文件?我得到了这里创建的文件,即使是为了记录的原始版本。 是的,这两个命令都没有创建文件。 touch foobar </dev/null >/dev/null 2>&1 不会阻止进程由其控制 TTY 发出信号。 nohup 情况可能是一种竞争条件——nohup 在阻止信号之前被发出信号。 【参考方案1】:

哦,这个不错。

这与进程组的工作方式、使用 -c 作为非交互式 shell 调用时 bash 的行为方式以及输入命令中 & 的效果有关。

答案假定您熟悉 UNIX 中作业控制的工作原理;如果你不是,这里是一个高级视图:每个进程都属于一个进程组(同一组中的进程通常作为命令管道的一部分放在那里,例如cat file | sort | grep 'word' 将放置运行cat(1) 的进程, sort(1)grep(1) 在同一进程组中)。 bash 和其他进程一样,它也属于一个进程组。进程组是会话的一部分(会话由一个或多个进程组组成)。在一个会话中,最多有一个进程组,称为前台进程组,可能还有许多后台进程组。前台进程组拥有终端的控制权(如果有控制终端连接到会话);会话负责人 (bash) 使用 tcsetpgrp(3) 将进程从后台移动到前台,从前台移动到后台。发送到进程组的信号会传递到该组中的每个进程。

如果流程组和作业控制的概念对您来说是全新的,我认为您需要阅读它才能完全理解这个答案。 UNIX 环境中的高级编程(第 3 版)的第 9 章是一个很好的学习资源。

话虽如此,让我们看看这里发生了什么。我们必须将拼图的每一部分拼凑在一起。

在这两种情况下,ssh 远程端都会调用 bash(1)-c-c 标志导致 bash(1) 作为非交互式 shell 运行。从手册页:

交互式 shell 是在没有非选项参数的情况下启动的,并且 没有 -c 选项,其标准输入和错误都是 连接到终端(由 isatty(3) 确定),或者一个启动 使用 -i 选项。 PS1 已设置并且 $- 包括 i 如果 bash 是 交互式,允许一个 shell 脚本或一个启动文件来测试它 状态。

另外,重要的是要知道在非交互模式下启动 bash 时会禁用作业控制。这意味着 bash 不会创建单独的进程组来运行该命令,因为禁用了作业控制,因此无需在前台和后台之间移动此命令,因此它还不如保留在与 bash 相同的进程组中.无论您是否使用 -t 在 ssh 上强制分配 PTY,都会发生这种情况。

但是,使用& 具有导致shell 不等待命令终止的副作用(即使禁用了作业控制)。从手册页:

如果一个命令被控制操作符 & 终止,shell 在子shell 的后台执行命令。外壳可以 不等待命令完成,返回状态为 0。 用 ; 分隔的命令依次执行;外壳等待 每个命令依次终止。返回状态为退出 最后执行的命令的状态。

因此,在这两种情况下,bash 都不会等待命令执行,touch(1) 将在与bash(1) 相同的进程组中执行。

现在,考虑一下当会话负责人退出时会发生什么。引用 setpgid(2) 手册页:

如果会话具有控制终端,并且该会话的 CLOCAL 标志 未设置终端,并且发生终端挂断,则会话 领导者被发送一个 SIGHUP。 如果会话负责人退出,则 SIGHUP 信号也会被发送到前台进程中的每个进程 控制终端组

(强调我的)

当你不使用-t

当你不使用-t时,远程端没有PTY分配,所以bash不是会话负责人,实际上并没有创建新的会话。因为 sshd 作为守护进程运行,所以 fork + exec()'d 的 bash 进程将没有控制终端。因此,即使 shell 很快终止(可能在 touch(1) 之前),也没有 SIGHUP 发送到进程组,因为 bash 不是会话负责人(并且没有控制终端)。所以一切正常。

当您使用-t

-t强制PTY分配,也就是说ssh远程端会调用setsid(2),分配一个伪终端+用forkpty(3)fork一个新进程,连接PTY主设备输入输出到socket端点这导致你的机器,最后执行bash(1)forkpty(3) 在会变成 bash 的 fork 进程中打开 PTY 从端;由于当前会话没有控制终端,并且正在打开终端设备,因此 PTY 设备成为会话的控制终端,而 bash 成为会话领导者。

然后同样的事情再次发生:touch(1) 在同一个进程组中执行,等等,yadda yadda。重点是,这一次,有一个会话领导者和一个控制终端。因此,由于 bash 不会因为& 而烦恼等待,所以当它退出时,SIGHUP 被传递到进程组,touch(1) 过早死亡。

关于nohup

nohup(1) 在这里不起作用,因为仍然存在竞争条件。如果bash(1)nohup(1) 有机会设置必要的信号处理和文件重定向之前终止,它将不起作用(这可能是发生的情况)

一种可能的解决方法

强制重新启用作业控制可以修复它。在 bash 中,您可以使用 set -m 执行此操作。这有效:

ssh -t localhost 'set -m ; touch foobar &'

或者强制 bash 等待 touch(1) 完成:

ssh -t localhost 'touch foobar & wait `pgrep touch`'

【讨论】:

感谢您给出如此清晰详细的解释,非常感谢。 @user414310 很高兴我能帮上忙。我得说,我喜欢这个问题。谢谢你提出这么好的问题:)【参考方案2】:

@Filipe Gonçalves 的答案很棒,但它有问题。我没有足够的声誉在那里发表评论,所以我在这里更正/丰富内容:

当你不使用 -t 时,

@Filipe 说:

不使用 -t 时,远程端没有 PTY 分配,所以 bash 不是会话负责人,实际上不会创建新会话。 ...

实际上,bash 是一个会话负责人,并创建了新会话。

让我们测试一下:

# run sleep background process first, then call ps directly:
[root@90fb1c3f30ce ~]# ssh localhost  'sleep 66 & ps -o pid,ppid,pgid,sess,tpgid,tty,args'
    PID    PPID    PGID    SESS   TPGID TT       COMMAND
 184074      67  184074  184074      -1 ?        sshd: root@notty
 184076  184074  184076  184076      -1 ?        bash -c sleep 66 & ps -o pid,ppid,pgid,sess,tpgid,tty,args
 184081  184076  184076  184076      -1 ?        sleep 66
 184082  184076  184076  184076      -1 ?        ps -o pid,ppid,pgid,sess,tpgid,tty,args

Notice           ^^^^^   ^^^^^

我们可以看到这些 bash/sleep/ps 进程具有相同的 PGID/SESS 等于 bash 进程的 PID 184076,但是 sshd 父进程具有不同的 PGID /SESS。这里,bash 进程是新会话的领导者,而 bash/sleep/ps 进程属于另一个进程组

另外,我们可以发现 ssh 命令并没有立即返回,它仍然等待大约 66 秒。你可以在这里找到它的原因:Getting ssh to execute a command in the background on target machine

在ssh命令等待期间,我们可以打开另一个会话并运行:

[root@90fb1c3f30ce ~]# ps -eo pid,ppid,pgid,sess,tpgid,tty,args
    PID    PPID    PGID    SESS   TPGID TT       COMMAND
    # unrelated lines removed #
 184074      67  184074  184074      -1 ?        sshd: root@notty
 184081       1  184076  184076      -1 ?        sleep 66
Notice           ^^^^^   ^^^^^

[root@90fb1c3f30ce ~]# ps -e | grep 184076
[root@90fb1c3f30ce ~]#

我们可以看到bash进程(pid 184076)已经走了,但是sleep后台进程的PGID/SESS保持不变。没关系,APUE session 9.4:

每个过程组可以有一个过程组负责人。领导者由其进程组 ID 标识,该 ID 等于其进程 ID。

进程组负责人可以创建进程组,在组中创建进程,然后终止。进程组仍然存在,只要组中至少有一个进程,无论组长是否终止

那么,为什么这个睡眠进程不会死掉呢?

不使用-t时,远程端没有PTY分配,所以远程端的进程组不是前台进程组(没有终端,没有前台或后台的意义)。因此,即使 shell 很快终止,也没有 SIGHUP 发送到它的进程组,因为进程组不是前台进程组。 (SIGHUP 信号将发送到控制终端的前台进程组中的每个进程)。

【讨论】:

【参考方案3】:

关键是将子进程的 stdin/stdout/stderr 流与原始 bash/ssh 会话解耦;然后不再需要伪 tty 分配 (ssh -t) 以允许子进程在 ssh 连接终止后幸存下来。完整答案见here...

【讨论】:

以上是关于如果分配了伪 tty,为啥通过 ssh 运行后台任务会失败?的主要内容,如果未能解决你的问题,请参考以下文章

SSH 不分配远程主机tty

什么是伪 TTY 分配? (SSH 和 Github)

结束 ssh 会话后在后台运行 python/matplotlib 的问题

Docker入门学习

如何从 BeagleBone Black 连接到 USB TTY?

使用没有 tty 的 heredoc 在 ssh 后获取 bash 提示