奇怪:终止后台 bash 会杀死父进程?

奇怪:终止后台 bash 会杀死父进程?

如果答案真的很简单,我深表歉意。登录到 Linux 服务器,我正在练习不同的作业控制内置程序,然后我到达了暂停命令。出于好奇,我做了任何人都会做的第一件事:输入“暂停”,看看会发生什么。

user@server:~$ suspend
-bash: suspend: cannot suspend a login shell

因此我创建了一个子 shell,并尝试将其暂停:

user@server:~$ bash
user@server:~$ suspend

[1]+ Stopped   bash
user@server:~$ 

这很好。或者说我是这么想的!由于对挂起命令的工作感到满意,我决定结束该子 shell:

user@server:~$ kill %1

[1]+ Stopped   bash
user@server:~$ user@server:~$

奇怪,我想。忽略我未能实际终止该子 shell 的事实,我在该行上收到了两个提示。所以我按 Enter 键以获得更简洁的提示,并且:

user@server:~$ user@server:~$ logout
user@server:~$ Connection to server closed.
user@client:~$

这令人惊讶。它也适用于本地终端,不需要连接到远程服务器。本地终端将返回到登录提示符。桌面会话中的终端将关闭。

那么尝试杀死后台子 shell 是如何导致父 shell 死亡的呢?

答案1

我可以在 Ubuntu 16 中重现它,如下所示:

  • 创建一个新的 Gnome 终端窗口。

  • 跑一个孩子bash;然后suspend

  • kill %1

窗户死了。 更新:如果我们使用kill -KILL这个就不会重现!

TL;博士:

根据下面的分析,我当前的假设(不完全是结论性的)是,当子 bash 收到 时SIGTERM,它会通过强制自身进入前台进程组来夺取终端。父 Bash 可能会阻止该SIGTTIN信号,因此其 TTYread会收到一个EIO,然后它会退出。当bashsuspend由于致命信号而恢复执行时,它不应该将自己挂起到前台。

为了获取更多信息,我附加strace -f -p <pid>到父 shell 来查看系统调用。

看起来它可能正在退出,因为由于某种原因,它从read标准输入的 a 接收到 -1 返回,换句话说errnoEIO标准输入上的 I/O 错误。

这是日志的尾部strace:PID18860是父级,18910是子级:

孩子退出的结语:

18910 exit_group(0)                     = ?
18910 +++ exited with 0 +++

父级的 TTYread通过以下方式以可重新启动的方式中断SIGCHLD

18860 <... read resumed> 0x7ffe891c6717, 1) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
18860 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=18910, si_uid=1001, si_status=0, si_utime=0, si_stime=1} ---

父级的信号处理调用wait4来收集子级:

18860 wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG|WSTOPPED|WCONTINUED, NULL) = 18910
18860 wait4(-1, 0x7ffe891c6010, WNOHANG|WSTOPPED|WCONTINUED, NULL) = -1 ECHILD (No child processes)

父级执行从信号处理程序返回到内核:

18860 rt_sigreturn({mask=[]})           = 0

现在奇怪的事情来了,到底是什么?恢复后read出现 I/O 错误:

18860 read(0, 0x7ffe891c6717, 1)        = -1 EIO (Input/output error)

父级开始退出:

18860 ioctl(0, TCGETS, {B38400 opost isig icanon echo ...}) = 0
18860 ioctl(0, SNDCTL_TMR_STOP or TCSETSW, {B38400 opost isig -icanon -echo ...}) = 0
18860 ioctl(0, TCGETS, {B38400 opost isig -icanon -echo ...}) = 0
[ ... ]
18860 write(2, "exit\n", 5)             = 5
18860 rt_sigaction(SIGINT, {0x460390, [], SA_RESTORER, 0x7f598a157860}, {0x460390, [], SA_RESTORER, 0x7f598a157860}, 8) = 0
18860 stat("/local/home/kaz/.bash_history", {st_mode=S_IFREG|0600, st_size=57362, ...}) = 0
18860 open("/local/home/kaz/.bash_history", O_WRONLY|O_APPEND) = 3
18860 write(3, "echo $$\nbash\nkill %1\n", 21) = 21
18860 close(3)                          = 0
[ ... ]
etc.

看起来终止确实像是对 I/O 错误的响应,这几乎肯定是意外的。

那么问题是,子进程的终止做了什么导致了后续的 I/O 错误?如果子进程没有机会做任何事情 ( kill -KILL %1),那么它就不会重现,这表明子进程bash采取了一些步骤,将 TTY 置于生成 的状态-1/EIO

看起来内核确实可能与此有关,作为可能的根本原因。

另外,我又尝试了几次。有时,ioctl(0, ...)父级在退出时发出的调用也会失败,并显示-1/EIO;有时他们不这样做。

在内核中,可以出于几个原因tty_read而放弃。EIO下一步是添加一些printk调试以查看到底是哪个。这是来自 4.12.2 的内容,由 free-electrons.com 提供:

