🌊

rubyのOpen3でハマったところ

2023/09/04に公開

Open3とは

rubyのOpen3とは、rubyプログラム上から別プログラムを実行し、その子プロセスの標準入力・標準出力・標準エラー出力にパイプをつないでくれるものです。
外部コマンドを実行する手段は他にもKernel#systemバッククォート記法などがありますが、標準出力以外やステータスが受け取れなかったり、処理が全て終了するまで待たされるなどの制約があります。
その点Open3は柔軟性が非常に高く、標準入力・標準出力・標準エラー出力を子プロセスの動作中にリアルタイムで読み書きすることなどが可能です。

問題

問題が起きたコードは、Open3.popen3を用いてrubyプログラム上から外部コマンドを実行し、その標準出力と標準エラー出力をリアルタイムで親プロセスの標準出力として出力するというもので、概ね以下のような実装でした。

require "open3"

Open3.popen3('command') do |stdin, stdout, stderr, wait_thr|
  stdin.close_write # 標準入力に書き込むことはないので閉じる

  until stdout.eof? && stderr.eof?
    readable_ios, = IO.select([stdout, stderr])
    readable_ios.each do |io|
      io.each do |line|
        puts line
      end
    end
  end

  wait_thr.value # 子プロセスの終了を待ち、終了ステータスを返す
end

この実装は、子プロセスが正常に動作する場合は問題ありませんでした。
しかし、子プロセスでエラーが生じた場合に、子プロセスと親プロセスの処理が進まなくなるという、いわゆるデッドロックの状態になってしまうことがありました。

要因

この問題の要因は主に2つあります。

1つ目は、pipeにはバッファサイズ(Linuxではデフォルトで65536バイト)があることです。
例えば、子プロセスから標準エラー出力へバッファサイズを超えて書き込みをしようとすると、処理がブロックされ子プロセスはそこで待機状態になります。これを解消するためには、親プロセスから子プロセスの標準エラー出力を読み込む必要があります。

2つ目は、IO#eachはIOオブジェクトが閉じるまで処理を抜けないという挙動です。(公式ドキュメントに記載はありませんでしたが実際そうなりました。)
すなわち、stdoutに対してeachを呼び出すと、stdoutが閉じるまでio.eachの処理は終わらず、したがってstderrの読み込み処理が実行されなくなっていました。

以上から、子プロセスでエラーが発生した際には以下のようなデッドロックの状態になっていました。

  • 子プロセス:標準エラー出力へバッファサイズを超えた書き込みをしようとして待機状態になっていた。
  • 親プロセス:子プロセスの標準出力先のパイプがcloseするのを待機していた。

ちなみに、公式ドキュメントにはまさに今回のようなケースに該当する注意書きがありました。

You should be careful to avoid deadlocks. Since pipes are fixed length buffers, Open3.popen3("prog") {|i, o, e, t| o.read } deadlocks if the program generates too much output on stderr. You should read stdout and stderr simultaneously (using threads or IO.select).
(訳)デッドロックを避けるよう注意する必要がある。パイプは固定長のバッファであるため、プログラムが標準エラーに多くの出力を生成すると、Open3.popen3("prog") {|i, o, e, t| o.read } がデッドロックする。(スレッドやIO.selectを用いて)stdoutとstderrを同時に読み込むべきである。

また、IO#eof?に関してもデッドロックに陥る危険性があります。
IO#eof?はIOオブジェクトへ書き込みが生じるか閉じるまでブロックします。そのため、もし子プロセスが標準出力を出力することなく、標準エラー出力をバッファサイズ以上出力しようとした場合、親プロセスはuntil stdout.eof? && stderr.eof?stdout.eof?で待ち状態にあるためデッドロックになります。

解決策

解決方法はいくつかあります。

1. stdoutとstderrをそれぞれ別のスレッドで読み込む

複数のスレッドを用いてstdoutとstderrを同時に読み込む方法です。
こうすればパイプバッファが一杯になって子プロセスが書き込み待ちでブロックされることはありません。
この実装が最も素直で分かりやすいかもしれません。

