前回、プロセスとはOSが処理を切り替えるときの処理の単位だという話をしましたが、まずはプロセスの例を見てみましょう。
ターミナルを開いてコンテナにログインして、
$ psと入力してみましょう。psは今実行中のプロセスの一覧を見ることができるコマンドです。オプションなしで実行すると自分が実行中のプロセスの一覧が見れます。で、psを実行してみると、(環境によって異なるかと思いますが)以下のような文字が出力されるかと思います。
PID TTY TIME CMD
4400 pts/2 00:00:00 bash
4419 pts/2 00:00:00 ps
一番右を見ると、(この場合は)bashというプロセスとpsというプロセスが実行されていることがわかります。bashはログインシェル、psはいまさっき打ったpsコマンドですね。ちなみに、一番左のPIDという列は、そのプロセスのidで、実行されているプロセスを一意に判別するために使われているものです。
では、今度は & つきでバックグラウンドでコマンドを実行してみましょう。
$ ruby -e 'loop { sleep }' &ただsleepし続けるだけのrubyのワンライナーです。この状態で、もう一度
$ psと入力してみると、
PID TTY TIME CMD
4420 pts/2 00:00:00 ruby
のような、さっきは存在していなかったプロセスが新しく増えているのがわかると思います。これがさきほど実行した
$ ruby -e 'loop { sleep }' &コマンドのプロセスです。新しく処理を始めたら新しくプロセスが生成されたのがわかるかと思います。
さて、バックグラウンドで実行中のsleepするだけのプロセスですが、今度は
$ fgでフォアグラウンドに処理を戻して、 Ctrl+C かなんかで処理を止めましょう。その後再度 ps コマンドでプロセスの一覧を確認すると、rubyのプロセスが無くなっていることが確認できるかと思います。
プロセスは、なんらかの方法で生成されたあとは、ぐんぐん処理を行っていき、処理が終わったり外部から止められたりすると消滅します。
生成 -> 処理中 -> 終了
というライフサイクルを持っているわけです。今簡単に「処理中」と書いてしまいましたが、大きくわけてこの「処理中」には3つの状態があります。
- 走行中
- 待ち状態
- ブロック中
「えっ待ち状態とブロック中ってなにが違うの」という疑問を持ったかた、ごもっともです。でも、その違いは簡単です。「待ち状態」というのは、「もうすぐにでも処理できるよ!CPUさん、はやくわたしを処理して!」という状態のことです。一方、「ブロック中」というのは、たとえばファイルの読み込みを行うときにdisk I/Oを待っているなどで、「今CPUさんが私を処理しようとしても私まだIO待ちだから何もできないよ!」みたいな状態のことです。
さて、さきほど簡単に「プロセスをなんらかの方法で生成」と言いましたが、たとえば新しくコマンドを叩いて新しいプロセスが生成されるとき、中では何が起きてるのでしょうか?
通常、プロセスは、「親プロセス」がforkというシステムコールをOSに送ることによって生成されます。すると、OSは親プロセスをまるっと複製して、「子プロセス」を新しく生成します。このとき、メモリの状態は親プロセスから子プロセスにまるっとコピーされます1。コピーされて新しい環境が出来上がるため、親プロセスでなにか操作しても(変数に新しい値代入するとか新しくインスタンスを生成するとか)、その操作は子プロセスに影響を与えません。親でなんか変更したからといって、子にもその変更が伝わるみたいなことはないわけです。逆もまたしかりで、子プロセスでなにか操作しても、その変化は親プロセスに影響を与えません。
こうして、forkにより新しくプロセスが生まれると、OSによりそのプロセス専用の環境が用意されて、その中でいろんな処理が行えるようになるわけです。
実際に、親プロセスと子プロセスでメモリが分離されているところを見てみましょう。
# メモリ分離の確認例
puts "=== メモリ分離の確認 ==="
# forkする前に変数を設定
shared_value = 100
puts "fork前の値: #{shared_value}"
pid = fork
if pid.nil?
# 子プロセス
puts "子プロセス開始: shared_value = #{shared_value}"
# 子プロセスで値を変更
shared_value = 200
puts "子プロセスで変更後: shared_value = #{shared_value}"
sleep 2 # 親プロセスが値を出力するのを待つ
puts "子プロセス終了: shared_value = #{shared_value}"
else
# 親プロセス
puts "親プロセス開始: shared_value = #{shared_value}"
sleep 1 # 子プロセスが値を変更するのを待つ
# 親プロセスで確認(子の変更は反映されない)
puts "親プロセス確認: shared_value = #{shared_value}"
# 親プロセスでも値を変更
shared_value = 300
puts "親プロセスで変更後: shared_value = #{shared_value}"
# 子プロセスの終了を待つ
Process.waitpid(pid)
puts "親プロセス終了: shared_value = #{shared_value}"
endこのスクリプトを実行すると、以下のような出力が得られるはずです:
=== メモリ分離の確認 ===
fork前の値: 100
子プロセス開始: shared_value = 100
親プロセス開始: shared_value = 100
子プロセスで変更後: shared_value = 200
親プロセス確認: shared_value = 100
親プロセスで変更後: shared_value = 300
子プロセス終了: shared_value = 200
親プロセス終了: shared_value = 300
ポイントを整理すると:
- fork時点でメモリがコピーされる: 両方のプロセスとも最初は
shared_value = 100 - 子プロセスの変更は親に影響しない: 子で200に変更しても、親では100のまま
- 親プロセスの変更は子に影響しない: 親で300に変更しても、子では200のまま
- それぞれ独立したメモリ空間: 同じ変数名でも、実際には別々のメモリ領域
こんなふうに、forkによって「それぞれ独立した環境」で新しいプロセスが生成されるわけです。
ちなみに、forkは複数行うことができるので、「子だくさん」なプロセスというのも、あり得ます。preforkのサーバープロセスなんかは子供をたくさん作って、複数の接続のひとつひとつをそれぞれひとつの子供に処理させることで並列性を上げているわけですね。子供たちを酷使するひどいやつです。
しかし、ここでちょっと立ち止まって考えると、プロセスがforkで生成されるということは、基本的に全てのプロセスには「自分を生んだ親プロセス」が存在することになります。となると、当然「えっじゃあ、その親プロセスは誰が作ったの?」という疑問がわいてきますよね。疑問にお答えしましょう。親プロセスは、「親プロセスの親プロセス」がforkで作ったのです。となると、当然「えっじゃあ、その『親プロセスの親プロセス』はだれが作ったの」いう疑問がわいてきますよね。もちろん、「親プロセスの親プロセスの親プロセス」がforkで作ったのです。となると当然(ry
というように、全てのプロセスはどんどんその「親」を辿って行くことができます。そんなわけで、全てのプロセスの祖先となる「最初のプロセス」というものが存在しないといけないわけです。このプロセスはブート時に生成されて、そのあと全てのプロセスがここを祖先としてforkされていきます。この「最初のプロセス」はPIDが1であり、Linuxの場合は init というプロセスがその実体となります。この文書のコンテナ環境で実行している場合は、/sbin/docker-init というのがinitとしてふるまっているはずです。
$ ps ax | grep init1 0.0 0.0 860 320 pts/0 Ss 03:02 0:00 /sbin/docker-init -- /bin/bash
このように、プロセスは親子関係の木構造を持っています。この親子関係を「プロセスツリー」と呼びます。プロセスツリーがどうなっているかを調べるためにpstreeというコマンドが使えますので、興味があればpstreeコマンドでどのようなプロセスツリーが生成されているか見てみるのもよいかと思います。pstree コマンドの使いかたはmanで調べてください(丸投げ)
さて、「すべてのプロセスは祖先からforkされて生まれた」という話と「forkは親プロセスをまるっとコピーして子プロセスを作る」という話をしましたが、これ、なんかおかしいですね。そうです。このままでは、「親の複製のプロセス」しかなくって、すべてが同じことを行うプロセスになってしまいます!
そこで必要になるのが、execというシステムコールです。あるプロセスがexecというシステムコールを呼ぶと、OSはそのプロセスをexecの内容で書き換えてしまいます。つまり、execというのは、「自分自身の内容を別の内容で書き換えて実行してしまう」システムコールなんですね。くらくらしてきた!
まとめると、
- forkでプロセスを生成して、独立した環境を用意してあげる
- その環境に、execによって別の実行可能なものを読み込んで実行する
ことで、親プロセスとは違うプロセスをどんどん生成していくような仕組みになっているわけです。
「日本語だとよくわかんないよ、コードで書いてよ」という声がわたしの脳内から聞こえてきたので、コードで書きます。
puts "forking..."
# forkシステムコール:親プロセスを複製して子プロセスを作る
# 成功すると、親には子のPIDが、子には0が返る
pid = fork
# ここに来てるということは、正常にプロセスが複製された。
# この時点で親プロセスと子プロセスが *別々の環境で*
# 同時にこのプログラムを実行していることになる。
puts "forked!"
# forkの返り値で親プロセスか子プロセスかを判別
# 子プロセス:pidがnil
# 親プロセス:pidが子プロセスのPID
if pid.nil?
# 子プロセス側の処理
# execシステムコール:現在のプロセスを別のプログラムで置き換える
# ここでRubyプロセスが無限sleepするプロセスに変わる
exec "ruby -e 'loop { sleep }'"
else
# 親プロセス側の処理
# Process.waitpid:指定したPIDの子プロセスが終了するまで待機
# 子プロセスが終了すると親プロセスも次の行に進む
Process.waitpid(pid)
end上記のようなRubyスクリプトをfork_exec.rbという名前で用意して、バックグラウンドで実行してみましょう。すると、以下のような出力が得られると思います。
$ ruby ./fork_exec.rb &forking...
forked!
forked!
なぜこうなるのか、説明しましょう。
puts "forking..." という行は、まだfork前なので、プロセスがひとつだけの状態です。なので、普通にひとつの"forking..."が出力されます。しかし、puts "forked!" という行は、forkシステムコールでプロセスが複製されたあとです。そのため、この行は親プロセスとそこから複製された子プロセスが、別のプロセスとして実行します。親プロセスは親プロセスで"forked!"という文字列を標準出力という場所に出力します(putsメソッドは、引数に渡された文字列を標準出力に出力します)、一方、別の環境で動いている子プロセスも、"forked!"という文字列を標準出力という場所に出力します。今回の場合、親プロセスも子プロセスも標準出力はターミナルを意味するので(このあたりの話はまたあとで詳しくやります)、ターミナルに親プロセスと子プロセスの二つ分のforked!が出力されるわけです。
さて、今バックグラウンドで実行したこのスクリプトですが、ではプロセスはどのようになっているでしょうか。psコマンドで確認して見ましょう。
$ ps PID TTY TIME CMD
81996 ttys003 0:00.01 ruby fork_exec.rb
81998 ttys003 0:00.01 ruby -e loop do;sleep;end
psコマンドの出力に、上記のようなふたつの行が見つかるかと思います。上の ruby fork_exec.rb というプロセスが私たちがさっき「$ ruby fork_exec.rb &」と実行したプロセスで、下の ruby -e 'loop { sleep }' というプロセスが、forkされた子プロセスです。pstreeで見てみましょう。
$ pstree 81996 # さっきpsで確認した "ruby fork_exec.rb" のPIDを指定-+= 81996 shinpeim ruby fork_exec.rb
\--- 81998 shinpeim ruby -e 'loop { sleep }'
というような出力が得られ、"ruby fork_exec.rb" というプロセスから "ruby -e 'loop { sleep }'" というプロセスが生成されているのがわかるかと思います。
さて、今バックグラウンドで実行しているプロセス(親プロセスです)を fg コマンドでフォアグランドに移して、Ctrl+Cで止めてしまいましょう。その後もう一度psコマンドを叩くと、子プロセスごと消えているのがわかるかと思います。なぜこうなるのかについては、シグナルについて見るときに説明しましょう。
今は、「forkで子プロセスを生成できて、execでそのプロセスの内容を書き換えられた」ということがわかれば十分です。コマンドを叩いて新しいプロセスを生成する場合とかも、内部ではこのようにforkでプロセスを生成して、確保された環境の内容をexecで書き換えるという形で生まれているのです。ちなみに、シェルからコマンドを叩いてプロセスを生成するときには、「親プロセス」に当たるのはシェルのプロセスになります。
- forkしたpidを看取る話と子供がゾンビになっちゃう話
- あらゆる入出力はファイルとして扱われてるよって話からの、forkした際の file descripter と open file description について
あたりを書きたい気持ちがある。
Footnotes
-
「えっ、まるまるメモリーをコピーするの、そんなのメモリーの無駄じゃないの」と思われる方もいるかもしれませんが、そこはよくできていて、COW(Copy On Write)という方法を使うことで、うまいこと無駄なメモリーを食わないようになっています。 ↩