no-image

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

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

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

forkとは

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

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

forkしてみる

Pythonでforkをして、その挙動を確認してみます。

Pythonではos.fork()関数を使うことで、プロセスをフォークできます。
以下のようなコードを書いてみました。

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

その後、forkを実行し、その戻り値によって条件分岐させます。

さて、この関数を実行したら何が表示されるのでしょうか。
「子」の方か、「親」の方か、どっちが表示されるのでしょうか。

実行する

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

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

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

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

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

これによってif文の条件分岐の両方が実行されます。

ちなみに、OSのリソース枯渇などによりforkに失敗すると-1が返ります。

内部実装を見る

この関数はCPythonではどのように定義がされているんだろうと思って少し調べてみました。
ですが、Pyhon上では定義されてないようです(ちゃうかも)

【参考】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コマンドのたくさんのオプションについて詳細に解説してあり、とても参考になりました。

【参考】

よく使われるオプションはauxらしいですが、これはPPIDが表示されないので今回はalxを使っていきます。

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

では、以下のコマンドを実行してみます。

$ 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するべき対象がいないと言われています。

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

悲しいですね・・。

ゾンビプロセス

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

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

  • 子よりも親が先に死ぬ(「孤児プロセス」と呼ぶこともある)
  • 子が死んだ後、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とゾンビプロセス プロセスの適切な扱い方を再確認した – えいのうにっき