2014年3月26日水曜日

forkとゾンビプロセス

とあるアプリから外部コマンドを実行する機能を実装していたんだけど、ふと気がついたらゾンビプロセスが大量にできていて焦った。

  • とあるアプリはデーモンプロセスでずっと生きている
  • 外部コマンドは終了を待たなくていいし出力も取らなくていい

ゾンビができちゃうダメな実装

とにかく実行すればいいってことで単純に次のようなコードを書いていた。
forkしてexecするだけという至って単純な実装(実際にはSTDOUTを/dev/nullに向けるとか色々あるけど)。

# 外部コマンドを実行する
# コマンドの出力とか終了には興味がない
def run_command(command)
  fork do
    puts "child: pid=#{Process.pid}, ppid=#{Process.ppid}"
    exec(command)
  end
end

puts "app: pid=#{Process.pid}"
run_command("/bin/date")

# この後イベントループとかで
# 終了しないデーモンアプリだと思って
sleep 10

で、これを実行するとこうなる。

$ ruby zombie.rb
app: pid=4083
child: pid=4114, ppid=4083
2014年  3月 25日 火曜日 23:49:05 JST

sleepのところで終了しないうちにプロセスを確認するとこんな感じ(関係ない行は省略)。

$ ps fo ppid,pid,stat,cmd
 PPID   PID STAT CMD
 1491  1493 Ss   /bin/zsh
 1493  4083 Sl+   \_ ruby zombie.rb
 4083  4114 Z+        \_ [date] <defunct>

実行した外部コマンドであるところのdateが見事にゾンビと化している。

で、zombie.rbが終了するとゾンビプロセスも消滅するのだが、先にも書いたとおり、このアプリは実際にはずーっと起動しているアプリなので終了しない。
ということは、ずーっとゾンビが残ったまま。しかもイベントを受けて定期的に実行する機能なので、どんどんゾンビが増えていく。バイオハザード状態。まずい。

なぜこんなことに

通常、子プロセスを生成した後はwaitでその終了を待って終了ステータスを得るのだが、逆に言うとwaitを呼ぶまではたとえ子プロセスが終了していても終了ステータスを保持しておかなければならない。いつ親からwaitを呼ばれるかわからないから。後になってwaitを呼ばれた時に「もうなくなっちゃったから分からないよ」では困るから。これがゾンビプロセスという状態。

つまりさっきの現象はforkするだけしてwaitしていなかったので、終了ステータスの情報だけどんどん残っていってる状態だったわけだ。

initに任せる

waitしなかったのが問題なのでwaitすればいいわけだけど、コマンドの実行終了を待ちたくないのでwaitしたくない。
じゃあどうするかというと、問題のプロセスをinitに引き取ってもらう。

initは平たく言うとOSの起動時に最初に実行(PID=1)されてOSの終了時までずっといるやつで、親プロセスがいなくなって孤児になったプロセス(親が先に終了して子だけ残ったプロセス)はinitが引き取ってくれることになっている(initの子プロセスになる)。更にいうと引き取られたプロセスのwaitも面倒見てくれる。

件のプロセスをinitに押し付けるには、forkして自分はすぐに終了するだけというプロセスを一段挟むようにする。

次のような流れになる。

  1. 親:forkして子を生成する
  2. 親:子をwaitする
  3. 子:forkして孫を生成する
  4. 子:終了する
  5. 孫:execする

親:アプリケーション
子:initに押し付けるためのプロセス
孫:実行したいコマンド

親は子をforkしたあとにwaitするんだけど、子はforkしたあとにすぐに終了しちゃうので目的のコマンドの終了を待たずともすぐに制御が返ってくる。
孫は(孫の)親であるところの子がwaitせずにとっとと終了してしまうので親がinitに切り替わる。
そして孫の終了はinitが最後まで面倒を見てくれるのでゾンビにはならない。めでたしめでたし。

修正した実装

というわけで、上記のように修正したのがこちら(修正したメソッドのみ)。
実行した時に動きがわかりやすいようにsleepとputsが入っている。

# 外部コマンドを実行する
# コマンドの出力とか終了には興味がない
def run_command(command)
  pid = fork do
    puts "child1: pid=#{Process.pid}, ppid=#{Process.ppid}"
    fork do
      puts "child2: pid=#{Process.pid}, ppid=#{Process.ppid}"
      sleep 2
      puts "child2: pid=#{Process.pid}, ppid=#{Process.ppid}"
      exec(command)
    end
    sleep 1
  end
  Process.waitpid(pid)
end

これを実行するとこうなる。

app: pid=5064
child1: pid=5095, ppid=5064
child2: pid=5098, ppid=5095   # まだchild1が生きてる時
child2: pid=5098, ppid=1      # child1が終了した後(ppidが1になっている)
2014年  3月 26日 水曜日 01:00:53 JST

プロセスの変遷はこのような感じ。

# まだchild1があるとき
$ ps fo ppid,pid,stat,cmd
 PPID   PID STAT CMD
 1491  1493 Ss   /bin/zsh
 1493  5064 Sl+   \_ ruby no_zombie.rb          # 親
 5064  5095 Sl+       \_ ruby no_zombie.rb      # 子
 5095  5098 Sl+           \_ ruby no_zombie.rb  # 孫

# child1が終了した後
$ ps fo ppid,pid,stat,cmd
 PPID   PID STAT CMD
 1491  1493 Ss   /bin/zsh
 1493  5064 Sl+   \_ ruby no_zombie.rb          # 親
    1  5098 Sl+  ruby no_zombie.rb              # 孫(PPIDが1になってる)

# 実行が終了してアプリだけ残ってる状態
# ゾンビプロセスは残ってない!
$ ps fo ppid,pid,stat,cmd
 PPID   PID STAT CMD
 1491  1493 Ss   /bin/zsh
 1493  5064 Sl+   \_ ruby no_zombie.rb

参考

0 件のコメント:

コメントを投稿