概要

以下のコードは、os.fork()を使って子プロセスを生成して、それぞれの子プロセスは10~20秒(ランダム)待機して終了する、というものです。

import os
import sys
import random
import time
from typing import Tuple

# 子プロセスで実行する処理
def some_work() -> int:
    wait_time: int = random.SystemRandom().randint(10, 20)
    pid: int = os.getpid()

    print('Child process works for %d sec (PID:%s)' % (wait_time, pid))
    time.sleep(wait_time)

    return wait_time

if __name__ == '__main__':
    # 5つの子プロセスを生成する
    for i in range(5):
        pid: int = os.fork()
        if pid == 0 : # No pid. so this is child process!
            result: int = some_work()
            child_pid: int = os.getpid()
            print('Child process worked for %d sec. (PID:%s)' % (result, child_pid))
            sys.exit()

    print('Ended main process (PID: %s)' % os.getpid())

実行結果は

(gevent) [koji:gevent]$ python fork_process.py
Ended main process (PID: 4494)
Child process works for 15 sec (PID:4496)
Child process works for 20 sec (PID:4497)
Child process works for 17 sec (PID:4498)
Child process works for 10 sec (PID:4499)
Child process works for 10 sec (PID:4495)
(gevent) [koji:gevent]$ Child process worked for 10 sec. (PID:4499)
Child process worked for 10 sec. (PID:4495)
Child process worked for 15 sec. (PID:4496)
Child process worked for 17 sec. (PID:4498)
Child process worked for 20 sec. (PID:4497)

のようになります。

実行結果の最初にEnded main process (PID: 4494)と表示されていることからも、親プロセスは、子プロセスの終了を待つこと無く、すぐに終了していることが分かります。
子プロセスは親プロセスがすでに停止した状態で自分の処理を進めます。
psコマンドで確認すれば、親プロセスがすでに終了しているので、自動的にPPIDが1になってしまっていることが確認できます。

[koji:~]$ ps -C python -o pid,ppid,args
  PID  PPID COMMAND
 3847     1 python fork_process.py
 3848     1 python fork_process.py
 3849     1 python fork_process.py
 3851     1 python fork_process.py
 3852     1 python fork_process.py

ということで、親プロセスを全子プロセスが終了するまで待機させる方法を調べてみました。

waitpid

os.waitpid()で、生成した子プロセスIDをしていることで、その子プロセスが終了するまで待機することが出来ます。

import os
import sys
import random
import time
from typing import Tuple

# 子プロセスで実行する処理
def some_work() -> int:
    wait_time: int = random.SystemRandom().randint(10, 20)
    pid: int = os.getpid()

    print('Child process works for %d sec (PID:%s)' % (wait_time, pid))
    time.sleep(wait_time)

    return wait_time

if __name__ == '__main__':

    # 5つの子プロセスを生成する
    for i in range(5):
        pid: int = os.fork()
        if pid == 0 : # No pid. so this is child process!
            result: int = some_work()
            child_pid: int = os.getpid()
            print('Child process worked for %d sec. (PID:%s)' % (result, child_pid))
            sys.exit()
        else :
            os.waitpid(pid, 0)
    print('Ended main process (PID: %s)' % os.getpid())

ちゃんと全ての子プロセスが終わった後に親プロセスが終わりました。

(gevent) [koji:gevent]$ python fork_process.py
Child process works for 13 sec (PID:5157)
Child process worked for 13 sec. (PID:5157)
Child process works for 15 sec (PID:5183)
Child process worked for 15 sec. (PID:5183)
Child process works for 11 sec (PID:5213)
Child process worked for 11 sec. (PID:5213)
Child process works for 18 sec (PID:5222)
Child process worked for 18 sec. (PID:5222)
Child process works for 10 sec (PID:5271)
Child process worked for 10 sec. (PID:5271)
Ended main process (PID: 5156)
(gevent) [koji:gevent]$

が、実はこの書き方だと、os.fork()で子プロセスを1個生成する度に、その子プロセスが終わるまでos.waitpid()で待機してしまっているので、その子プロセスの処理が終了するまで、次の子プロセスの生成(os.fork())処理が実行されません。
そのため、実はこの書き方なのであればos.waitpid()じゃなくてos.wait()と書いても全く同じになります。

ただし、os.wait()の方は、プロセスIDを指定しません。何でもいいので自身が生成した子プロセスがどれか終了する度に値がreturnされます。
メイン処理を以下のように書き換えてみます。

