🌟

【Python入門】パイプで親子プロセス間通信をしてみる(親→子)

に公開

はじめに

アプリケーションプロセスが扱うデータはどのように他のプロセスに渡されるのか。
その方法は、パイプ・名前付きパイプ・ソケットなどさまざまありますが、この記事では、パイプにフォーカスして基本を解説していきます。

パイプとは何か

パイプは親子関係にあるプロセスがデータを「一方通行」で送受信する、プロセス間通信の仕組みです。
片方のプロセスが書き込んだデータを、もう片方のプロセスが読み込んで、プロセス間でのデータのやりとりを実現します。

開発環境

  • OS: macOS Sequoia 15.6.1
  • PC: MacBook Pro(M2チップ)
  • エディタ: VS Code
  • 言語: Python 3.13.5
  • 使用ライブラリ: 標準ライブラリのみ(os)

パイプを使って単方向通信を実装

親プロセスから子プロセスへデータを送信
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())

実装のポイント

前提1: PID

os.fork() によって、2つのプロセスが作られます。

  • 親プロセス: pid > 0
  • 子プロセス: pid == 0

このとき両者は 同じファイルディスクリプタを参照できるため、パイプを介した通信が可能になります。

前提2:os.fdopen()

これはFDを、Pythonのファイルオブジェクトに変換するための関数です。
上記の実装コードでいうと、変数pipeには、ファイルオブジェクト(インスタンスへの参照)が格納されています。

1. pipe()システムコール

このシステムコールによってOSが行なうこと

  1. カーネル内にパイプ用のバッファ(メモリ領域)を確保する
  2. そのファイルにアクセスできるよう、ファイル記述子を2つ発行する(以降、ファイル記述子を「FD」と表記します)
    ※1つ目が読み込み用で、2つ目が書き込み用です。
  3. FDをプロセスをタプル形式で返す

プロセスは FD を通じてこのバッファにアクセスします。

イメージ図

※本来なら見やすいよう、draw.ioなどを使って見やすくする必要がありますが、工数の観点から自分の理解を整理するために書いたメモを共有させていただきました。見づらい点、申し訳ありません。

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 を共有できます。

3. 親プロセスの処理

  1. 読み込み用FD(r)を閉じる
    使用しないFDは閉じます。
    これによって、データの一方向性を担保します。
  2. 実際に送信するメッセージを記述
  3. 書き込み用FD(w)・メッセージを指定し、write()システムコールによってパイプ専用バッファに書き込む
    このとき、文字列をバイト列にエンコードする必要があります(例:UTF-8)
  4. 書き込み用FD(w)を閉じる
    これによって、子プロセス側での読み取りを可能にします。
    子プロセス側で pipe.read() でデータを読み込めるのは、この書き込み終了によって実現します。
    子プロセスに「これ以上書き込むデータはありません」(End Of File)と知らせるからです。

4. 子プロセスの処理

  1. 書き込み用FD(w)を閉じる
    使用しないFDは閉じます。
    これによって、データの一方向性を担保します。
  2. os.fdopen(r) を使って読み込み用のファイルオブジェクトを取得
    デフォルトはテキストモードなので、自動的にデコードされ文字列として扱えます。
    ※ここで、バイト列から文字列へのデコードが必要になりますが、記述していないのは、fdopen()がデフォルトではテキストモードであるためです。これによって、特段指定しなければバイト列を自動的に文字列に変換してくれます。
    したがって、変数fには文字列が入っています。
  3. read() でパイプからデータを受け取る
    親が w を閉じたタイミングで EOF が通知され、読み取りが完了します。
    親プロセスから渡したデータ(文字列)が表示されていればパイプを使ったプロセス間通信が成功です。

実装中のエラー

出力結果
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()))

with 構文

上記の実装コードでは、書き込み・書き込みFDを閉じる動作を別々に記述しました。
しかし、これだとFDを閉じ忘れてしまうリスクがあります。
そこで役に立つのが with 構文です。

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()が自動的に呼び出されるようになっています。

まとめ

Pythonのモジュールを使って、プロセス間通信を実装したことによって、データの流れ方を理解することができます。
この他、パイプによって双方向通信を行なう方法もあります。この場合は、パイプを2つ作成する必要があります。詳しい点については、別の記事でまとめていきたいと思います。

最後までお読みいただき、ありがとうございました。

参考URL

https://docs.python.org/ja/3/library/os.html

Discussion