如何等待子进程?

5 投票
1 回答
5705 浏览
提问于 2025-04-17 23:00

我有一个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

1 个回答

8

处理这种情况的常规方法并不奏效。因为默认情况下,os.system会等待a.py执行完毕,但a.py在它的子进程执行完之前就已经退出了。要找到b.py的进程ID(PID)也很麻烦,因为一旦a.py退出,b.py就无法再与它有任何关联——即使b.py的父进程ID也是1,也就是init进程。

不过,我们可以利用继承的文件描述符来作为一种简单的信号,表示子进程已经结束。我们可以在run.py中设置一个管道,管道的读端在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.")

解释:

管道是一种进程间通信的工具,允许父进程和子进程通过继承的文件描述符进行交流。通常,我们会创建一个管道,分叉一个进程,可能执行一个外部文件,然后从管道的读端读取一些数据,这些数据是由另一个进程写入管道的写端的。(Shell使用这个机制实现管道,通过进一步操作将标准文件描述符如stdin和stdout指向管道的适当端。)

在这个例子中,我们并不关心与子进程交换实际数据,我们只想在它们退出时得到通知。为此,我们利用一个事实:当一个进程结束时,内核会关闭它的所有文件描述符。反过来,当一个分叉的进程继承文件描述符时,只有当所有该描述符的副本都关闭时,这个文件描述符才被认为是关闭的。因此,我们设置一个管道,其写端会被a.py生成的所有进程继承。这些进程不需要知道这个文件描述符的存在,唯一重要的是,当它们全部结束时,管道的写端会关闭。这时在管道的读端执行os.read()就会停止阻塞,并返回一个长度为0的字符串,表示文件结束。

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

  • os.pipe()和第一个print之间的部分是os.system()的实现,唯一的区别是它在子进程中关闭了管道的读端。(这是必要的——简单调用os.system()会保持读端打开,这会导致父进程的最终读取无法正常工作。)

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

  • if pid == 0:这个分支在子进程中运行,只执行./a.py。这里的“执行”意味着它运行指定的可执行文件而不会返回os._exit()只是为了防止execlp失败时的情况(在Python中可能不必要,因为execlp失败会引发异常并退出程序,但还是保留了)。程序的其余部分在父进程中运行。

  • 父进程关闭管道的写端(否则尝试从读端读取会导致死锁)。os.waitpid(pid)是父进程通常通过os.system()等待a.py的方式。在我们的例子中,调用waitpid并不是必须的,但这样做是个好主意,可以防止僵尸进程的产生。

  • os.read(r, 1)是关键所在:它尝试从管道的读端读取最多1个字符。由于没有人会向管道的写端写入数据,因此读取会阻塞,直到管道的写端关闭。由于a.py的子进程对继承的文件描述符一无所知,关闭的唯一方式是内核在相应进程结束后进行关闭。当所有继承的写端描述符都关闭时,os.read()会返回一个长度为0的字符串,我们会忽略它并继续执行。

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

撰写回答