1. はじめに
アプリケーションプロセスが扱うデータはどのように他のプロセスに渡されるのか。
その方法は、パイプ・名前付きパイプ・ソケットなどさまざまありますが、この記事では、実装中に発生したエラーなどについても言及しながら、パイプにフォーカスして基本を解説していきます。
2. 開発環境
- OS: macOS Sequoia 15.6.1
- PC: MacBook Pro(M2チップ)
- エディタ: VS Code
- 言語: Python 3.13.5
- 使用ライブラリ: 標準ライブラリのみ(os)
3. パイプとは何か
パイプは親子関係にあるプロセスがデータを「一方通行」で送受信する、プロセス間通信の仕組みです。
片方のプロセスが書き込んだデータを、もう片方のプロセスが読み込んで、プロセス間でのデータのやりとりを実現します。
4. パイプを使って単方向通信を実装
import os
r,w = os.pipe()
pid = os.fork()
# 親プロセス
if pid > 0:
os.close(r)
message = "Parent PID: {}".format(os.getpid())
print("Send this message: '{}'".format(message))
os.write(w, message.encode('utf-8'))
os.close(w)
# 子プロセス
else:
os.close(w)
print("Child PID: {}".format(os.getpid()))
pipe = os.fdopen(r)
print("Incoming message: ", pipe.read())
5. 実装のポイント
前提1: PID
os.fork() によって、2つのプロセスが作られます。
- 親プロセス: pid > 0
- 子プロセス: pid == 0
このとき両者は 同じファイルディスクリプタを参照できるため、パイプを介した通信が可能になります。
前提2:os.fdopen()
これはFDを、Pythonのファイルオブジェクトに変換するための関数です。
上記の実装コードでいうと、変数pipeには、ファイルオブジェクト(インスタンスへの参照)が格納されています。
5-1. pipe()システムコール
このシステムコールによってOSが行なうこと
- カーネル内にパイプ用のバッファ(メモリ領域)を確保する
- そのファイルにアクセスできるよう、ファイル記述子を2つ発行する(以降、ファイル記述子を「FD」と表記します)
※1つ目が読み込み用で、2つ目が書き込み用です。 - FDをプロセスをタプル形式で返す
プロセスは FD を通じてこのバッファにアクセスします。
イメージ図
※本来なら見やすいよう、draw.ioなどを使って見やすくする必要がありますが、工数の観点から自分の理解を整理するために書いたメモを共有させていただきました。見づらい点、申し訳ありません。
5-2. fork()システムコール
上記の例で言うと、プロセスAは読み込み用のFD(15)と書き込み用のFD(16)を受け取ります。
ここで、fork()システムコールによって、子プロセスを生成します。
このとき、子プロセスBは、カーネル空間において、プロセスAのFDテーブルのコピーを独自に取得します。
これにより、同じ内容のFDテーブルを取得し、同じFDをプロセス空間で保持することになります。
結果として、プロセスAとプロセスBが同じメモリバッファを共有することになります。
また、以下の記述の順番が重要になります。
import os
r,w = os.pipe() # 先
pid = os.fork() # 後
先に pipe()
を呼んで FD を作り、その後で fork()
を行なうことで、親子が同じ FD を共有できます。
5-3. 親プロセスの処理
- 読み込み用FD(r)を閉じる
使用しないFDは閉じます。
これによって、データの一方向性を担保します。 - 実際に送信するメッセージを記述
- 書き込み用FD(w)・メッセージを指定し、
write()
システムコールによってパイプ専用バッファに書き込む
このとき、文字列をバイト列にエンコードする必要があります(例:UTF-8) - 書き込み用FD(w)を閉じる
これによって、子プロセス側での読み取りを可能にします。
子プロセス側でpipe.read()
でデータを読み込めるのは、この書き込み終了によって実現します。
子プロセスに「これ以上書き込むデータはありません」(End Of File)と知らせるからです。
5-4. 子プロセスの処理
- 書き込み用FD(w)を閉じる
使用しないFDは閉じます。
これによって、データの一方向性を担保します。 -
os.fdopen(r)
を使って読み込み用のファイルオブジェクトを取得
デフォルトはテキストモードなので、自動的にデコードされ文字列として扱えます。
※ここで、バイト列から文字列へのデコードが必要になりますが、記述していないのは、fdopen()がデフォルトではテキストモードであるためです。これによって、特段指定しなければバイト列を自動的に文字列に変換してくれます。
したがって、変数fには文字列が入っています。 -
read()
でパイプからデータを受け取る
親が w を閉じたタイミングで EOF が通知され、読み取りが完了します。
親プロセスから渡したデータ(文字列)が表示されていればパイプを使ったプロセス間通信が成功です。
5-5. 実装中のエラー
Send this message Parent PID: 55365
Child PID: # 子プロセスのPIDがない
Incoming message: Parent PID: 55365
原因
format()
を使うための波括弧の不足
# 修正前
print("Child PID: ".format(os.getpid()))
# 修正後
print("Child PID: {}".format(os.getpid()))
6. with 構文
上記の実装コードでは、書き込み・書き込みFDを閉じる動作を別々に記述しました。
しかし、これだとFDを閉じ忘れてしまうリスクがあります。
そこで役に立つのが with 構文です。
import os
r,w = os.pipe()
pid = os.fork()
# 親プロセス
if pid > 0:
os.close(r)
message = "Parent PID: {}".format(os.getpid())
print("Send this message: '{}'".format(message))
with os.fdopen(w, 'wb') as pipe:
pipe.write(message.encode('utf-8'))
# 子プロセス
else:
os.close(w)
print("Child PID: {}".format(os.getpid()))
with os.fdopen(r) as pipe:
print("Incoming message: ", pipe.read())
これによって、見た目がスッキリすることに加え、os.close(w)
の記述が不要になります。
この with 構文は、リソースの管理を自動化し、with ブロックを抜けると、os.close()が自動的に呼び出されるようになっています。
7. パイプを使って双方向通信を実装
import os
r1,w1 = os.pipe()
r2,w2 = os.pipe()
pid = os.fork()
if pid > 0:
os.close(r1)
os.close(w2)
# 親プロセスから子プロセスへ
with os.fdopen(w1, 'wb') as parent_to_child_pipe:
parentMessage = "Parent PID: {}".format(os.getpid())
print("Send this message for child: '{}'".format(parentMessage))
parent_to_child_pipe.write(parentMessage.encode('utf-8'))
# 子プロセスから親プロセスへ
with os.fdopen(r2) as child_to_parent_pipe:
print("Incoming message from child: ", child_to_parent_pipe.read())
else:
os.close(w1)
os.close(r2)
# 親プロセスから子プロセスへ
with os.fdopen(r1) as parent_to_child_pipe:
print("Incoming message from parent: ", parent_to_child_pipe.read())
print("Child PID: {}".format(os.getpid()))
# 子プロセスから親プロセスへ
with os.fdopen(w2, 'wb') as child_to_parent_pipe:
childMessage = "Child PID: {}".format(os.getpid())
print("Send this message for parent: '{}'".format(childMessage))
child_to_parent_pipe.write(childMessage.encode('utf-8'))
「with 構文を使わない」 → 「with 構文を使う」という順番で実装しました。
8. 実装のポイント
8-1. pipe()システムコールを2回実行
これによって、親プロセスから子プロセスにデータを流すためのパイプ、子プロセスから親プロセスにデータを流すためのパイプを作成することができます。
パイプは単方向にしかデータが流れないため、この方法によって双方向のデータ通信を実現します。
8-2. 使用しない FD は閉じることで一方向性を担保
8-3. with 構文で FD を自動的に閉じることができ、忘れによるブロックを防止
9. with 構文使用前に発生したエラー
import os
r1,w1 = os.pipe()
r2,w2 = os.pipe()
pid = os.fork()
if pid > 0:
os.close(r1)
os.close(w2)
parentMessage = "Parent PID: {}".format(os.getpid())
print("Send this message: '{}'".format(parentMessage))
os.write(w1, parentMessage.encode('utf-8'))
pipe2 = os.fdopen(r2)
print("Incoming message from child: ", pipe2.read())
else:
os.close(w1)
os.close(r2)
print("Child PID: {}".format(os.getpid()))
pipe1 = os.fdopen(r1)
print("Incoming message from parent: ", pipe1.read())
childMessage = "Send my PID: {}".format(os.getpid())
print("Send this message: '{}'".format(childMessage))
os.write(w2, childMessage.encode('utf-8'))
Send this message: 'Parent PID: 82079'
Child PID: 82080
^CTraceback (most recent call last):
Traceback (most recent call last):
File "/Users/mavo/practice/python/pipe/pipe.py", line 24, in <module>
print("Incoming message from parent: ", pipe1.read())
~~~~~~~~~~^^
KeyboardInterrupt
File "/Users/mavo/practice/python/pipe/pipe.py", line 16, in <module>
print("Incoming message from child: ", pipe2.read())
~~~~~~~~~~^^
9-1. 原因
書き込み用のファイルディスクリプタ(FD)が閉じられていないことによる
9-2. read()
の挙動
パイプは、書き込み側からデータが来なくなるまで、read()
がブロック(停止)するように設計されています。
これは、read()
がEOF(End Of File)を認識して、初めてパイプからデータを読み込むことができるようになっています。
そのため、書き込み用のFDが閉じられて初めてEOFを read()
側に通知し、データを読み込めるようになります。
9-3. read()
の後は os.close(pipe)
しなくていいのか?
-
read()
後でも FD を閉じる必要はある場合とない場合がある - 安全策としては明示的に閉じるのが推奨
9-3-1. パイプの EOF と read()
の関係
パイプの read()
は、書き込み側FDが全て閉じられるまでブロック(停止)します。
書き込み側が閉じられることで EOF が通知され、読み取り側はデータを受け取れるようになります。
つまり、書き込み側を閉じていないと read()
は永遠に待ち続ける可能性があるということです。
9-3-2. os.fdopen()
とファイルオブジェクトの自動クローズ
os.fdopen()
で作成したファイルオブジェクトは、プログラム終了時やガベージコレクション時に自動で閉じられます。
そのため、最終的には os.close()
で閉じる必要はないこともあるということです。
9-3-3. なぜ明示的に閉じるべきか
ガベージコレクションのタイミングは予測できません。
長時間動作するプロセスや複雑な通信では、閉じ忘れによるブロックのリスクがあります。
そこで、with 構文を使うことによって、オブジェクトのスコープを抜けると自動で close()
されるので手動管理よりも安全です。
修正後の実装コード
if pid > 0:
os.close(r1)
os.close(w2)
parentMessage = "Parent PID: {}".format(os.getpid())
print("Send this message: '{}'".format(parentMessage))
os.write(w1, parentMessage.encode('utf-8'))
os.close(w1) #追加
pipe2 = os.fdopen(r2)
print("Incoming message from child: ", pipe2.read())
else:
os.close(w1)
os.close(r2)
print("Child PID: {}".format(os.getpid()))
pipe1 = os.fdopen(r1)
print("Incoming message from parent: ", pipe1.read())
childMessage = "Send my PID: {}".format(os.getpid())
print("Send this message: '{}'".format(childMessage))
os.write(w2, childMessage.encode('utf-8'))
os.close(w2) # 追加
得られた学び
-
read()
システムコールは、パイプの書き込み側がすべて閉じられるまで、データの到着を無限に待ち続けること
read()
がパイプからのデータの読み込みを完了して処理を続けることができるようになるのは、通信の相手方がos.close()
を使って、パイプの書き込み側を閉じたとき -
os.close(w)は単にFDを閉じるだけでなく、パイプの読み取り側に「これ以上データは来ない」という通知を送る重要な役割を果たすこと
-
os.fdopen()
で作成されたファイルオブジェクトは、プログラムが正常に終了するか、ガベージコレクションによってオブジェクトが破棄されるときに自動的にclose()
されること -
with文が使えるのは、
os.fdopen()
で作成されたファイルオブジェクトに対してであること
まとめ
この記事では、
- 親プロセスから子プロセスにデータを渡す流れ
- 子プロセスから親プロセスにデータを渡す流れ
について解説しました。
パイプが単方向通信を実現する手段であることから、双方向の通信を実現するには、パイプが2つ必要だということがお分かりいただけたと思います。
最後までお読みいただき、ありがとうございました。
参考URL