ツナワタリマイライフ

日常ネタから技術ネタ、音楽ネタまで何でも書きます。

「なるほどUNIXプロセス」を読んだ

はじめに

読んだ。

tatsu-zine.com

ずいぶん前に買っていて、途中までやって放置していた。3時間ほどで読み通せた。 モチベーションとしては、「プロセス」って正直よくわかってないな、と思ったから。そのあたりをちゃんと学ぼうと思った。 コンテナを使うことが当たり前になった今、コンテナはプロセスだーって言っても、そもそもプロセスって何なのか。こういうOSレイヤーの知識は長く活きるので、学習効果が高いと思い、最近はこのあたりを学んでいきたいと思っている。

感想

プロセスとはUNIX上の全てのプログラムの実行単位としての抽象概念であることがわかった。

この本はUNIXプロセスの様々な挙動をRubyプログラムから試す本である。さくさく進めて気持ちいいし、著者のユーモアの効いた表現が読んでいて楽しい。ただその特性からRubyプログラムを深く理解するものでも、UNIXプロセスの詳細を理解できるものでもなく、あくまでRubyプログラマUNIXプロセスの仕組みを理解するための最初の一歩を手助けしてくれる本である。必要に応じて別のサイトを参考にしながら進めたほうがいい。

副読書としてはLinuxのしくみのプロセスの部分に先に目を通しておくと良さそうだ。

[試して理解]Linuxのしくみ ~実験と図解で学ぶOSとハードウェアの基礎知識

[試して理解]Linuxのしくみ ~実験と図解で学ぶOSとハードウェアの基礎知識

この本には付録がついている。おそらく実際のアプリケーションを通じた応用的な内容だと思う。現職でもWebサーバや非同期ジョブシステムは使っているので、また別の機会に学ぶ。

  • 付録
    • Resque
    • Unicorn
    • preforkサーバ
    • Spygrass(学習用アプリ)

以降は動かしながらのメモ。

3章 すべてのプロセスにはIDがある

rubyプロセスのid

irb(main):001:0> puts Process.pid
12512
=> nil

psで確認

$ ps -p 12512
  PID TTY           TIME CMD
12512 ttys001    0:00.17 irb

4章 プロセスには親がいる

irb(main):002:0> puts Process.ppid
9825
=> nil

irbプロセスの親プロセスはbash

$ ps -p 9825
  PID TTY           TIME CMD
 9825 ttys001    0:00.15 -bash

5章 プロセスにはファイルディスクリプタが ある

Unix の世界では「すべてがファイルである」。これは Unix の哲学の一つだ。

ファイルディスクリ プタはプロセスとともに生き、プロセスとともに死ぬ運命にある。

/etc/passwdを開いたときのファイルディスクリプタ番号を取得する。

irb(main):003:0> passwd = File.open('/etc/passwd')
=> #<File:/etc/passwd>
irb(main):004:0> puts passwd.fileno
9
=> nil

このファイルを閉じないまま、/etc/hostsを開く

irb(main):005:0> hosts = File.open('/etc/hosts')
=> #<File:/etc/hosts>
irb(main):006:0> puts hosts.fileno
10
=> nil

1つ増える。

クローズするとファイルディスクリプタ番号は再利用される。

irb(main):005:0> hosts.close
=> nil
irb(main):006:0> profile = File.open('/etc/profile')
=> #<File:/etc/profile>
irb(main):007:0> puts profile.fileno
10
=> nil

通常、ファイルディスクリプタ番号の最小は3であり、0, 1, 2は標準入力、標準出力、標準エラー出力に割り当てられている。

irb(main):008:0> puts STDIN.fileno
0
=> nil
irb(main):009:0> puts STDOUT.fileno
1
=> nil
irb(main):010:0> puts STDERR.fileno
2
=> nil

第6章 プロセスにはリソースの制限がある

1プロセスあたりのファイルディスクリプタ上限を調べる。

irb(main):011:0> p Process.getrlimit(:NOFILE)
[4864, 9223372036854775807]
=> [4864, 9223372036854775807]

