Pythonでプロセスをforkしてみる

Pythonで処理をもっと速く実行しようってなったときに、「PythonはGILの制限があるから、マルチスレッド化しても意味ないよね」となります。
ですので、マルチプロセスにして並列処理をしましょう、ということになったりしますが、ではマルチプロセス化ってどうやるんでしょうか。

そもそもプロセスってなんだろう。
Pythonよりもう少し手前からこの話題に入ってみます。

forkとは

今あるプロセスから、新しいプロセスを作るためにはシステムコールのforkを実行します。
forkをすることで、そのプロセスの複製を作ることができます。

「ビットコインが、ビットコインとビットコインキャッシュにハードフォークした」とか、gitにあるforkなどの概念と同じです。

forkしてみる

Pythonでforkをして、その挙動を確認してみます。
このためには、os.fork()を実行します。

os.getpid()でこのプログラムのpidをリストに追加します。
pidというのは、ProcessIDのことですね。

その後、forkを実行し、その戻り値によって条件分岐させます。
さて、この関数を実行したら何が表示されるのでしょうか。

「子」の方か、「親」の方か、どっちが表示されるのでしょうか。

実行する

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

なんと、プログラムを一回走らせただけなのに、親も子も両方出力されています。
なんとも不思議です。
どうしてこんなことになるのでしょうか。

これは、os.fork()によって新たなプロセスが生まれ、2つの処理が走っているからです。

os.fork()の戻り値は以下のようになります。

  • 親プロセスでは子プロセスのpidが返る
  • 子プロセスでは0が返る

これによってif文の条件分岐の両方が実行されます。
ちなみに、OSのリソース枯渇などによりforkに失敗すると-1が返ります。

これはCPythonでは、どんなふうにos.fork()が定義がされているんだろうと思って少し調べてみました。
ですが、定義することなく使われてるっぽいです。(ちゃうかも)
【参考】cpython/os.py at 3.7 · python/cpython

なので、たぶんCの標準関数を使っているのかと思って、それっぽいのを探してみたらそれっぽいのがありました。
【参考】getpid() – C言語例文集

実際はどうなっているのかわかりませんが、ここのCのコードでは、switch文で実装されています。
こうやって見ると結構シンプル。
上述したようなものになっていることが見て取れます。

次に、結果のリストを見てみます。
親から見ると、リストの中身は両方共PIDは同じ(今回はともに38048)ですが、子プロセスから見ると、異なるPID(今回は3804838064)が見えていることがわかります。

つまり、これは「1つの親プロセスから、新しく子プロセスが作られていること」と、「これらのプロセス同士はプロセス識別子が異なるので、この2つのプロセス間ではメモリコンテキストを共有していないこと」がわかります。

確認する

本当にこのような結果になっているのかをターミナル上で確認してみます。
上のコードを実行中に、ctrl-zでサスペンドしてpsコマンドで確認することができます。

psコマンド

psコマンドで現在動作しているプロセスの一覧を見ることができます。
ちなみに’ps’というのは、’process status’の略みたいですね。

以下の記事はpsコマンドのたくさんのオプションについて詳細に解説してあり、とても参考になりました。

【参考】
psコマンドについて詳しくまとめました 【Linuxコマンド集】
Macターミナルコマンド「ps」のオプションまとめと使用方法 | D-Box

よく使われるオプションはauxらしいですが、これはPPIDが表示されません。
ほぼ何も知らないのですが、以下の記事にもある通り、alxのほうがいいかもと思ったので、alxを使っていきます。

【参考】psコマンドはalxのがいいかも – uncertain world

では、以下のコマンドを実行してみます。
ps alxだけではたくさん表示されるので少し絞って出力させます。
$ ps alx | grep -e fork -e bash -e PPID

fork.pyのPIDやPPIDを見てみると、親子関係が見て取れます。
これらから以下のようなことがわかりますね。

  • 3行目のfork.pyプロセスが親で、4行目のfork.pyプロセスが子
  • 「3行目の親fork.pyプロセス」の親はbash

これは、先程のPythonプログラムの結果と一致しています。

同期の話

プロセスには同期という概念がありますが、waitを実行することでプロセス間の同期を取れます。
先ほどのコードに少し手を加えたコレを実行してみます。

コレの結果は、

こんな感じにました。
つまり、子プロセスが全て実行されるのを待ってから、親プロセスが終了しています。

試しに、os.wait()time.sleep(10)を実行する行を入れ替えてみると、こんなエラーが出ました。
ChildProcessError: [Errno 10] No child processes
このプログラムの「子プロセス」にはまだ子供がいないので、waitするべき対象がいないと言われています。

こんな感じに、プロセスでは基本的に「親よりも先に子が死ぬ」んですね。
悲しいですね・・。

余談ですが、pythonのwait()に関して割と新しめの記事があったので試してみました。
挙動が気持ち悪くてびっくりしました。
是非試してみてください。

ゾンビプロセス

ゾンビプロセスというのがあります。

ゾンビプロセスが生まれるのは、以下のようなことが起きたときです。

  • 子よりも親が先に死ぬ←「孤児プロセス」と呼ぶこともあるらしい
  • または、子が死んだ後、waitせずに親が死ぬ

先に親が死んだ子はゾンビになるんですね。
悲しいですね・・。

ゾンビプロセスというのは、役目を終えているのにメモリを開放しないプロセスのことです。
生きているのに死んでいます。
なので、ゾンビなのです。

親が死んだら、じゃあこの子のPPIDは何になるのかというと1になります。
このPIDが1のプロセスを「initプロセス」と呼びます。
これは、$ pstreeの出力のてっぺんにいます。

ちなみに、$ ps alxで確認してみると、このinitプロセスのPPIDは0です。
強そうですね。

つまり、親が死んだゾンビの子はinitプロセスの養子になるのです。
悲しいですね・・。

このようなゾンビプロセスが増えてくると、有限のリソースを再利用できなくなったりしてで少し厄介です。
これに対処するためには、killコマンドを使ってこれらを殺すか、システムをシャットダウンなどをしてinitプロセスを終了させるかです。

親が死に、ゾンビになった子は僕らに殺される運命にあるのです。
無慈悲ですね・・。

コピーオンライトの話

Copy on Write。そのままの意味ですね。
パフォーマンスの最適化のために、プロセスはforkされただけでは、同じメモリ空間を共有しています。

これらのプロセスに何かがwriteされ、プロセス間に差分が出たときに初めてコピーされます。
てことは、読み込むだけならまだコピーされていない?(わからん)

なんでこんな事になっているのかというと、前述した通りforkというのは、メモリ内のデータをまるごと複製することですが、この処理は本来は時間のかかるコストの掛かるもののはずです。
しかし、コピーしたのに、その後、実行するプログラムによって上書きされる部分もあり、その場合はコピーしただけ無駄になる部分も出てきます。
これはなんとなくもったいない。
なので、とりあえず最初はメモリ空間だけ共有しておいて、差分が出たときに初めてちゃんとコピーするという仕様になっているっぽいです。

関数型の遅延評価みたいですね。

【参考】
コピー・オン・ライト | 日経 xTECH(クロステック)

参考

17.2. multiprocessing — プロセスベースの並列処理 — Python 3.6.5 ドキュメント
process2.md
forkとwaitとゾンビプロセス
プロセスの適切な扱い方を再確認した – えいのうにっき

コメントを残す