🧦

fork 後に子プロセスにパイプを渡す

2024/10/04に公開

Ruby を前提として Ruby スクリプトを使用した例を書きますが、他の言語でも同様のことが言えるはずです。

前提: パイプとは

UNIX にはパイプという便利な機能があり、関連するプロセス間でデータをやり取りできます。
例えば以下の Ruby プログラムを実行すると、親プロセスで書き込んだ"Hello, world!"という文字列を、子プロセスで受け取って表示します。

r, w = IO.pipe

fork do
  while line = r.readline
    puts "Child: #{line}"
  end
rescue EOFError
  exit
end

r.close

w.puts "Hello, world!"
w.close

このコードの動きをシーケンス図に表すと以下のようになります。

「関連するプロセス間」というのが重要になります。パイプがデータをやり取りできるのは、forkによる親子関係があるプロセスのみです。というのも、パイプ自身をプロセス間で共有するためには、fork時にパイプのファイルディスクリプタ(FD)の複製が共有される必要があるためです。

つまりforkによる親子関係がないプロセス間ではパイプ自身が共有されないため、基本的にはパイプを用いたプロセス間通信は行えません。

問題: fork 後にパイプを共有したい

直接の親子関係があるが、fork後に生成したパイプを親から子に渡したいケースを考えてみましょう。シーケンス図に表すと次のようになります。

パイプはfork時に共有されるため、fork後に生成したパイプは子プロセスに共有できず、このような処理は不可能なように思えます。

解決策: UNIX ドメインソケットを使う

これは UNIX ドメインソケットを使うことで解決できます。

UNIX ドメインソケットはバイトストリームだけでなく、FDを送受信できます。この機能を利用すると、先の問題は次のように実装できます。

require 'socket'

# FD を送受信するための UNIX ドメインソケットを用意
sock_parent, sock_child = UNIXSocket.socketpair

fork do
  sock_parent.close
  # パイプを受信
  r = sock_child.recv_io

  while line = r.readline
    puts "Child: #{line}"
  end
rescue EOFError
  exit
end

r, w = IO.pipe

# パイプを送信
sock_parent.send_io(r)

r.close

w.puts "Hello, world!"
w.close

fork後にIO.pipeによって生成されたパイプを子プロセスに送信し、そのパイプを通じてメッセージの送受信を行っています。

なお FD の送受信にはUNIXSocket#send_io, UNIXSocket#recv_ioメソッドを、通信に使う UNIX ドメインソケットの生成にはUNIXSocket.socketpairメソッドを使用しています。
socketpairによって生成されるソケットはファイルシステム上にソケットファイルを必要としないため、取り回しがしやすく便利です。

実際のユースケース

さて、先程の例を見て「UNIX ドメインソケットを事前に共有するのなら、わざわざIO.pipeで生成したパイプで通信せずとも UNIX ドメインソケットを通信に使用すればよいのでは」と思った方もいるかも知れません。
先程の例ではそれはそのとおりです。UNIX ドメインソケットを使って"Hello, world!"という文字列を送受信できます。ですが今回はもう少し複雑なユースケースがありました。

reforking

Ruby の HTTP サーバーである Puma や pitchfork では、reforking と呼ばれるテクニックが使われています。

これらの HTTP サーバーは、メインのプロセスと、実際に HTTP リクエストを処理する worker プロセスに分かれています。ナイーブな実装を考えると、これらの worker プロセスはメインプロセスからforkして生成されるように思えます。

# ナイーブな実装のイメージ

puma main process
\_ puma worker process 0
\_ puma worker process 1
\_ puma worker process 2
\_ puma worker process 3

しかし Puma や pitchfork は Copy on Write の効果を最大化するために、リクエストを何回か捌いた worker プロセスから、他の worker プロセスを fork しています(Puma では現在オプショナルな機能です)。これが reforking と呼ばれるテクニックです。

# reforking のイメージ
puma main process
\_ puma worker process 0
   \_ puma worker process 1
   \_ puma worker process 2
   \_ puma worker process 3

reforking と今回の問題

メインプロセスと worker プロセスの間で、IO.pipeを使って通信をしていると仮定しましょう。(Puma や pitchfork がどのように通信を行っているかは分かりませんが、今回私が実装を検討しているケースでは、IO.pipeの使用を考えています。)