if __name__ == '__main__':

    # 5つの子プロセスを生成する
    for i in range(5):
        pid: int = os.fork()
        if pid == 0 : # No pid. so this is child process!
            result: int = some_work()
            child_pid: int = os.getpid()
            print('Child process worked for %d sec. (PID:%s)' % (result, child_pid))
            sys.exit()

    os.wait()
    print('Ended main process (PID: %s)' % os.getpid())

実行結果は以下のようになります。

(gevent) [koji:gevent]$ python fork_process.py
Child process works for 20 sec (PID:6047)
Child process works for 19 sec (PID:6048)
Child process works for 13 sec (PID:6049)
Child process works for 12 sec (PID:6050)
Child process works for 11 sec (PID:6051)
Child process worked for 11 sec. (PID:6051)
Ended main process (PID: 6046)
(gevent) [koji:gevent]$ Child process worked for 12 sec. (PID:6050)
Child process worked for 13 sec. (PID:6049)
Child process worked for 19 sec. (PID:6048)
Child process worked for 20 sec. (PID:6047)

実際にos.wait()が、子プロセス(今回はPID:6051)が一つ終わったタイミングで値をreturnしてくれている、つまり親プロセスの処理が先に進んでいる事が分かります。

そしてos.wait()は、親プロセスが生成した子プロセスが存在しない場合、ChildProcessErrorを投げます。

>>> import os
>>> os.wait()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ChildProcessError: [Errno 10] No child processes
>>>

これらの動作を利用すれば自分で全ての子プロセスの終了を待つ処理を実装です。

自前で実装

import os
import sys
import random
import time
from typing import Tuple

# 子プロセスで実行する処理
def some_work() -> int:
    wait_time: int = random.SystemRandom().randint(10, 20)
    pid: int = os.getpid()

    print('Child process works for %d sec (PID:%s)' % (wait_time, pid))
    time.sleep(wait_time)

    return wait_time

# 自作の子プロセス待機処理
def wait_all() -> None:
    while True:
        try :
            status: Tuple = os.wait()
            print("Ended child process (PID: %s)" % status[0])
        except ChildProcessError:
            print('All child processes were done.')
            return

if __name__ == '__main__':

    # 5つの子プロセスを生成する
    for i in range(5):
        pid: int = os.fork()
        if pid == 0 : # No pid. so this is child process!
            result: int = some_work()
            child_pid: int = os.getpid()
            print('Child process worked for %d sec. (PID:%s)' % (result, child_pid))
            sys.exit()

    wait_all()
    print('Ended main process (PID: %s)' % os.getpid())

実行結果は以下のとおりです。

(gevent) [koji:gevent]$ python fork_process.py
Child process works for 10 sec (PID:7156)
Child process works for 12 sec (PID:7157)
Child process works for 13 sec (PID:7158)
Child process works for 11 sec (PID:7159)
Child process works for 20 sec (PID:7160)
Child process worked for 10 sec. (PID:7156)
Ended child process (PID: 7156)
Child process worked for 11 sec. (PID:7159)
Ended child process (PID: 7159)
Child process worked for 12 sec. (PID:7157)
Ended child process (PID: 7157)
Child process worked for 13 sec. (PID:7158)
Ended child process (PID: 7158)
Child process worked for 20 sec. (PID:7160)
Ended child process (PID: 7160)
All child processes were done.
Ended main process (PID: 7155)
(gevent) [koji:gevent]$

ちゃんと全ての子プロセスが終了するまで親プロセスが待機しています。
自作したwait_all()関数の無限ループ内では何も考えずに単にos.wait()を実行しています。
子プロセスが終わる度にos.wait()は一旦終わりますが、他にまだ子プロセスが残っている可能性があるので無限ループを継続させています。
もう子プロセスが無い状態になれば、ChildProcessErrorが発生するので、その時点でwait_all()関数を終了させています。
これで、子プロセスが全部終わるまで処理を中断することが出来ています。

まとめ

実際には子プロセスがハングしている状態などは別途対処が必要になるはずですので、単純にこのようなチェックでは不十分だと思います。
が、単純にどうすればPythonで子プロセスが終了するのが待機できるのだろう?と言う点が気になったのでまとめてみました。

なお、コードをご覧頂ければ分かると思いますが、変数と関数の戻り値にちゃんと型を指定しています。
mypy fork_process.pyと実行すれば、型情報に誤りがないかの確認が出来て非常に便利です。