如何终止进程并确保 PID 未被重用

如何终止进程并确保 PID 未被重用

例如,假设您有一个类似于以下内容的 shell 脚本:

longrunningthing &
p=$!
echo Killing longrunningthing on PID $p in 24 hours
sleep 86400
echo Time up!
kill $p

应该能做到这一点,不是吗?除了进程可能提前终止并且其 PID 可能已被回收之外,这意味着某些无辜的作业在其信号队列中收到了炸弹。在实践中,这可能确实很重要,但它仍然让我担心。破解长时间运行的东西使其自行死亡,或者在 FS 上保留/删除其 PID 就可以了,但我正在考虑这里的一般情况。

答案1

最好是使用该timeout命令(如果有的话):

timeout 86400 cmd

当前(8.23)GNU 实现至少可以通过alarm()在等待子进程时使用或等效方式来工作。它似乎并没有防止在返回和退出SIGALRM之间传递(有效地取消了waitpid()timeout警报)。在这个小窗口期间,timeout甚至可能在 stderr 上写入消息(例如,如果子进程转储了一个核心),这将进一步扩大该竞争窗口(例如,如果 stderr 是一个完整的管道,则无限期地扩大)。

我个人可以忍受这个限制(可能会在未来的版本中修复)。timeout还将格外小心地报告正确的退出状态,处理其他极端情况(例如 SIGALRM 在启动时被阻止/忽略,处理其他信号......)比您可能手动完成的更好。

作为近似值,您可以将其写为perl

perl -MPOSIX -e '
  $p = fork();
  die "fork: $!\n" unless defined($p);
  if ($p) {
    $SIG{ALRM} = sub {
      kill "TERM", $p;
      exit 124;
    };
    alarm(86400);
    wait;
    exit (WIFSIGNALED($?) ? WTERMSIG($?)+128 : WEXITSTATUS($?))
  } else {exec @ARGV}' cmd

有一个timelimit命令在http://devel.ringlet.net/sysutils/timelimit/(比 GNUtimeout早几个月)。

 timelimit -t 86400 cmd

该方法使用alarm()类似的机制,但安装了一个处理程序SIGCHLD(忽略停止的孩子)来检测孩子的死亡情况。它还会在运行之前取消警报waitpid()(如果它处于待处理状态,则不会取消其交付SIGALRM,但就其编写方式而言,我看不出这是一个问题)并杀死调用waitpid()(因此不能杀死重用的 pid)。

网络管道还有一个timelimit命令。该方法比所有其他方法早了几十年,采用了另一种方法,但对于停止的命令不能正常工作,并1在超时时返回退出状态。

作为对你的问题的更直接的回答,你可以这样做:

if [ "$(ps -o ppid= -p "$p")" -eq "$$" ]; then
  kill "$p"
fi

也就是说,检查该进程是否仍然是我们的子进程。同样,存在一个小的竞争窗口(在ps检索该进程的状态和kill终止它之间),在此期间进程可能会终止,并且其 pid 会被另一个进程重用。

使用某些 shell(zshbashmksh),您可以传递作业规范而不是 pid。

cmd &
sleep 86400
kill %
wait "$!" # to retrieve the exit status

仅当您仅生成一项后台作业时,这才有效(否则并不总是能够可靠地获得正确的作业规范)。

如果这是一个问题,只需启动一个新的 shell 实例:

bash -c '"$@" & sleep 86400; kill %; wait "$!"' sh cmd

这是有效的,因为 shell 在孩子死亡时从作业表中删除了作业。这里,不应该有任何竞争窗口,因为当 shell 调用 时kill(),要么 SIGCHLD 信号尚未被处理并且 pid 不能被重用(因为它还没有被等待),要么它已经被处理并且作业已从进程表中删除(并且kill会报告错误)。至少在访问其作业表以扩展 之前阻止 SIGCHLDbash并在 后解除阻止。kill%kill()

避免该进程即使在死亡sleep后仍悬而未决的另一种选择是使用或的管道来代替:cmdbashksh93read -tsleep

{
  {
    cmd 4>&1 >&3 3>&- &
    printf '%d\n.' "$!"
  } | {
    read p
    read -t 86400 || kill "$p"
  }
} 3>&1

该命令仍然存在竞争条件,并且您会丢失该命令的退出状态。它还假设cmd不关闭其 fd 4。

您可以尝试实施无竞争的解决方案,例如perl