すると今回の記事で紹介した問題と同様の問題が浮かび上がります。つまり、reforking で子 worker プロセスからforkされる孫 worker プロセスのためのパイプは、最初に子 worker プロセスをforkした後に必要となります。
fork後にメインプロセスと孫 worker プロセスで、パイプをうまく共有する必要があるのです。

対応方法

このケースも先の解決策と同様に、UNIX ドメインソケットを使用して解決できます。具体的には次のコードで実装しました。

require 'socket'

def log(msg)
  puts "[#{Process.pid}]: #{msg}"
end

def run_child(sock)
  log "Child started"

  while io = sock.recv_io
    fork do
      sock.close
      run_grandchild(io)
    end
  end
rescue EOFError, SocketError
  log "Child exiting"
  exit
end

def run_grandchild(io)
  log "Grandchild started"
  while line = io.readline
    log "Grandchild received: #{line}"
  end
rescue EOFError
  log "Grandchild exiting"
  exit
end

sock_parent, sock_child = UNIXSocket.socketpair

fork do
  sock_parent.close
  run_child(sock_child)
end

sock_child.close

writers = 3.times.map do |i|
  r, w = IO.pipe
  log "Parent sending IO to launch grandchild #{i}"
  sock_parent.send_io(r)
  r.close
  w
end

system "pstree #{Process.pid}"

writers.each.with_index do |w, i|
  w.puts "Hello, #{i} grandchild!"
  w.close
end

これを実行すると、次のような出力が得られます。

[47164]: Parent sending IO to launch grandchild 0
[47164]: Parent sending IO to launch grandchild 1
[47164]: Parent sending IO to launch grandchild 2
[47177]: Child started
[47179]: Grandchild started
[47180]: Grandchild started
[47181]: Grandchild started
-+= 47164 kuwabara.masataka ruby test.rb
 |-+- 47177 kuwabara.masataka ruby test.rb
 | |--- 47179 kuwabara.masataka ruby test.rb
 | |--- 47180 kuwabara.masataka ruby test.rb
 | \--- 47181 kuwabara.masataka ruby test.rb
 \-+- 47178 kuwabara.masataka pstree 47164
   \--- 47182 root ps -axwwo user,pid,ppid,pgid,command
[47180]: Grandchild received: Hello, 1 grandchild!
[47181]: Grandchild received: Hello, 2 grandchild!
[47179]: Grandchild received: Hello, 0 grandchild!
[47180]: Grandchild exiting
[47181]: Grandchild exiting
[47179]: Grandchild exiting
[47177]: Child exiting

fork後に生成したパイプが UNIX ドメインソケットを経由して孫プロセスに届き、メインプロセスと孫プロセス間で通信できていることがわかります。

なおこの手法はまだ上記のプロトタイプで検証したのみで、本番コードとしてはまだ実装していません。

別解

別解として、IO.pipeによって生成されるパイプではなく、mkfifoによって生成される名前付きパイプ(FIFO)やソケットファイルを必要とする UNIX ドメインソケットを使用するという手段もあります。

これらはファイルシステム上に特殊なファイルを作ることでプロセス間通信を実現します。そのためそれらのファイルパスの生成ルールをあらかじめ共有しておけば、プロセス間に親子関係がなくても通信が行えます。例えばN番目の worker のための名前付きパイプは/tmp/myapp-Nという名前にする、というルールにすれば、メインプロセスと worker はそれぞれそのファイルをopenすれば通信が行えます。

このようにファイルシステム上のファイルを通信経路に使うことで、通信経路の共有が簡単になります。
一方で作成するファイルの権限をよく考えないと、意図しないユーザーからファイルがアクセスされてしまうかもしれません。またプロセスの終了時にファイルを適切に削除する必要も生まれます。

このような煩雑さを避けるため、socketpairで生成される UNIX ドメインソケットを使用して FD のやり取りを行う方法の採用を検討しています。

参考文献

今回の実装を考えるにあたっては、Linux プログラミングインターフェースを大きく参考にしました。
https://www.oreilly.co.jp/books/9784873115856/

主に参考にしたのは次の章です。

  • 44章 パイプとFIFO
  • 57章 ソケット:UNIXドメイン 57.5 接続済みソケットペアの作成: socketpair()
  • 61章 ソケット:応用 61.13.3 ファイルディスクリプタの送受信
Money Forward Developers

Discussion