Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: fork(2)がみんなに嫌われる理由(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Ruby: fork(2)がみんなに嫌われる理由(翻訳)

Shopify/pitchfork - GitHub

私がやりたいのは、Pitchforkに関する記事を書いて、これがどんな理由でできたのか、なぜ現在のような形になったのか、そして今後どうなるのかについて説明することです。しかしその前に、いくつか解説しておく必要があります。
なぜ多くの人がforkを過去の遺物呼ばわりし、下手をすると悪魔の作りしものであるかのような目で見るのでしょうか?forkはRubyエコシステムのどこにでもあるにもかかわらず、です。

システムプログラミングの経験者なら、おそらく本記事に書かれていることは既にだいたいご存知でしょう。

Rubyアプリケーションをproduction環境にデプロイしたことがある人なら、気づいていようといまいとfork(2)を操作したことがあるはずです。

あるいはPumaのworkerを設定したことがありますか?Pumaはこれらのワーカーをfork(2)で生成します(厳密にはRubyのProcess.forkメソッドで、これは背後のfork(2)システムコールに対応するRuby APIです)。

また、Rubyユーザーでなくても、PHP、Nginx、Apache HTTPd、Redisなどを使ったことがあれば、fork(2)に大きく依存するシステムを使ったことがあると言えます(完全にfork(2)を中心とするアーキテクチャでないとしても)。

しかし、fork(2)は邪悪であり、使うべきではないと主張する人はたくさんいます。個人的には、同意できる部分もあれば同意できない部分もありますので、これについて説明しておこうと思います。

🔗 forkのごく簡単な歴史

Wikipedia によると、forkの概念が初めて登場したのは1962年、コンウェイの法則を考案した人物によるもので、その後、UNIXの最初のバージョンで導入されました。

当初、これは新しいプロセスを作成するためのプリミティブとして意図されていました。fork(2)を呼び出して現在のプロセスのコピーを作成し、そこから新しいプロセスを希望どおりに変更して、すぐにexec(2)になります。これは今でもRubyで以下のように実行できます。

if (child_pid = Process.fork)
# 親プロセス内にいるので子プロセスIDがわかる。
# 子プロセスの終了待ちやシグナル送信などが可能。
  Process.wait(child_pid)
else
  # ここでは子プロセス内にいる。
  # 現在のユーザーやその他の属性をここで変更可能。
  Process.uid = 1
  # 現在のプログラムを別のプログラムに置き換える。
  Process.exec("echo", "hello")
end

この設計はある意味で、非常にエレガントです。無数の引数を受け取る1つの巨大な関数の代わりに、組み合わせることで必要な動作を正確に得られる、いくつかのシンプルなプリミティブがあります。

しかし、これは非常に非効率的でもあります(プロセスを完全に複製して新しいプロセスを作成するのは一般にオーバーキルです)。
上の例では、親プログラムにアドレス指定可能なギガバイト単位のメモリがあると仮定すると、それをすべてコピーしてから直ちにすべて破棄するので、/bin/echoのような非常に小さなプログラムに置き換えるのは非常に無駄です。

もちろん現代のOSでは実際に丸ごとコピーするようなことはせず、代わりにCopy-on-writeを使いますが、それでもこの方法は非常にコストが高く、親プロセスが巨大な場合は数百ミリ秒を要することもざらにあります。

そういうわけで、他のプログラムをfork(2)で生成するという歴史的な使い方は、現在ではほぼ非推奨とみなされており、ほとんどの新しいソフトウェアではposix_spawn(3)vfork(2)+exec(2)などの現代的なAPIが使われています。

しかしfork(2)の使い道はそれだけではありません。私が冒頭で挙げたソフトウェアは、どれもexec(2)を呼び出さない形でfork(2)を使っています(最初から未来を正しく見通していたのか、単なるなりゆきなのかはわかりませんが)。

🔗 並列処理のプリミティブとしてのfork

繰り返しますが、私は1970年代前半には生まれていなかったので、この習慣が実際にいつから始まったのかはよくわかりませんが、ある時点でfork(2)が並列処理のプリミティブとして、特にサーバーで使われるようになってきました。

入力をオウム返しにする単純な「エコー」サーバーをRubyでゼロから実装すると、以下のようになります。

require 'socket'

server = TCPServer.new('localhost', 8000)

while socket = server.accept
  while line = socket.gets
    socket.write(line)
  end
  socket.close
end

このスクリプトは、まずリッスンするソケットをポート8000で開き、次にaccept(2)システムコールをブロッキングしてクライアントが接続するのを待ちます。そのメソッドが返されると、双方向ソケットが提供されます。この場合は、そこから#getsで読み取ってクライアントに書き戻すことも可能です。

ここでは最新のRubyを使っています。昔のさまざまなサーバーの記述方法と非常に似ていますが、かなり単純化してあります。

これを試してみたい場合は、telnet localhost 8000を実行することで書き込みを開始できます。

ただし、このサーバーは、同時接続ユーザーを1人しかサポートしていないという大きな問題があります。
試しに2つのtelnetセッションをアクティブにしようとすると、2つ目のセッションは接続できないことがわかります。

そこで、より多くのユーザーをサポートできるようにfork(2)がだんだん使われるようになったのです。

require 'socket'

server = TCPServer.new('localhost', 8000)
children = []

while socket = server.accept
  # 終了した子をクリーンアップする
  children.reject! { |pid| Process.wait(pid, Process::WNOHANG)}

  if (child_pid = Process.fork)
    children << child_pid
    socket.close
  else
    while line = socket.gets
      socket.write(line)
    end
    socket.close
    Process.exit(0)
  end
end

ロジックは先ほどと同じですが、accept(2)がソケットを返すと、そのソケットでブロックする代わりに、新しい子プロセスをfork(2)して、クライアントが接続を閉じるまでその子プロセスにブロッキング操作を実行させます。

勘の良い読者(もしくは既にfork(2)のセマンティクスについて詳しい人)であれば、forkの呼び出し後に、親プロセスと子プロセスの両方がソケットにアクセス可能になっていることに気づいたかもしれません。UNIXのソケットは「ファイル」なので「ファイルディスクリプタ(file descriptor: ファイル記述子)」で表され、fork(2)のセマンティクスの一部はすべてのファイルディスクリプタも継承しています。

親プロセスがソケットを確実に閉じることが重要であるのはそのためです。そうしないと、ソケットは親プロセスで永久に開いたままになります1。これが、fork(2)が多くの人に嫌われる最初の理由の1つです。

🔗 両刃の剣

上で示したように、オープン中のファイルディスクリプタは子プロセスによってすべて継承されるので、機能を実装するには非常に便利なのですが、共有するつもりのないファイルディスクリプタを閉じ忘れると、壊滅的なバグが発生する可能性があります。

たとえば、SQLデータベースへのアクティブなコネクションを持つプロセスをforkして、両方のプロセスでそのコネクションを使い続けると、おかしなことが起きます。

require "bundler/inline"
gemfile do
  gem "trilogy"
  gem "bigdecimal" # trilogy用
end

client = Trilogy.new
client.ping

if child_pid = Process.fork
  sleep 0.1 # 子に猶予時間を与える

  5.times do |i|
    p client.query("SELECT #{i}").first[0]
  end
  Process.kill(:KILL, child_pid)
  Process.wait(child_pid)
else
  loop do
    client.query('SELECT "oops"')
  end
end

上のスクリプトはtrilogyクライアントを使ってMySQLへのコネクションを確立し、次にSELECT "oops"をループ内で無期限にクエリする子プロセスをforkします。子プロセスが生成されると、親プロセスはクエリを5回発行します。各クエリは0〜4の数字を返すことになっており、その結果を出力します。

このスクリプトを実行すると、以下のような、どことなくランダムな出力が得られます。

"oops"
1
"oops"
"oops"
3

ここでは、両方のプロセスが同じソケット内に書き込んでいます。クエリはMySQLサーバーにとっては小さいので、ある程度「アトミックに」ソケットに書き込まれ、大きな問題にはなりません。しかし大きなクエリを発行すると、2つのクエリがインターリーブされ、サーバーが何らかのプロトコルエラーでコネクションを閉じる可能性があります。

しかしこれは、クライアントにとっては非常に悪い状況です。両方のプロセスの応答が同じソケットで返信され、各クライアントがread(2)を発行するので、発行直後のクエリへの応答を受け取るのではなく、他のプロセスによって発行された別の無関係なクエリの応答を受け取ってしまう可能性があるためです。

そのことを念頭に置くと、fork(2)を呼び出す前に、アプリケーションのソケットや、オープン中のその他のファイルを1つ残らず適切に閉じることがどれほど面倒であるかが想像できます。

自分で書くコードならその点に注意しながらやれるかもしれませんが、fork(2)が呼び出されることを想定していないためにファイルディスクリプタをきちんと閉じられないライブラリを使っている可能性があります。

fork+execのユースケースについては、これをうんと簡単に実現できる便利な機能があります。execが呼び出されたときにファイルディスクリプタを閉じる必要があるとマーキングしておけば、OSが代わりに処理してくれます。O_CLOEXEC("close on exec")は、RubyではIOクラスのメソッドとして公開されているので手軽に使えます。

STDIN.close_on_exec = true

しかし、execを続けて実行しない場合のforkシステムコールには、そういう便利なフラグはありません。

厳密には、O_CLOFORKというフラグがあり、これはいくつかのUNIXシステム(主にIBMシステム)に存在し、2020年にPOSIX仕様に追加されました。しかし現時点では広くサポートされているとは言えず、最も重要なのはLinuxでサポートされていないことです。
2011年にこれをLinuxに追加するためのパッチを提出した人がいましたが、あまり関心を惹かなかったらしく、2020年に別の人がもう一度試みましたが猛反対に遭いました。非常に有用なはずなので残念です。

fork安全にしたいコードのほとんどは、代わりに別の方法を採用しています。1つは、現在のプロセスIDをチェックし続ける形でfork発生の検出を試みるという方法です。

def query
  if Process.pid != @old_pid
    @connection.close
    @connection = nil
    @old_pid = Process.pid
  end

  @connection ||= connect
  @connection.query
end

もう1つの方法では、何らかのat_forkコールバックに依存する形を取ります。Cの世界ではpthread_atforkがよく使われており、Ruby 3.1からProcess._fork(メソッド名の_に注意)にデコレートされました(#17795)。

module MyLibraryAtFork
  def _fork
    pid = super
    if pid == 0
      # 子の処理
    else
      # 親の処理
      MyLibrary.close_all_ios
    end
    pid
  end
end
Process.singleton_class.prepend(MyLibraryAtFork)

fork(2)はRubyできわめて多用されています。Active Recordやredis gemのようなソケットを扱う多くの有名ライブラリでは、これを透過的に扱うようベストを尽くしているため、このことについて考える必要はほとんどありません。
つまり、ほとんどのRubyプログラムでは問題ありません。

しかしネイティブ言語の場合、これは非常に面倒なことになる可能性があります。だからこそ、fork(2)が多くの人から絶対的に嫌われているのです。
ファイルやソケットを利用するコードは、fork安全性について注意しておかなければ、fork(2)の呼び出し後に完全に壊れてしまう可能性はありますが、そのような事態はめったに起きません。

🔗 一部のスレッドが死ぬ可能性がある。

話を小さな"エコー"サーバーに戻すと、なぜスレッドではなくfork(2)を使うのか疑問に思うかもしれません。

既に申し上げたように私は当時そこにいませんでしたが、私の理解では、スレッドはずっと後になってから(1980年代後半?)登場し、登場後も、標準化や調整を経て複数のプラットフォームで使えるようにまでかなりの時間がかかりました。

また、fork(2)を使ったマルチプロセスの方がむしろ理解しやすいのではないかという議論もあるでしょう。
各プロセスは独自のメモリ領域を抱えているため、競合状態やその他のスレッドの落とし穴についてそれほど心配する必要はありません。そのため、スレッドが選択肢として利用可能になった後も引き続きfork(2)を使い続けたい人たちがいたのは理解できます。

しかし、スレッドの登場はfork(2)よりずっと遅れたため、スレッドの実装と標準化を担当した人たちは少々困った状況に陥り、両者をうまく連携させる方法を見つけられなかったようです。

POSIX標準のforkの項では以下のように述べられています。

プロセスは単一のスレッドで作成される。マルチスレッドプロセスがfork()を呼び出す場合、新しいプロセスには呼び出しスレッドのレプリカと、そのアドレス空間全体(ミューテックスなどのリソースの状態を含む可能性がある)が含まれる。したがって、エラーを回避するために、子プロセスはexec関数の1つが呼び出されるまで、非同期シグナル安全(async-signal-safe)な操作のみ実行してよい。

言い換えれば、この標準では、古典的なfork+execがマルチスレッドプロセスから実行可能であることについては認めていますが、execが後に続かないforkの利用についてはこれといった言及がありません。非同期シグナル安全操作の利用を推奨していますが、これは実際にはごく一部の操作にすぎません。つまり、この標準によれば、いくつかのスレッドが生成された後にexecをすぐに呼び出すつもりもなくfork(2)を呼び出すと、未知の問題が起きることになります。

その理由は、fork(2)を呼び出したスレッドだけが子プロセス内で生き残り、他のスレッドはすべて存在するものの死んだ状態になるためです。仮に別のスレッドがミューテックスなどをロックしていたとすると、永久にロックされたままになり、新しいスレッドが取得しようとするとデッドロックが発生する可能性があります。

この標準には、そのようになっている理由を説明している根拠のセクションも含まれています。これは少し長いですが興味深いものです。

マルチスレッドの世界でfork()を動かす場合の一般的な問題は、すべてのスレッドをどう扱うかである。代替案は2つある。

1つは、すべてのスレッドを新しいプロセスにコピーすることである。
この場合、プログラマーまたは実装は、システムコールで中断されているスレッドや、新しいプロセスで実行すべきではないシステム コールを実行しようとしているスレッドを処理する必要がある。

もう1つの方法は、fork()を呼び出すスレッドのみをコピーすることである。
この場合、プロセスローカルなリソースの状態が多くの場合プロセスメモリに保持されるという問題が生じる。fork()を呼び出さないスレッドがリソースを保持している場合、そのリソースは子プロセスで解放されない(リソースを解放するスレッドが子プロセスに存在しないため)。

プログラマーがマルチスレッドプログラムを作成する場合、(中略) fork()関数は新しいプログラムを実行するためだけに使われfork()呼び出しとexec関数の呼び出しの間に特定のリソースを必要とする関数を呼び出した場合の影響は定義されていない。

forkall()関数を標準に追加することが検討されたが、却下された。

そこではfork(2)の別バージョンであるforkall()の可能性も検討されていました。これは他のスレッドもコピーしますが、一部のケースで何が起こるかという明確なセマンティクスを思いつけませんでした。

代わりに、forkの前後でコールバックを呼び出して状態を復元する方法(例: ミューテックスの再初期化)をユーザーに提供しました。

ただし、pthread_atfork(3)コールバックのmanページを見ると、以下のことがわかります。

pthread_atfork()の本来の目的は、子プロセスを一貫した状態に戻すことであった。(中略)
実際には、このタスクは一般に難しすぎて実行できない。

したがって、pthread_atforkはまだ存在していますし利用も可能なのですが、これを正しく使いこなすのは非常に難しいと標準側でも認識しています。

だからこそ、多くのシステムプログラマーがfork(2)をマルチスレッドプログラムと決して混在させてはならないと、または少なくともスレッドが生成された後にfork(2)を呼び出してはならないと注意を呼びかけているのです。これらが守られていないと、すべてが台無しになってしまいます。

つまり、どちらかの陣営をとにかく選ぶ必要があり、スレッドが明らかに勝利したようです。

ただしこれは、CまたはC++プログラマー向けの機能です。

しかし、今日のRubyプログラマーがスレッドではなくfork(2)を使う理由は、それがRubyのデフォルトであり、しかも最も一般的に使われている実装であるMRIで真の並列処理(parallelism)を実現する唯一の方法だからです2

悪名高いGVLのため、Rubyスレッドでは実際にはIO操作の並列化しかできず、Rubyコードの実行は並列化できません。そのため、ほぼすべてのRubyアプリケーションサーバーは、複数のCPUコアを活用するために何らかの方法でfork(2)と統合されています。

ありがたいことに、Rubyの場合は、スレッドとfork(2)が混在したときに生じる落とし穴のいくつかは軽減されます。

たとえば、Rubyのミューテックスは、オーナーが死ぬと自動的に解放されるように実装されています。
擬似Rubyコードで表すと以下のようになります。

class Mutex
  def lock
    if @owner == Fiber.current
      raise ThreadError, "deadlock; recursive locking"
    end

    while @owner&.alive?
      sleep(1)
    end

    @owner = Fiber.current
  end
end

もちろん、実際にはこんなふうにループ内でsleepしているわけではなく、はるかに効率的な方法でブロッキングしていますが、これは一般的な考え方を示すためのものです。

ここで重要な点は、Rubyのミューテックスはロックを取得したファイバー(つまりスレッド)への参照を保持していますが、ミューテックスが死んだ場合は自動的に無視することです。
したがって、forkすると、バックグラウンドスレッドが保持しているすべてのミューテックスは即座に解放され、ほとんどのデッドロックシナリオを回避できます。

もちろん、これは完璧とは言えません。ミューテックスを保持したままスレッドが死んだ場合、ミューテックスによって保護されていたリソースが不整合な状態のままになる可能性が非常に高いのですが、GVLの存在によってミューテックスの必要性がいくらか減っているおかげなのか、私は実際にそのような状態を経験したことは一度もありません。

さて、Rubyスレッドはこれらの落とし穴から完全に免れるわけではありません。MRIでは、Rubyスレッドの背後にはネイティブスレッドがあるため、別のスレッドがGVLを解放してミューテックスをロックするC APIを呼び出すと、fork後に厄介なデッドロックが発生する可能性があります。

裏は取れませんでしたが、一部のRubyユーザーがこれを踏んだのではないかと推測しています。私の理解では、Rubyでホスト名の解決に使われているglibcのgetaddrinfo(3)はグローバルなミューテックスを利用しており、RubyがGVLを解放した状態でこれを呼び出すことで、同時にforkを行えるようになっているからです。

私は、これを防ぐために別のロックをMRI内に追加して(#20590)、getaddrinfo(3)呼び出しの実行中にProcess.forkが発生しないようにしました。

これは完璧にはほど遠いのですが、Ruby がProcess.forkにどれほど依存しているかを考えると、これは筋の良い方法のように思えます。

また、macOSでforkに依存するRubyプログラムがクラッシュすることも珍しくありません。これは、多数のmacOSシステムAPIが暗黙的にスレッドを生成したり、ミューテックスをロックしたりするためであり、macOSはこれが発生すると一貫してクラッシュすることを選択しました(#38560)。

したがって、純粋なRubyコードであってもfork(2)の落とし穴にハマる可能性があるため、むやみに使うわけにはいきません。

🔗 まとめ

本記事のタイトルへの答えは、「fork(2)が嫌われるのは、特にネイティブコードでうまくいかないため」ということになります。

fork(2)を使うなら、コードを書くときやリンク先のコードに細心の注意を払う必要があります。

ライブラリを使うときは、スレッドが生成されたり、ファイルディスクリプタが保持されたりしないよう常に注意しておく必要があります。fork(2)とスレッドのどちらかを選択できる場合、ほとんどのシステムプログラマーはスレッドを選択します。スレッドには独自の落とし穴もありますが、構成しやすい点が優れています。また、呼び出すAPIの内部でスレッドが使われている可能性が高いため、既にある程度スレッドが選択されていることになります。

しかしRubyコードの場合はそれほど状況は悪くありません。Rubyではfork安全なコードをずっと手軽に書けますし、Rubyの哲学のおかげで、Active Recordなどのライブラリではそうした面倒な詳細を代わりに処理してくれるからです。

そういうわけで、問題が発生するのは、ほとんどの場合、grpclibvipsのような一部のネイティブライブラリにバインドしようとしたときです。そうしたライブラリは一般にfork(2)のことを想定しておらず、それらを制約とすることにあまり積極的ではありません。

特に、forkはアプリケーションの初期化処理の最後に使われることが多いので、技術的にはfork安全でないライブラリであっても、アプリケーションでは最初のリクエスト時にスレッドとファイルディスクリプタを遅延初期化するのが一般的なので、問題なく動作します。

ともあれ、皆さんがfork(2)を今でも悪いヤツだと思っているとしても、将来Rubyで本物のパラレリズムを利用可能な別のプリミティブが提供される日が来るまでは(次の記事のテーマはこれになるはずです)、必要悪であり続けるでしょう。

関連記事

「RailsアプリはIO-boundである」という神話について考える(翻訳)

RubyのGVLを消し去りたいあなたへ(翻訳)


  1. 原注: 技術的には、Rubyではオブジェクトがガベージコレクションされると自動的にソケットを閉じてくれますが、その考え方は理解できるでしょう。 
  2. 原注: はい、Ractorでもある程度やれますが、これは次回の記事のテーマとします。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。