Open3.popen3('command') do |stdin, stdout, stderr, wait_thr|
  stdin.close_write

  stdout_thread = Thread.new do
    while (line = stdout.gets)
      puts line
    end
  end
  stderr_thread = Thread.new do
    while (line = stderr.gets)
      puts line
    end
  end
  [stdout_thread, stderr_thread].each(&:join)

  wait_thr.value
end

2. IO.selectを用いる

2つ目は、IO.selectを用いる方法です。
元のコードでもIO.selectを用いていたので、これはどちらかというとIO#eachIO#eof?などのブロッキング処理を用いないというところがポイントになります。

IO.selectは、第一引数に渡した読み込み対象のIOオブジェクトのいずれかが読み込み可能になるまで待機し、読み込み可能になったIOオブジェクトの配列を返します。
IO#read_nonblockでは、IOオブジェクトから読み込み可能なデータがあればそれを返し、なければIO::WaitReadableをraiseします。また、EOFに達した場合はEOFErrorをraiseします。

以下のコードでは、IO::WaitReadableがraiseされた場合は次のループへ進むことで、stdoutまたはstderrの読み込み待ち状態になることを防いでいます。
また、EOFErrorがraiseされた場合は、そのIOオブジェクトを対象の配列iosから削除することで、読み込みが終了したことを表しています。

Open3.popen3('command') do |stdin, stdout, stderr, wait_thr|
  stdin.close_write

  ios = [stdout, stderr]
  until ios.empty?
    readable_ios, = IO.select(ios)
    readable_ios.each do |io|
      puts io.read_nonblock(65536)
    rescue IO::WaitReadable
      # next
    rescue EOFError
      ios.delete(io)
    end
  end

  wait_thr.value
end

3. Open3.popen2eを用いる(ただしstdoutとstderrを分ける必要がない場合に限る)

stdoutとstderrを分けて取り扱う必要がない場合は、Open3.popen2eを用いることで簡単に解決できます。今回のユースケースではこの方法が最もシンプルでした。

Open3.popen2e('command') do |stdin, stdout_and_stderr, wait_thr|
  stdin.close_write

  while (line = stdout_and_stderr.gets)
    puts line
  end

  wait_thr.value
end

4. Open3.capture3やcapture2eを用いる(ただし子プロセスが終了しないと出力を取り出せない)

子プロセスの出力をリアルタイムで取り出す必要がない場合は、Open3.capture3Open3.capture2eを用いることで簡単に実現できます。
これらは、コマンドの実行完了まで待ち、stdoutとstderrの出力結果とコマンドの終了ステータスを返すメソッドです。

stdout_str, stderr_str, _status = Open3.capture3('command')
puts stdout_str + stderr_str
stdout_and_stderr_str, _status = Open3.capture2e('command')
puts stdout_and_stderr_str

ちなみに、Open3.capture3の内部実装を見ると、Open3.popen3を用いて複数スレッドでstdoutとstderrを読み込む方法を用いていることがわかります。

https://github.com/ruby/open3/blob/38904e204d92dbf92f7051af37e74365bd37e4d7/lib/open3.rb#L290-L308

参考

最後に、実際にOpen3を設計した方が書いたAPIデザインケーススタディという本があり、その中でOpen3の設計意図などが書かれていてとても勉強になりました。
例えば、Open3.popen3Open3.popen2eは適切に用いないとデッドロックの問題があり単純な用途として用いるには複雑すぎるため、Open3.capture3Open3.capture2eなどが実装されたという経緯などが書かれています。

また、こちらのブログ記事も言語はperlですが、同様の問題に関して解説されておりとても参考になりました。
https://kazuhooku.hatenadiary.org/entry/20100813/1281690025
今回の問題は、pipeのバッファサイズを考慮して適切に書き込みと読み込みをしなければデッドロックに陥るということなので、言語によらず同じ問題が発生する可能性があります。

Discussion