ソフトリミットとハードリミットが得られる。

ちなみにOSのファイルディスクリプタ上限は以下の通り、256。こっちのほうが少ない。

$ sudo launchctl limit
Password:
    cpu         unlimited      unlimited
    filesize    unlimited      unlimited
    data        unlimited      unlimited
    stack       8388608        67104768
    core        0              unlimited
    rss         unlimited      unlimited
    memlock     unlimited      unlimited
    maxproc     709            1064
    maxfiles    256            unlimited

第7章 プロセスには環境がある

ここでいう「環境」とはいわゆる「環境変数」のことだ。環境変数はキーとバリューが 対になっており、プロセスで使えるデータを保持している。

$ MESSAGE='wing it' ruby -e "puts ENV['MESSAGE']"
wing it

ENV には Hash のようにアクセスできる API が用意されているが、実際には Hash オ ブジェクトではない。

第8章 プロセスには引数がある

$ cat /tmp/argv.rb
p ARGV
$ ruby /tmp/argv.rb foo bar -va
["foo", "bar", "-va"]

前章で紹介した ENV は Hash オブジェクトではなかったが、ARGV は普通の Array だ。

所感:引数はプログラムのものと思っていたが、プロセスの概念だったのははじめて知った

第9章 プロセスには名前がある

プロセスのレベルで情報を伝えるための仕組みは二つある。一つはプロセス名。もう一 つは終了コードだ。

RubyではPROGRAM_NAMEというグローバル変数に格納されている。

irb(main):001:0> puts $PROGRAM_NAME
irb
=> nil
irb(main):002:0> puts Process.pid
13146
=> nil
irb(main):003:0> $PROGRAM_NAME = "irbirb"
=> "irbirb"

この変数を変更すれば、psコマンドでも変更が確認できる。

$ ps -p 13146
  PID TTY           TIME CMD
13146 ttys001    0:00.17 irbirb

第10章 プロセスには終了コードがある

プロセスが終了時にこの世に残す最後のしるし、それが終了コードだ。あらゆるプロセ スは、正常終了か異常終了かを示す終了コード値(0-255)と共に終了する。

  • Kernel#exit: デフォルトでは終了コード0を返す。任意の終了コードを返すこともできる。
  • Kernel#exit! デフォルトでは終了コード1を返す。任意の終了コードを返すこともできる。Kernel#at_exit で定義されたブロック は実行されない。
  • Kernel#abort デフォルトでは終了コード1を返す。メッセージを渡すことができ、STDERRに出力される。
  • Kernel#raise デフォルトでは終了コード1を返す。呼び出し元に送出される。メッセージを渡すことができ、STDERRに出力される。
irb(main):004:0> exit
$ echo $?
0
$ irb
irb(main):001:0> exit!
$ echo $?
1

第11章 プロセスは子プロセスを作れる

子プロセスは親プロセスで使われているすべてのメモリのコピーを引き継ぐ。親プロ セスが開いているファイルディスクリプタも同様に引き継ぐ。

irb(main):001:0> puts "parents process pid is #{Process.pid}"
parents process pid is 13273
=> nil
irb(main):002:0> if fork
irb(main):003:1> puts "entered the if block from #{Process.pid}"
irb(main):004:1> else
irb(main):005:1> puts "entered the else block from #{Process.pid}"
irb(main):006:1> end
entered the if block from 13273
=> nil
entered the else block from 13286
irb(main):007:0> => nil

何が起きたのか?

  • forkは子プロセスを生成し、1つは呼び出し元に、1つは子プロセスに返る
    • 親プロセスに対しては、生成した子プロセスのpidが返す
    • 子プロセスではforkはnilを返す
irb(main):001:0> puts fork
13383
=> nil
irb(main):002:0>
=> nil

第12章 孤児プロセス

親プロセスが死んだら、子プロセスには一体何が起きるのだろうか? 端的に言えば「何も起きない」。どういうことかというと、オペレーティングシステム は子プロセスを特別扱いしない。だから親プロセスが死んでも子プロセスは生き続ける。 親プロセスは死ぬ時に子プロセスを道連れにしない。

