fork 後に子プロセスにパイプを渡す
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
によって生成されるソケットはファイルシステム上にソケットファイルを必要としないため、取り回しがしやすく便利です。
- https://docs.ruby-lang.org/ja/latest/method/UNIXSocket/i/recv_io.html
- https://docs.ruby-lang.org/ja/latest/method/UNIXSocket/i/send_io.html
- https://docs.ruby-lang.org/ja/latest/method/UNIXSocket/s/pair.html
実際のユースケース
さて、先程の例を見て「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 プログラミングインターフェースを大きく参考にしました。
主に参考にしたのは次の章です。
- 44章 パイプとFIFO
- 57章 ソケット:UNIXドメイン 57.5 接続済みソケットペアの作成: socketpair()
- 61章 ソケット:応用 61.13.3 ファイルディスクリプタの送受信
Discussion