static ssize_t tty_read(struct file *file, char __user *buf, size_t count,
            loff_t *ppos)
{
    int i;
    struct inode *inode = file_inode(file);
    struct tty_struct *tty = file_tty(file);
    struct tty_ldisc *ld;

    if (tty_paranoia_check(tty, inode, "tty_read"))
        return -EIO;
    if (!tty || tty_io_error(tty))
        return -EIO;

    /* We want to wait for the line discipline to sort out in this
       situation */
    ld = tty_ldisc_ref_wait(tty);
    if (!ld)
        return hung_up_tty_read(file, buf, count, ppos);
    if (ld->ops->read)
        i = ld->ops->read(tty, file, buf, count);
    else
        i = -EIO;
    tty_ldisc_deref(ld);

    if (i > 0)
        tty_update_time(&inode->i_atime);

    return i;
}

这几乎肯定不是由于线路规则没有功能read(最后一个EIO)。要么偏执检查失败,要么tty为空,要么tty_io_error为真。

这不是偏执检查,因为当它发生时,它会记录一条警告消息。我在内核日志中没有看到这一信息。该检查必须在编译时启用,并检查tty指针是否为空。tty不能排除由于某种原因为空的情况。

tty_io_error测试 TTY 结构中的标志:

static inline bool tty_io_error(struct tty_struct *tty)
{
    return test_bit(TTY_IO_ERROR, &tty->flags);
}

如果以某种方式设置了这一点,我们EIO将从read尝试和可能的其他系统调用中获得持续的返回。然而,这是由较低级别的 TTY 驱动程序(如串行代码)指示的。

所以也许ld->ops->read(tty, file, buf, count);一线纪律行动正在回归-EIO。 TTY 应始终按照 POSIX 线路规则在此处进行编号N_TTY。我发现文件名已经二十年没有改变了;它仍然在n_tty.c。我们想要n_tty_读

这只有一种EIO情况:

            if (test_bit(TTY_OTHER_CLOSED, &tty->flags)) {
                retval = -EIO;
                break;
            }

不过,该标志与 TTY/PTY 交互相关。这里的PTY应该是gnome终端控制的设备;在这种情况下没有理由关闭。

啊,但是看看进入时会发生什么n_tty_read:

c = job_control(tty, file);
if (c < 0)
    return c;

这就是我强烈怀疑“确凿证据”的地方。该代码有EIO返回并且与作业控制有关。这最终出现在以下函数中,参数sigSIGTTIN

int __tty_check_change(struct tty_struct *tty, int sig)
{
    unsigned long flags;
    struct pid *pgrp, *tty_pgrp;
    int ret = 0;

    if (current->signal->tty != tty)
        return 0;

    rcu_read_lock();
    pgrp = task_pgrp(current);

    spin_lock_irqsave(&tty->ctrl_lock, flags);
    tty_pgrp = tty->pgrp;
    spin_unlock_irqrestore(&tty->ctrl_lock, flags);

    if (tty_pgrp && pgrp != tty->pgrp) {
        if (is_ignored(sig)) {
            if (sig == SIGTTIN)
                ret = -EIO;
        } else if (is_current_pgrp_orphaned())
            ret = -EIO;
        else {
            kill_pgrp(pgrp, sig, 1);
            set_thread_flag(TIF_SIGPENDING);
            ret = -ERESTARTSYS;
        }
    }
    rcu_read_unlock();

    if (!tty_pgrp)
        tty_warn(tty, "sig=%d, tty->pgrp == NULL!\n", sig);

    return ret;
}

这里,有两个条件EIO。一是尝试从 TTY 读取的调用任务不在前台进程组中,并且忽略该SIGTTIN信号。

这完全符合 POSIX(2016 年第 7 期)的规定:

后台进程组中的进程从其控制终端读取数据的任何尝试都会导致向其进程组发送 SIGTTIN 信号,除非适用以下特殊情况之一: 如果读取进程忽略 SIGTTIN 信号或读取线程正在阻塞SIGTTIN 信号,或者如果读取进程的进程组是孤立的,则 read() 将返回 -1,并将 errno 设置为 [EIO],并且不会发送任何信号。 SIGTTIN 信号的默认操作是停止其发送到的进程。[11.1.3 控制终端]

问题是,我们不希望父 shell 成为孤儿。

是否可能只是退出的子 bash 在退出时强制自己进入前台,从而使父进程意外地留在后台?

事实上,我在一份strace日志中看到的是父 bash 正在退出孩子是其中之一,而孩子正在做的tcsetpgrp就是让自己成为前景。即在某些情况下,父母甚至没有收到SIGCHLD信号;它从终止子进程的 TTY 干扰和保释中获取 I/O 错误。然后子进程完成终止。

答案2

它看起来像是 bash 中的一个错误。它在我的 Ubuntu 上复制GNU bash, version 4.3.48(1)-release (x86_64-pc-linux-gnu)

  • 它不需要,suspend因为它也会在 后发生kill -STOP bash_pid
  • kill -9 %1如果您代替则不会发生这种情况kill %1
  • kill pid如果您代替则不会发生这种情况kill %1
  • 如果子进程不是bash(trydashsleep 999),则不会发生这种情况。然而在这种情况下,bash 的行为对我来说仍然是出乎意料的——在这种情况下 bash 不应该 SIGCONT sleep 999,但它显然确实如此。
  • 它不会发生在其他 shell 中(包括dash执行dash子进程),并且它们以更预期的方式终止。我们停止并终止的子进程仍然停止(ps uw始终显示子进程处于状态T)。使用 SIGCONT 唤醒子进程后,它会处理 SIGTERM 并终止,而不会影响其父进程。

相关内容