第13章 プロセスは優しい

CoW は fork(2) で子プロセスを生成するときにリソースを節約できるのがとても便利 だ。親プロセスの物理メモリを一切コピーしなくて構わないので、fork(2) は速い。子プ ロセス側で必要になるデータだけコピーすればよく、残りは共有すればいいのだ。

第14章 プロセスは待てる

Process.wait は何をしたのだろうか? Process.wait は、子プロセスのどれ か 1 つが終了するまでの間、親プロセスをブロックして待つ。

Process.wait には Process.wait2 という親戚がいるん だ! どうしてこんな紛らわしい名前なのだろうか? だが、戻り値(pid)を 1 つ返すの が Process.wait で、2 つ(pid と終了ステータス)を返すのが Process.wait2 だと説 明すれば納得してもらえると思う。

動かしてみる。

$ cat /tmp/orphan.rb
# 子プロセスを 5 つ生成する
5.times do
  fork do
# 子プロセスごとにランダムな値を生成する。
# もし偶数なら 111 を、奇数なら 112 を終了コードとして返す。
    if rand(5).even?
      exit 111
    else
      exit 112
    end
  end
end

5.times do
# 生成した子プロセスが終了するのを待つ。
  pid, status = Process.wait2
# もし終了コードが 111 なら、
# 子プロセス側で生成された値が偶数だとわかる。
  if status.exitstatus == 111
    puts "#{pid} encoutered an even number!"
  else
      puts "#{pid} encoutered an odd number!"
  end
end
$ ruby /tmp/orphan.rb
13530 encoutered an odd number!
13529 encoutered an odd number!
13528 encoutered an odd number!
13527 encoutered an even number!
13531 encoutered an odd number!

Process.wait2はいずれかの子プロセスが終了するまで待つ、というのがポイントだ。

ま だ 話 は 終 わ ら な い 。実 は Process.wait に は さ ら に 2 人 の 親 戚 が い る 。 Process.waitpid と Process.waitpid2 だ。

!?

違いは、任意の子プロセスの終了 を待つのではなく、指定した子プロセスの終了を待つという点になる。このとき、終了を 待つ子プロセスは pid で指定する。

2 つのメソッドの実体は同じかもしれないが、任意 の子プロセスを待つ場合には Process.wait を、特定のプロセスを待つ場合には Process.waitpid を使おう。

親プロセスがProcess.waitにたどり着く前に子プロセスが終了したとしたら?これは問題ない。

カーネルは終了したプロセス の情報をキューに入れておくため、親プロセスは子プロセスの終了時点の情報を必ず受け 取ることができる。

逆に、子プロセスがない状態でProcess.waitを行うと例外となる。

終了を待つ子プロセスが 1 つも無い状態で Process.wait の一族のどれかを呼び出 すと Errno::ECHILD 例外が送出されることに気をつけておこう。生成した子プロ セス数を控えておいて、この例外が送出されないようにしておこう。

第15章 ゾンビプロセス

カーネルは、親プロセスが Process.wait を使ってその情報を要求するまで、終了し た子プロセスの情報をずっと持ち続ける。すなわち、親プロセスが子プロセスの終了ス テータスをいつまでも要求しなければ、その情報はカーネルから決して取り除かれない。 となると、子プロセスを「撃ちっ放し」方式で生成して、子プロセスの終了ステータスを 放置しているのは、カーネルのリソースの無駄使いになるというわけだ。

Process.detach は何をしているのだろうか? Process.detach は新しいスレッドを 生成している。生成されたスレッドに与えられた唯一の仕事は、 pid で指示された子プ ロセスの終了を待ち受けることだ。こうすることで、カーネルは誰からも必要とされない 終了ステータスを持ち続けなくてよくなる。

整理。ゾンビプロセスとは、

  • 親プロセスからforkされた子プロセスであり
  • 終了した子プロセスであり
  • 親プロセスが子プロセスの終了を要求していない状態である

本当?

プロセス - Wikipedia

