如何等待子进程?

5

我有一个启动任务的Python脚本,就像这样:

import os
os.system("./a.sh")
do_c()

a.sh 是一个启动其他程序的 bash 脚本。该 bash 脚本似乎在所有启动的脚本准备好之前就已经准备好了。

如何等待所有脚本(子进程)就绪后,再执行 do_c()

澄清一下:当我说准备好时,我的意思是完成/退出。

示例

run.py

可以更改此文件。但不要依赖于 sleep,因为我不知道 a.pyb.py 需要多长时间。

#!/usr/bin/env python

import os
from time import sleep

print("Started run.py")
os.system("./a.py")
print("a is ready.")
print("Now all messages should be there.")

sleep(30)

a.py

这个文件不允许修改:

#!/usr/bin/env python

import subprocess
import sys

print("  Started a.py")
pid = subprocess.Popen([sys.executable, "b.py"])
print("  End of a.py")

b.py

这个文件不允许修改:

#!/usr/bin/env python

from time import sleep

print("    Started b.py")
sleep(10)
print("    Ended b.py")

期望输出

最后一条消息应该是现在所有的消息都应该在那里。

当前输出

started run.py
  Started a.py
  End of a.py
a is ready.
Now all messages should be there.
    Started b.py
    Ended b.py

script.sh如何启动其他进程?这些进程中是否有任何一个会变成守护进程? - univerio
“准备好”指的是“退出”,对吗?如果script.sh在它启动的程序退出之前退出,那么等待它们将不容易。你能修改script.sh让它等待它的子进程吗? - user4815162342
@user4815162342:是的,我的意思是“退出”。不,我不能修改script.sh。我已经添加了一个例子。 - Martin Thoma
@univerio:我不知道进程守护程序自我守护时意味着什么。但是我已经在上面添加了一个类似情况的示例。 - Martin Thoma
1个回答

8
处理这种情况的常规方法不起作用。等待a.py(默认情况下os.system会这样做)是行不通的,因为a.py在其子进程执行完成之前就退出了。查找b.py的PID很棘手,因为一旦a.py退出,b.py就无法以任何方式与其连接 - 即使b.py的父PID是1,也就是init进程。
但是,可以利用继承的文件描述符作为贫穷的信号,表明子进程已经结束。设置一个管道,其中读端在run.py中,写端由a.py及其所有子进程继承。只有最后一个子进程退出时,管道的写端才会关闭,并且对管道的读端的read()操作将停止阻塞。
以下是修改过的run.py版本,实现了这个想法并显示所需的输出:
#!/usr/bin/env python

import os
from time import sleep

print("Started run.py")

r, w = os.pipe()
pid = os.fork()
if pid == 0:
    os.close(r)
    os.execlp("./a.py", "./a.py")
    os._exit(127)   # unreached unless execlp fails
os.close(w)
os.waitpid(pid, 0)  # wait for a.py to finish
print("a is ready.")

os.read(r, 1)       # wait for all the children that inherited `w` to finish
os.close(r)
print("Now all messages should be there.")

说明:

管道是一种进程间通信设备,允许父进程和子进程通过继承的文件描述符进行通信。通常情况下,我们创建一个管道,fork一个进程,可能执行一个外部文件,并从管道的读端读取一些数据,这些数据由另一个进程写入到管道的写端。(shell使用这种机制实现管道,通过将标准文件描述符如stdin和stdout指向管道的适当端点来实现。)

在这种情况下,我们不关心与子进程交换实际数据,我们只想在它们退出时得到通知。为了实现这一点,我们利用了这样一个事实:当一个进程死亡时,内核关闭它的所有文件描述符。反过来,当一个forked进程继承文件描述符时,当所有描述符的副本都被关闭时,该文件描述符被认为已关闭。因此,我们设置了一个带有写端的管道,该管道将被a.py生成的所有进程继承。这些进程不需要知道任何关于这个文件描述符的信息,唯一重要的是当它们全部死亡时,管道的写端将关闭。这将通过os.read()在管道的读端指示,不再阻塞并返回一个长度为0的字符串,表示文件结束条件。

代码是这个想法的一个简单实现:

  • os.pipe()和第一个print之间的部分是os.system()的实现,不同之处在于它关闭了子进程中的读端。这是必要的-简单地调用os.system()将保持读端开放,这将导致父进程中最终读取无法正确工作。

  • os.fork()复制当前进程,唯一区分父进程和子进程的方法是在父进程中获得子进程PID(而子进程获得0,因为它可以始终使用os.getpid()找到自己的PID)。

  • if pid == 0:分支在子进程中运行,并且仅执行./a.py。 "Exec"意味着它运行指定的可执行文件而不会返回os._exit()只有在execlp失败时才存在(在Python中可能不需要,因为execlp的失败会引发异常,从而退出程序,但仍然存在)。程序的其余部分在父进程中运行。

  • 父进程关闭管道的写端(否则尝试从读端读取将死锁)。os.waitpid(pid)是通过os.system()正常执行a.py的等待。在我们的情况下,不需要调用waitpid,但最好这样做以防止僵尸进程残留。

  • os.read(r, 1)是魔法发生的地方:它试图从管道的读端最多读取1个字符。由于没有人向管道的写端写入任何内容,因此读取将会阻塞,直到关闭管道的写端。由于a.py的子进程对继承的文件描述符一无所知,因此唯一关闭它的方法是内核在相应进程死亡后执行此操作。当所有继承的写端描述符都关闭时,os.read()返回一个零长度字符串,我们忽略它并继续执行。

  • 最后,我们关闭管道的写端,以便释放共享资源。


@user4815162342 感谢您的长篇回答!虽然它在我提供的示例设置中运行良好,但在工作中却无法正常工作。我认为问题出在 SLURM 上。一些工作(我必须等待)会向 SLURM 提交作业,这些作业必须完成后我才能继续。但我认为这将是另一个问题/需要我进一步澄清的问题。 - Martin Thoma
你能详细说明一下在父进程中关闭写端口,在子进程中关闭读端口吗?我们为什么需要这样做? - x-yuri
1
@x-yuri 在父进程中关闭写端是必要的,因为我们希望子进程继承的写端是唯一存在的文件描述符,它指向管道的写端。这样,当孙子进程退出时,管道会关闭并且 os.read(r) 会读取长度为0的 EOF 标记。如果我们没有在父进程中关闭 w,那么父进程仍将持有管道的写端,最终的 os.read(r) 将无限期地挂起,等待永远不会到达的数据。在子进程中关闭读端可能不是必要的,但是这是一个好主意,因为子进程不需要它。 - user4815162342

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接