perl -MPOSIX -e '
   $p = fork();
   die "fork: $!\n" unless defined($p);
   if ($p) {
     $SIG{CHLD} = sub {
       $ss = POSIX::SigSet->new(SIGALRM); $oss = POSIX::SigSet->new;
       sigprocmask(SIG_BLOCK, $ss, $oss);
       waitpid($p,WNOHANG);
       exit (WIFSIGNALED($?) ? WTERMSIG($?)+128 : WEXITSTATUS($?))
           unless $? == -1;
       sigprocmask(SIG_UNBLOCK, $oss);
     };
     $SIG{ALRM} = sub {
       kill "TERM", $p;
       exit 124;
     };
     alarm(86400);
     pause while 1;
   } else {exec @ARGV}' cmd args...

(尽管需要改进以处理其他类型的极端情况)。

另一种无竞争的方法可能是使用进程组:

set -m
((sleep 86400; kill 0) & exec cmd)

但请注意,如果涉及到终端设备的 I/O,则使用进程组可能会产生副作用。它还有一个额外的好处,那就是杀死 . 产生的所有其他额外进程cmd

答案2

一般来说,你不能。到目前为止给出的所有答案都是有缺陷的启发法。只有一种情况可以安全地使用 pid 发送信号:当目标进程是将发送信号的进程的直接子进程,并且父进程尚未等待它时。在这种情况下,即使它已经退出,pid 也会被保留(这就是“僵尸进程”),直到父进程等待它为止。我不知道有什么方法可以用 shell 干净地做到这一点。

终止进程的另一种安全方法是使用设置为您拥有主端的伪终端的控制 tty 来启动它们。然后,您可以通过终端发送信号,例如为ptySIGTERM或通过 pty 写入字符。SIGQUIT

另一种更方便的脚本编写方法是使用命名screen会话并向屏幕会话发送命令以结束它。此过程通过根据屏幕会话命名的管道或 unix 套接字进行,如果您选择安全的唯一名称,则不会自动重用。

答案3

  1. 启动进程时保存其启动时间:

    longrunningthing &
    p=$!
    stime=$(TZ=UTC0 ps -p "$p" -o lstart=)
    
    echo "Killing longrunningthing on PID $p in 24 hours"
    sleep 86400
    echo Time up!
    
  2. 在尝试终止进程之前,先停止它(这并不是真正必要的,但这是避免竞争条件的一种方法:如果停止进程,它的 pid 就无法重用)

    kill -s STOP "$p"
    
  3. 检查具有该 PID 的进程是否具有相同的启动时间,如果是,则终止它,否则让进程继续:

    cur=$(TZ=UTC0 ps -p "$p" -o lstart=)
    
    if [ "$cur" = "$stime" ]
    then
        # Okay, we can kill that process
        kill "$p"
    else
        # PID was reused. Better unblock the process!
        echo "long running task already completed!"
        kill -s CONT "$p"
    fi
    

这是可行的,因为只能有一个进程具有相同的 PID给定操作系统上的启动时间。

在检查期间停止进程使得竞争条件不再是问题。显然这有一个问题,一些随机进程可能会停止几毫秒。根据流程的类型,这可能是也可能不是问题。


就我个人而言,我会简单地使用 python 和psutil它自动处理 PID 重用:

import time

import psutil

# note: it would be better if you were able to avoid using
#       shell=True here.
proc = psutil.Process('longrunningtask', shell=True)
time.sleep(86400)

# PID reuse handled by the library, no need to worry.
proc.terminate()   # or: proc.kill()

答案4

考虑让你的longrunningthing行为变得更好一点,更像守护进程一点。例如,您可以让它创建一个pid文件这将至少允许对过程进行一些有限的控制。有多种方法可以在不修改原始二进制文件的情况下执行此操作,所有方法都涉及包装器。例如:

  1. 一个简单的包装脚本,它将在后台启动所需的作业(带有可选的输出重定向),将该进程的 PID 写入文件,然后等待进程完成(使用wait)并删除该文件。如果在等待过程中进程被杀死,例如被类似的事情杀死

    kill $(cat pidfile)
    

    包装器只会确保 pidfile 被删除。

  2. 一个监视器包装器,它将放置它自己的PID 某处并捕获(并响应)发送给它的信号。简单的例子:

    #!/bin/bash
    p=0
    trap killit USR1

    killit () {
        printf "USR1 caught, killing %s\n" "$p"
        kill -9 $p
    }

    printf "monitor $$ is waiting\n"
    therealstuff &
    p=%1
    wait $p
    printf "monitor exiting\n"

现在,正如 @R.. 和 @StéphaneChazelas 指出的那样,这些方法通常在某个地方存在竞争条件,或者对可以生成的进程数量施加限制。此外,它不处理可能分叉且子级分离的情况longrunningthing(这可能不是原始问题中的问题)。

对于最近的(几年前的)Linux 内核,可以通过使用很好地处理这个问题cgroups,即冰箱- 我想,这就是一些现代 Linux init 系统所使用的。

相关内容