UNIXオペレーティングシステムにおいて、ゾンビプロセスZombie Process)は、処理を完了したがプロセステーブル(プロセス制御ブロック相当)が残っていて、終了ステータスを読まれるのを待っているプロセスである[1][5]。この用語のメタファーに従えば、ゾンビプロセスは「死んでいる」が、まだ「死神」が到着していないということになる。

本当だった。

所感:今まで子プロセスの終了を始末するという場面に出会ったことがなかった。子プロセスを生成するシステムではうまくゾンビとならないようになっているのだろう。

第16章 プロセスはシグナルを受信できる

Process.wait はブロッキング呼び出し だ。つまり、子プロセスが終了するまで親プロセスは処理を続行できない。 親は親で忙しい場合はどうすればいいだろうか? すべての親が日がな一日、ぶらぶら している子供たちを待てるわけもない。そんな忙しい親のための解決策を紹介しよう! Unix シグナルの出番だ。

子プロセスが終了した時に、カーネルから送られるSIGCHLDを親プロセスで補足すれば良い。

Unix シグナルの世界への第一歩を踏み入れることができた。シグナルは非同期通信だ。 プロセスはカーネルからシグナルを受けたとき、以下のいずれかの処理を行なう。

  1. シグナルを無視する
  2. 特定の処理を行なう
  3. デフォルトの処理を行なう

シグナルはある特定のプロセスから別のプロセスへと送られるものであり、 カーネルはその仲介役となっている。

2つのrubyプログラムで、片方のプログラムから片方のプログラムにkillシグナルを送ってみる。

$ cat /tmp/sleep.rb
puts Process.pid
sleep
$ ruby /tmp/sleep.rb
13797
$ cat /tmp/kill.rb
Process.kill(:INT,13797)
$ ruby /tmp/kill.rb

実行すると、1つめのプログラムは終了する。

Traceback (most recent call last):
    1: from /tmp/sleep.rb:2:in `<main>'
/tmp/sleep.rb:2:in `sleep': Interrupt

INTシグナルが送られたことがわかる。

Signalにはたくさん種類があるが、killとstopとuser1/2ぐらいしか使ったことがない。

Man page of SIGNAL

第17章 プロセスは通信できる

プロセス間通信(IPC、Inter Process Communication)と呼ばれる分野の話 になる。IPC はさまざまな方法で実現できる。この章ではよく使われる 2 つの方法を紹 介しよう。パイプとソケットだ。

パイプを使ってwriterからreaderへ文字列を送る。

irb(main):001:0> reader, writer = IO.pipe
=> [#<IO:fd 9>, #<IO:fd 10>]
irb(main):002:0> writer.write("Into the pipe I go...")
=> 21
irb(main):003:0> writer.close
=> nil
irb(main):004:0> puts reader.read
Into the pipe I go...
=> nil

readerからwriterへ送ることはできない。そもそも、IO.pipeの返却値はreader, writerの順に返るようだ。

docs.ruby-lang.org

戻り値の配列は最初の要素が読み込み側で、次の要素が書き込み側です。

また、親プロセスはforkした子プロセスとメモリを共有するため、パイプでメッセージをやり取りすることが可能だ。

パイプは一方向だが、ソケットは両方向で読み書きができる。

第18章 デーモンプロセス

デーモンプロセスは、ユーザーに端末から制御されるのではなく、バックグラウンドで 動作するプロセスだ。デーモンプロセスのよくある例には、Web サーバやデータベース サーバのように、リクエストを捌くためにバックグラウンドで常に動作するプロセスが挙 げられる。

オペレーティングシステムにとって特別重要なデーモンプロセスが一つある。前の章 で、すべてのプロセスは親プロセスがあることを説明した。これはあらゆるプロセスに あてはまる真実だろうか? システム上のまさに一番最初のプロセスについてはどうだろ うか。

initプロセスである。pidは1だ。

$ ps -p 1
  PID TTY           TIME CMD
    1 ??         2:52.00 /sbin/launchd

Macだとlaunchdというものだった。

launchd - Wikipedia

launchdデーモン)、アプリケーションプロセススクリプトの起動・停止・管理を行う、オープンソースのサービス管理フレームワークである。アップル)のDave Zarzyckiによって作られ、Mac OS X Tiger (Mac OS X v10.4) で導入された。Apache Licenseのもとで公開されている。

