[Python] multiprocessing 備忘録

開始方法

multiprocessingは3つのプロセス開始方法を持ちます。
set_start_method()get_context()を使って以下の方法を指定します。

fork

使っているOSがPOSIX系であればデフォルトの開始方法はこれです。
はじめに書きましたが、forkはプロセスのクローンを作ります。
forkによって作成された子プロセスは親プロセスのデータセグメントをすべて引き継ぐため、親プロセスが巨大なデータを持ってるとメモリを圧迫する可能性があります。

spawn

WindowsのPythonを使っている場合、デフォルトの開始方法はこれです。
「子プロセスはプロセスオブジェクトの run() メソッドの実行に必要なリソースのみ継承します。」ということで、Processオブジェクトの実行に与えたものだけが子プロセスに渡されます。
インタラクティブシェルでこれをやろうとするとAttributeErrorになるのでなんらかのオブジェクトがPickle化、あるいは復号できなかったとかそういうことなんだと思います。理由については深入りしてません。

速度はforkやforkserverに劣るとのこと。理由はよくわかりませんがコンテキストのPickle化とかいろいろするからかな。詳しい人いたら教えてください。なんでもしま(ry

forkserver

forkserverはプロセスを作るためのプロセスを作り、それ経由でforkを行います。
spawnと同様に不要なリソースを継承しません。調べてないから憶測ですが最初のプロセスはspawnで作ってるのかな?
なのでインタラクティブシェルでやると概ね死にます。

ではメモリ使用量について調べてみましょう。以下をprocess.pyとして保存します。ここではset_start_methodを使います。

# coding: utf-8
import os
import sys
import time
import multiprocessing as mp
 
method = sys.argv[1]
 
if __name__ == '__main__':
    l = list(range(1000000))
    mp.set_start_method(method)
    # 20秒間スリープするだけのプロセス
    p = mp.Process(target=time.sleep, args=(20,))
    p.start()
    print('{}: {} -> {}'.format(method, os.getpid(), p.pid), end=' child:')
else:
    # 子プロセスの場合は名前空間を表示する
    print(__name__)

親子関係を表示して20秒間スリープするだけのプロセスを作るプログラムです。第一引数にプロセス開始方法を指定します。
サイズが100万のリストはただ持っているだけ使いません。生成方法ごとのメモリ使用量を比較するために持っています。

これらを同時に実行してそのメモリ使用量を比較してみましょう。

ターミナル1 ターミナル2
$ python3 process.py fork &       # [1] 3968
fork: 3968 -> 3969 child:

$ python3 process.py spawn &      # [2] 3970
spawn: 3970 -> 3972 child:__mp_main__

$ python3 process.py forkserver & # [3] 3973
forkserver: 3973 -> 3976 child:__mp_main__

[1]   Done                    python3 process.py fork
[2]-  Done                    python3 process.py spawn
[3]+  Done                    python3 process.py forkserver
$ ps aux|grep python3

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND # この行は本来表示されない
vagrant   3968  0.6  9.9  72584 49780 pts/1    S    12:32   0:00 python3 process.py fork
vagrant   3969  0.0  9.6  72584 48216 pts/1    S    12:32   0:00 python3 process.py fork

vagrant   3970  0.8 10.1  75548 50652 pts/1    S    12:32   0:00 python3 process.py spawn
vagrant   3971  0.2  2.1  35028 10816 pts/1    S    12:32   0:00 /usr/bin/python3 -c from multiprocessing.semaphore_tracker import main;main(3)
vagrant   3972  0.3  2.1  35152 10972 pts/1    S    12:32   0:00 /usr/bin/python3 -c from multiprocessing.spawn import spawn_main; spawn_main(tracker_fd=4, pipe_handle=6) --multiprocessing-fork

vagrant   3973  7.0 10.8  91356 54024 pts/1    S    12:32   0:00 python3 process.py forkserver
vagrant   3974  1.5  2.1  35024 10912 pts/1    S    12:32   0:00 /usr/bin/python3 -c from multiprocessing.semaphore_tracker import main;main(3)
vagrant   3975  3.0  2.9  50896 14576 pts/1    S    12:32   0:00 /usr/bin/python3 -c from multiprocessing.forkserver import main; main(3, 5, ['__main__'], **{'sys_path': ['/home/vagrant', '/usr/lib/python35.zip', '/usr/lib/python3.5', '/usr/lib/python3.5/plat-x86_64-linux-gnu', '/usr/lib/python3.5/lib-dynload', '/usr/local/lib/python3.5/dist-packages', '/usr/lib/python3/dist-packages']})
vagrant   3976  0.0  2.2  51028 11460 pts/1    S    12:32   0:00 /usr/bin/python3 -c from multiprocessing.forkserver import main; main(3, 5, ['__main__'], **{'sys_path': ['/home/vagrant', '/usr/lib/python35.zip', '/usr/lib/python3.5', '/usr/lib/python3.5/plat-x86_64-linux-gnu', '/usr/lib/python3.5/lib-dynload', '/usr/local/lib/python3.5/dist-packages', '/usr/lib/python3/dist-packages']})

forkで生成された子プロセスだけがメモリを多く消費しているのがわかりますね。

forkはプロセスをコピーした直後からプログラムが再開されますが、spawnはプログラムを最初からやり直します。つまりspawnで生成された子プロセスは親プロセスと同じコードが実行されうるということです。
子プロセスで実行されたくないコードは「if __name__ == ‘__main__':」のようにして保護する必要があります。このプログラムでは巨大なリストを生成している箇所がこれに該当します。
(ターミナルに出力されているように子プロセスの名前空間は「__mp_main__」となるので「__main__」と比較することで分岐できるというわけです)

ちなみに、さきほど説明した「set_start_method()」は一度しか呼ぶことが許されていません(どうやら子プロセスでもこの情報は共有されているっぽい)。そのためこの関数をコールする箇所は保護が必須です。

shimizukawa and tell_k に多謝

おまけ

CPUの数

マルチコアCPUの場合は下記のようにしてCPUの数を得られます。プロセスをプールする場合はこの数に合わせるのが効率的と言われています。
実際、デフォルトだとCPU数になります。

>>> import multiprocessing
>>> multiprocessing.cpu_count()
4

ゾンビプロセス

Processによって開始されたプロセスはjoinしないとゾンビプロセスになります。

import time
from multiprocessing import Process
 
p = Process(target=time.sleep, args=(3, ))
p.start()
print(p.pid)
time.sleep(20)

これをzonbie.pyとして実行すると..

vagrant   2307  0.7  0.4  32040 10144 pts/1    S+   13:51   0:00 python3 zonbie.py
vagrant   2308  0.0  0.0      0     0 pts/1    Z+   13:51   0:00 [python3] 
vagrant   2312  0.0  0.0  16572  1980 pts/0    S+   13:52   0:00 grep python

プロセステーブルは親プロセスが子プロセスの終了ステータスを認識すれば更新されるので「p.is_alive()」などで状態を確認してもゾンビプロセスは解消できます。

OSによっては確認できなかったりします(Macとかね)。
メインプロセス終了時に掃除されるみたいです。

以上。はー、長かったー。
間違い等あったら教えてください。

1 2 3