Process.setsid は次の 3 つの処理をおこなう。

  1. プロセスを新しいセッションのセッションリーダーにする
  2. プロセスを新しいプロセスグループのプロセスグループリーダーにする
  3. プロセスから制御端末を外す

???

  • プロセスグループ:プロセスのグループ。基本的に子プロセスは親プロセスと同じグループになる。プロセスはシグナルを受け取ると、グループ内のすべてのプロセスにシグナルを送る。
  • セッショングループ:プロセスグループよりさらに一段抽象化したグループ。親子関係はないが、パイプでつなげられた一連のコマンドが該当する。
    • セッションリーダーにシグナルが送られると、同じセッショングループのプロセスにもシグナルが送られる

はじめて知った。デーモンプロセスを作るには、新規セッション・グループのリーダーにし、端末の関連を外すことで独立させるということをしている。

第19章 端末プロセスを作る

Ruby プログラムでよくある他のプログラムとの連携方法に、端末から外部コマンドを 動かす「シェルに出る(シェル・アウトする)」というやり方がある。Ruby スクリプトで いくつかのコマンドを自分用に組み合わせて使う場合などに、こうした光景によく遭遇す る。Ruby から外部コマンドを実行するためにプロセスを生成する方法は複数提供されて いる。

この章でこれから説明していく手法はすべて、fork(2) + execve(2) の応用だ。

一度 Ruby プロセ スを execve(2) で他のプロセスに置き換えたら、それはもう決して元には戻せないのだ

irbで実行するとirbプロセスは終了した。

$ irb
irb(main):001:0> exec 'pwd'
/Users/take
$

execve(2) は、現在のプロセスを別のものに置き換える、とても強力で効果的な方法だ。唯一の難点 は、現在のプロセスが終了してしまうことだ。そこで fork(2) が活躍する。

rubyプログラムからpythonプログラムを実行する例。

$ cat /tmp/exec.rb
hosts = File.open('/etc/hosts')
python_code = %Q[import os; print os.fdopen(#{hosts.fileno}).read()]
# 引数の最後のハッシュは exec を介して開きつづける
# ファイルディスクリプタを指定している
exec 'python', '-c', python_code, {hosts.fileno => hosts}

結果、ちゃんと動く。

$ ruby /tmp/exec.rb
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1   localhost
255.255.255.255 broadcasthost
::1             localhost

ポイント

  • Ruby での exec 呼び出しは、デフォルトで標準ストリームを除くすべての ファイルディスクリプタを閉じる。

    • execのオプションとしてファイルディスクリプタとIOオブジェクトのハッシュを渡すことで、execで呼び出したpythonプログラムからも対象ファイルをopenできる
  • fork(2) と違い、execve(2) は新しいプロセスとメモリを共有しない。

  • execに引数として配列を渡す。

    • 外部コマンドを文字列としてまとめて exec に渡した場合は、実際にシェルプロセスが 起動して、渡された文字列を解釈する。配列にして渡した場合には、シェルの起動は行わ れずに配列を直接 ARGV にして新しいプロセスに渡す。

そういえばcapistranoで実行コマンドを文字列にするか配列にするかで挙動に違いがあるって話があった気がする。

これらの手法には一つだけ難点があって、それは fork(2) に依存していることだ。

シェルアウトする場合、いかなる場合でもforkを使うので、メモリを全てコピーされる。それが単純なlsだったとしても。

fork(2) にはコストがかかり、パフォーマンスのボトルネックになる可能性がある、と いうことを頭の隅に入れておこう。

第20章 おわりに

Unix のプロセスについて「なるほど」と合点がいくというのは、次の 2 つを会得する ということだ。つまり、抽象化と情報伝達だ。

  • Kernelから見るとプログラムは全て同じに見せるのがプロセスという抽象化である
  • シグナルや名前、パイプやソケットを使ってプロセス同士は情報伝達ができる