Process Book

Author: Shinpeim

この文書はなんですか?

この文書は*nix系のシステムにおけるプロセスやシグナルなどについて説明することを目的に書かれました。「プロセスとかよくわかってないからちゃんと知りたいな」みたいなひとたちが想定読者です。

簡単に試すには

このリポジトリをクローンすると、Docker環境で簡単に試すことができます:

git clone https://github.com/Shinpeim/process-book.git
cd process-book

また、Releasesにはビルド済みのPDFファイルが公開されているので、手軽に読みたい場合はそちらをご利用ください。

はじめに:Docker環境の準備

この文書では、実際にコマンドを実行してプロセスの動作を確認することが重要です。統一された環境で学習を進めるため、Dockerコンテナを使用することを推奨します。

前提条件

セットアップ手順

Docker イメージをビルド

docker-compose build

コンテナを起動してシェルに入る

# コンテナを起動
docker-compose up -d

# コンテナ内のシェルに入る
docker-compose exec process-book bash

複数ターミナルでの操作について

この文書では、サーバーとクライアントの通信など、複数のターミナルを使用する例が出てきます。その場合は以下のようにして、同じコンテナに複数のシェルでアクセスしてください:

# ターミナル1(サーバー側)
docker-compose exec process-book bash

# ターミナル2(クライアント側)
docker-compose exec process-book bash

終了時の片付け

# コンテナを停止・削除
docker-compose down

注意: 本文中で「ターミナルを開いてコンテナにログインして」と記載されている箇所は、上記の docker-compose exec process-book bash コマンドを実行することを指しています。

目次

導入

プロセスの生成

プロセスとファイル入出力

ファイルディスクリプタ

preforkサーバーを作ってみよう

ゾンビプロセスと孤児プロセス

シグナルとkill

プロセスグループとフォアグランドプロセス

ライセンス

クリエイティブ・コモンズ・ライセンス

この 作品 は クリエイティブ・コモンズ 表示 - 継承 3.0 非移植 ライセンスの下に提供されています。

更新履歴

2025年のアップデート

このプロジェクトは2025年にClaude Codeを使って令和最新版(変更当時)に更新されました。主な改善点: - GitHub Pagesでの自動公開 - HTML生成の改善 - ビルドプロセスの最適化 - サンプルコードをRubyに統一 - よりわかりやすい内容に変更 - その他の技術的負債の解消

サポート

この文書が役に立ったら、カンパがわりにAmazon wishlistからプレゼントをお待ちしています。

導入

マルチプロセスとスケジューリング

Unix系のシステム(Linux、macOS、FreeBSDなど、Unixから派生したOSの総称)は、もともと複数のユーザーが同じコンピューターリソース(CPUやメモリー)を同時に使うことを前提に作られています。そのため、Unix系のシステムでは様々な処理が同時に行われるような仕組みになっています。実際、小規模なwebサービスでは Webサーバー、アプリケーションサーバー、データベースサーバーがひとつのマシンで同時に走っているような状況は珍しくないのではないでしょうか。

いまはカジュアルに「同時に複数の処理が走っている」と言ってしまいましたが、マシンが持っているCPU(脳みそ)は限られた数なのに、どうやって複数の処理を同時に行っているのでしょうか? ひとつの脳みそでどうやって複数のことを同時に考えているのでしょうか? その答えは、「本当は同時に処理は行われていなくて、OSが目にも留まらぬ早さで複数の処理を切り替えているせいであたかも同時に複数の処理が行われているように見える」です。図にするとこういう感じ。

プロセスA: |████|    |████|             |██████████|    |███████████|
プロセスB:      |██|        |████████|              |████|  |██|       |███████|
プロセスC:         |████|            |██████████|      |███|              |██|
           時間経過 ─────────────────────────────────────────────────────→

OSは、上記のように処理A,B,Cを順々に切り替えながら少しずつ処理していきます。この切り替えのタイミングがめっちゃ早いため、人間にはまるで同時に処理されているかのように見えるわけです。この切り替えをする処理の単位が、プロセス(やスレッド)です。上図の場合だと、Aというプロセス、Bというプロセス、CというプロセスをOSがすごい早さで切り替えながら処理しているようなイメージですね。このように、プロセスやスレッドを上手に使うことで、同時に複数の計算が可能になるわけです。

ちなみに、この切り替えをどういう戦略やタイミングで行うかのことを、「スケジューリング」と言います。このへんはOSが面倒を見てくれますが、niceというコマンドで「これははあんまり重要じゃないプロセスなんで、優先度低めでいいよ」という情報をOSに教えたりできて、そうするとOSさんはそのプロセスを処理する時間を少なめにスケジューリングしたりします。

マルチコアとの関係

現在のマシンのCPUはコアが複数あるのが一般的です。コアがひとつだけならば、ひとつのコアで全てのプロセスをスケジューリングする必要があるわけですが、コアが複数あるため、下記のような感じで今度は「ほんとうの」同時処理が可能になります。

┌─────────┐ プロセスA: |████|    |████|             |██████████|    |███████████|
│ Core 1  │ プロセスB:      |██|        |████████|              |████|  |██|       |███████|
└─────────┘ プロセスC:         |████|            |██████████|      |███|              |██|
┌─────────┐ プロセスD: |███|      |████|             |██████████|    |███████████|
│ Core 2  │ プロセスE:     |██|        |████████|              |████|  |██|       |███████|
└─────────┘ プロセスF:        |███|             |████████|         |███|              |██|
                    時間経過 ─────────────────────────────────────────────────────→

大規模なデータを処理する場合などには、ひとつのコアだけではなく複数のコアを無駄なく使うためにも、複数のプロセスや複数のスレッドで処理を行う必要が出てくるわけです。

ただ、スレッドに関しては、OSが面倒を見てくれるスレッド(いわゆるネイティブスレッド)と、言語処理系やVMが面倒見てくれるスレッド(いわゆるグリーンスレッド)があります。スレッドと一口に言ってもどのような実装になっているかによって特徴が変わってくるので、自分が使っている環境の「スレッド」というのがどのような仕組みをさしているのかは意識しておく必要があるでしょう。なお、この文書ではプロセスについて詳しく説明していきますが、スレッドの詳細については扱いません。

次回予告

次回はプロセスについてもう少し深くまでもぐって見ていきます。

プロセスの生成

プロセスの例

前回、プロセスとは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

さて、さきほど簡単に「プロセスをなんらかの方法で生成」と言いましたが、たとえば新しくコマンドを叩いて新しいプロセスが生成されるとき、中では何が起きてるのでしょうか?

通常、プロセスは、「親プロセス」がforkというシステムコールをOSに送ることによって生成されます。すると、OSは親プロセスをまるっと複製して、「子プロセス」を新しく生成します。このとき、メモリの状態は親プロセスから子プロセスにまるっとコピーされます[^1]。コピーされて新しい環境が出来上がるため、親プロセスでなにか操作しても(変数に新しい値代入するとか新しくインスタンスを生成するとか)、その操作は子プロセスに影響を与えません。親でなんか変更したからといって、子にもその変更が伝わるみたいなことはないわけです。逆もまたしかりで、子プロセスでなにか操作しても、その変化は親プロセスに影響を与えません。

[^1]:「えっ、まるまるメモリーをコピーするの、そんなのメモリーの無駄じゃないの」と思われる方もいるかもしれませんが、そこはよくできていて、COW(Copy On Write)という方法を使うことで、うまいこと無駄なメモリーを食わないようになっています。

こうして、forkにより新しくプロセスが生まれると、OSによりそのプロセス専用の環境が用意されて、その中でいろんな処理が行えるようになるわけです。

メモリ分離を確認してみよう

実際に、親プロセスと子プロセスでメモリが分離されているところを見てみましょう。

# メモリ分離の確認例
puts "=== メモリ分離の確認 ==="

# forkする前に変数を設定
shared_value = 100
puts "fork前の値: #{shared_value}"

pid = fork

if pid == 0
    # 子プロセス
    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

ポイントを整理すると:

  1. fork時点でメモリがコピーされる: 両方のプロセスとも最初は shared_value = 100
  2. 子プロセスの変更は親に影響しない: 子で200に変更しても、親では100のまま
  3. 親プロセスの変更は子に影響しない: 親で300に変更しても、子では200のまま
  4. それぞれ独立したメモリ空間: 同じ変数名でも、実際には別々のメモリ領域

こんなふうに、forkによって「それぞれ独立した環境」で新しいプロセスが生成されるわけです。

ちなみに、forkは複数行うことができるので、「子だくさん」なプロセスというのも、あり得ます。preforkのサーバープロセスなんかは子供をたくさん作って、複数の接続のひとつひとつをそれぞれひとつの子供に処理させることで並列性を上げているわけですね。子供たちを酷使するひどいやつです。

プロセスツリー

しかし、ここでちょっと立ち止まって考えると、プロセスがforkで生成されるということは、基本的に全てのプロセスには「自分を生んだ親プロセス」が存在することになります。となると、当然「えっじゃあ、その親プロセスは誰が作ったの?」という疑問がわいてきますよね。疑問にお答えしましょう。親プロセスは、「親プロセスの親プロセス」がforkで作ったのです。となると、当然「えっじゃあ、その『親プロセスの親プロセス』はだれが作ったの」いう疑問がわいてきますよね。もちろん、「親プロセスの親プロセスの親プロセス」がforkで作ったのです。となると当然(ry

というように、全てのプロセスはどんどんその「親」を辿って行くことができます。そんなわけで、全てのプロセスの祖先となる「最初のプロセス」というものが存在しないといけないわけです。このプロセスはブート時に生成されて、そのあと全てのプロセスがここを祖先としてforkされていきます。この「最初のプロセス」はPIDが1であり、Linuxの場合は init というプロセスがその実体となります。

$ ps ax | grep init
1 ?        Ss     0:10 /sbin/init

このように、プロセスは親子関係の木構造を持っています。この親子関係を「プロセスツリー」と呼びます。プロセスツリーがどうなっているかを調べるためにpstreeというコマンドが使えますので、興味があればpstreeコマンドでどのようなプロセスツリーが生成されているか見てみるのもよいかと思います。pstree コマンドの使いかたはmanで調べてください(丸投げ)

exec

さて、「すべてのプロセスは祖先からforkされて生まれた」という話と「forkは親プロセスをまるっとコピーして子プロセスを作る」という話をしましたが、これ、なんかおかしいですね。そうです。このままでは、「親の複製のプロセス」しかなくって、すべてが同じことを行うプロセスになってしまいます!

そこで必要になるのが、execというシステムコールです。あるプロセスがexecというシステムコールを呼ぶと、OSはそのプロセスをexecの内容で書き換えてしまいます。つまり、execというのは、「自分自身の内容を別の内容で書き換えて実行してしまう」システムコールなんですね。くらくらしてきた!

まとめると、

  1. forkでプロセスを生成して、独立した環境を用意してあげる
  2. その環境に、execによって別の実行可能なものを読み込んで実行する

ことで、親プロセスとは違うプロセスをどんどん生成していくような仕組みになっているわけです。

「日本語だとよくわかんないよ、コードで書いてよ」という声がわたしの脳内から聞こえてきたので、コードで書きます。

puts "forking..."

# forkシステムコール:親プロセスを複製して子プロセスを作る
# 成功すると、親には子のPIDが、子には0が返る
pid = fork

# forkに失敗すると返り値はnil
raise "fork failed." if pid.nil?

# ここに来てるということは、正常にプロセスが複製された。
# この時点で親プロセスと子プロセスが *別々の環境で*
# 同時にこのプログラムを実行していることになる。
puts "forked!"

# forkの返り値で親プロセスか子プロセスかを判別
# 子プロセス:pidが0
# 親プロセス:pidが子プロセスのPID
if pid == 0
    # 子プロセス側の処理

    # 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で書き換えるという形で生まれているのです。ちなみに、シェルからコマンドを叩いてプロセスを生成するときには、「親プロセス」に当たるのはシェルのプロセスになります。

今後の予定

あたりを書きたい気持ちがある。

プロセスとファイル入出力

さて、前回、プロセスというのは「自分が独占したメモリーの中で動いているので、その中で何をしても他のプロセスのメモリーに影響を与えない」というのを見れたかと思います。でも、そんな自分の中だけで完結してる引きこもりみたいなプロセスじゃあ、意味がないですね。外界からなんかデータをもらって、自分の中で処理して、それを外の世界に知らせる方法が必要になってきます。

そこで、プロセスに外から何かを入力したり、プロセスが外に何かを出力する方法として、「ファイルの入出力」というのがあります。たとえば、ファイルに書かれたデータをプロセスがメモリー上に読み込んでなんか処理をするとか、処理を行った結果をテキストファイルに書き込みをするとか。例を見てみましょう。

まず、以下のようなテキストファイルを nyan.txt という名前で適当な場所に作ってみます。

nyan
nyan nyan
nyan nyan nyan

では、このファイルをプロセスの中に読み込んでみましょう。今日は Ruby を使います。

# ファイルを読み込みモード("r")で開く
file = File.open("nyan.txt","r")
# ファイルの中身を全て読み込んで配列として取得
lines = file.readlines
# ファイルを閉じる(リソースの解放)
file.close

ファイルを open して、その内容を lines という変数に読み込んで、最後にファイルを close しています。ファイルの中のデータはディスクに書かれたものであり、プロセスがもともとメモリー内に持っていたものではありません。このディスクに書かれた内容を

lines = file.readlines

の行でlines変数に読み込むことで、プロセスの「外界」の情報を、プロセスの内部のメモリーに読み込んでいますね。

では今度は出力をしてみましょう。

# nyan_copy.rb
# まずファイルを読み込む
file = File.open("nyan.txt","r")
lines = file.readlines  # 全行を配列として読み込み
file.close

# 今度は書き込み用のファイルを開く
file = File.open("nyan_copy.txt","w")  # "w"は書き込みモード
file.write(lines.join)  # 配列を文字列に結合して書き込み
file.close  # ファイルを閉じる

nyan_copy.rbを、nyan.txtと同じディレクトリに作って、実行してみましょう。nyan.txtと同じ内容の、nyan_copy.txtというファイルが生まれたかと思います。さきほどディスクから読み込んでメモリー上に展開したデータを、そのまま別のファイルに対して出力したためですね。

こうして、プロセスはファイルを通じて外部との入出力を行うことができます。

ファイル入出力から考える「入出力の本質」

ここで重要なことに気づいたでしょうか?プロセスにとって「入出力」とは:

  1. 何かから情報を読み取る(入力)
  2. 何かに情報を書き出す(出力)

という2つの基本的な操作に集約されます。さっきの例では「何か」がディスク上のファイルでしたが、この「何か」は必ずしもファイルである必要はありません。

例えば: - キーボードから文字を読み取る - ネットワーク経由でデータを受け取る
- 画面に文字を表示する - プリンターに印刷データを送る

これらすべて、プロセスから見れば「何かから読む」「何かに書く」という同じパターンの操作です。

そこでUnix系システムでは、これらすべての入出力操作を「ファイル操作と同じ方法」で扱えるように設計されました。つまり、ディスク上のファイルを読み書きするのと全く同じAPI(プログラミング方法)で、キーボードやディスプレイ、ネットワークなども操作できるのです。

Unix系の「すべてがファイル」という哲学

「すべてがファイル」って何?

さて、いまは「テキストファイル」への読み書きを行ってみましたが、「Linuxではすべてがファイルなんだよ」みたいな話を聞いたことがないでしょうか? そんなこと言われても、「はっ?」って感じの話ですよね。

まず、この「すべてがファイル」という表現は確かに誤解を招きやすいです。「Linuxではキーボードもファイルだからね」みたいなことを言うひとに至っては「こいつ頭大丈夫か、キーボードはキーボードだろうが」みたいな気持ちになりますよね。わたしは最初にこの話を聞いたときに「なにそれ、禅問答?哲学?頭大丈夫?ファイルはファイルだしキーボードはキーボードだろ」って思いました。

「全てがファイル」とか言われると「世の中のすべてはファイルなのだ、そう、きみも、わたしも」みたいな禅問答をやられてるみたいな気持ちになるので、こういう言い方はあまりよくない感じがしますね。

より正確に言うと

「すべてがファイル」というのは、実際には「すべての入出力が、ファイルと同じ操作方法で扱える」という意味です。

つまり: - ディスクからファイルを読むときの操作方法 - キーボードから入力を受け取るときの操作方法 - ネットワーク経由でデータを送受信するときの操作方法

これらがすべて同じAPI(プログラミングインターフェース)で操作できるように設計されているということなのです。

なぜこの設計が優れているのか

この統一された設計により、プログラマーは: 1. 一度覚えた操作方法(ファイルの読み書き)で、様々な入出力を扱える 2. 同じコードでファイル、ネットワーク、デバイスを操作できる 3. コマンドの組み合わせ(パイプやリダイレクト)が自然に動作する

といった恩恵を受けることができます。

標準入出力:ファイル操作の特別な形

「すべてがファイル」という話をしましたが、実際に体験してみる前に、まず「標準入出力」という概念について説明しましょう。

「標準」って何が標準なの?

プログラムやプロセスが動くとき、普通は以下の3つの「入出力の口」が標準的に用意されています:

  1. 標準入力(Standard Input, stdin):プログラムがデータを受け取るための入り口
  2. 標準出力(Standard Output, stdout):プログラムが結果を出すための出口
  3. 標準エラー出力(Standard Error, stderr):プログラムがエラーメッセージを出すための出口

なぜ「標準」と呼ばれるかというと、どんなプログラムでも最初からこの3つの口が自動的に用意されているからです。プログラマーが特別な準備をしなくても、すぐに使える「標準装備」みたいなものですね。

ターミナルがデフォルトの相手

プログラムを実行すると、これらの標準入出力はデフォルトでターミナル(あなたが使っているコマンドライン画面)につながっています:

つまり、普段あなたがターミナルでプログラムを実行して文字が表示されるのは、プログラムが「標準出力」に文字を送り、それがターミナル画面に表示されているからなのです。

ファイル操作と標準入出力の関係

ここで重要なのは、この標準入出力もファイルと全く同じ方法で操作できるということです。さっきファイルに対して行ったFile.openreadlineswritecloseといった操作が、標準入出力に対してもそのまま使えるのです。

では、実際に例を見てみましょう。

# stdout.rb
# ファイルの内容を読み込む
file = File.open("nyan.txt","r")
lines = file.readlines
file.close

# $stdout:標準出力(通常はターミナル)を表すグローバル変数
file = $stdout # この行だけ書き換えた
file.write(lines.join)  # 標準出力(ターミナル)に書き込み
file.close

nyan.txt と同じディレクトリに、今度は stdout.rb を作って、実行してみましょう。nyan.txtの内容が、ターミナルに出力されたかと思います。

Rubyの組み込みグローバル変数$stdoutには、「標準出力」と言われるものが、すでにFile.openされた状態で入っています。重要なのは、この標準出力もファイルと全く同じオブジェクトとして扱えることです。

つまり: - さっきは File.open("nyan_copy.txt", "w") でファイルを開いて書き込んだ - 今度は $stdout という「すでに開かれているファイル」に書き込んでいる - 操作方法(writeメソッドの使い方)は全く同じ

この「標準出力」の出力先は、デフォルトではターミナルを指します。そのため、さっきテキストファイルに内容を出力したのと全く同じやり方で、ターミナルに対して出力ができるわけです。

これが「すべてがファイル」の実際の意味です。ファイルもターミナルも、プログラムから見れば同じ操作で扱える「入出力先」なのです。

標準入力も同じ仕組み

標準出力があるなら標準入力もあるの?当然あります。Rubyだと標準入力もFile.openされた状態で$stdinというグローバル変数に入っています。

ここでも重要なのは、標準入力もファイルと全く同じオブジェクトとして扱えることです: - さっきは File.open("nyan.txt", "r") でファイルを開いて読み込んだ - 今度は $stdin という「すでに開かれているファイル」から読み込む - 操作方法(readlinesメソッドの使い方)は全く同じ

標準入力のデフォルトの入力ソースはターミナル(つまりあなたのキーボード入力)になります。例を見ましょう。

# stdin.rb
# $stdin:標準入力(通常はキーボード)を表すグローバル変数
file = $stdin
# EOFが入力されるまで全ての入力を受け取る(ここでブロック)
lines = file.readlines
file.close

# 受け取った内容をそのまま標準出力に書き出す
file = $stdout
file.write(lines.join)  # エコープログラムの動作
file.close

上記のような stdin.rb というファイルを作成して、実行してみましょう。何も出力されず、かつプロンプトも返ってこない状態になると思います。これはなぜかと言うと、

lines = file.readlines #標準入力からの入力を全部受け取る

の行で、プロセスが「ブロック中」になっているからです。前回の内容を思い出してください。プロセスの実行中の状態のうちの一つに、「ブロック中」があったと思いますが、ブロック中というのは、「IOとかを待ってて今は処理できないよ」という状態でしたね。

なぜファイルと標準入力で違いがあるの?

ここで重要な違いがあります:

この行では、標準入力からの入力を「全部」読み込もうとしています。そして、標準入力のデフォルトはターミナル(キーボード)からの読み込みを行います。

しかし、すでに何が書かれているか決まっているディスク上のファイルと違って、ターミナルへの入力は「終わり」がいつ来るものなのかわかりません。だから、このプロセスは「終わり」が入力されるまで、ずっとずっと「ブロック中」の状態で待ち続けているのです。けなげですね。

では、ひとまず以下のような感じで、プロンプトが戻ってきてないターミナルに何かを打ち込んでみてください。

$ ruby stdin.rb #さっき実行したコマンド
aaaa
bbbbbb
ccc

打ち込みましたか?そうしたら、改行したあと、おもむろにCtrlキーを押しながらDを押してみましょう。すると、ターミナルに、あたらしく

aaaa
bbbbbb
ccc

と、さっき自分で入力したのと同じ内容が出力されるはずです。

Ctrl+D を押すと、EOFというものが入力されます。この「EOF」というのは「End Of File」の略で、「ここでこのファイル(この場合は標準入力)はおしまいだよ」というのを伝える特別な信号です。

プロセスは、この「EOF」を受け取ることで、「よし、標準入力を全部読み込んだぞ」と理解して、IO待ちのブロック状態から抜けるわけですね。

プロセスと標準入出力の関係

ここまで見てきたように、どのプロセスも生まれた時から標準入出力という3つの「口」を持っていることがわかります。これらの口は:

  1. デフォルトではターミナルにつながっている
  2. ファイルと全く同じ方法で操作できる
  3. プロセスが終了するまで自動的に管理されている

つまり、プロセスから見ると「ファイルを読み書きするのも、ターミナルとやり取りするのも、全く同じ操作」なのです。

ところで、最初の例では標準入力ではなくてnyan.txtを読み込んでいましたが、実はその間にも、一瞬プロセスは「ブロック中」状態になっています。ディスクからデータを読みこんでくるのが一瞬なので、普段はあまり意識しないかもしれませんが(とはいえ、コンピューターの処理の中ではdiskIOというのはかなり遅い処理の部類です。だから、パフォーマンスが必要になってくるようなソフトウェアを書くときには、なるべくIOをしないことでブロックされないようにしてパフォーマンスを稼ぐみたいな手法が取られたりするわけです)。

こんな感じで、「実際はディスク上のファイルじゃないもの」(ターミナル、キーボード、ネットワーク接続など)も、「ディスク上のファイルと全く同じように」扱える。そういう統一的な仕組みがLinuxには備わっています。

これが「すべてがファイル」の本当の意味です: - キーボードが「ファイル」になるわけではない - でも、キーボードからの入力を「ファイルを読むのと同じ操作」で受け取れる - つまり、プログラマーは一つの操作方法を覚えるだけで、様々な入出力を扱える

この設計により、後で説明する「リダイレクト」や「パイプ」といった強力な機能が自然に動作するのです。

ちなみに、標準入力/出力の他にも、「標準エラー出力」というのがあり、これもデフォルトの出力先はターミナルになっています。

余談ですが、IO#readlinesは「ファイルの内容を全部読み込む」という挙動をしますが、では一行だけ読み込む IO#readline を使うとどういう挙動をするかなど、自分で確かめてみると、「あっブロックしてる」「あっ今読み込んでブロック中じゃなくなった」みたいなのがわかっておもしろいかもしれません。

標準入出力の「つなぎ先」を変更する

先ほどから標準入出力の「デフォルト」はどうこうみたいな話をしていますが、それはつまり標準入出力は他の場所にもつなげられるってことですね。

これまでの説明をまとめると: - プロセスは生まれた時から標準入出力という「3つの口」を持っている - デフォルトではこの3つの口は「ターミナル」につながっている
- でも、これらの口は「他の場所」にもつなぎ直すことができる

例えば: - 標準入力を「ファイル」につなぐ → ファイルの内容がプログラムに入力される - 標準出力を「ファイル」につなぐ → プログラムの出力がファイルに書き込まれる - 標準出力を「別のプログラムの標準入力」につなぐ → 2つのプログラムが連携動作する

このような「つなぎ先の変更」を実現するための機能が「リダイレクト」と「パイプ」です。

リダイレクト:標準入出力の「つなぎ先」を変更する

リダイレクトを使うと、標準入出力の「つなぎ先」を変更することができます。つまり、デフォルトではターミナルにつながっている標準入出力を、ファイルなどの別の場所に向けることができるのです。

標準入出力の番号による表現

シェル上(sh、bash、zsh等)では、3つの標準入出力がそれぞれ数字で表されます:

なぜ数字なのかは後で説明しますが、今は「そういう決まりがある」と覚えておいてください。

リダイレクトの記号

出力系のリダイレクトは以下の記号で行えます:

入力系のリダイレクトは:

標準出力のリダイレクト

例えば、

# print_mew.rb
# puts:標準出力に文字列を出力(自動的に改行も追加)
puts "mew"

というrubyスクリプトがあるとき、

$ ruby print_mew.rb 1>mew.txt

とすると、mew とだけ書かれた mew.txt というファイルができあがります。"1>mew.txt"が、「標準出力(1)の出力先はmew.txtだよ」を意味するわけですね。その上で

$ ruby print_mew.rb 1>>mew.txt

とすると、 mew.txt にさらに mew が追記され、mew.txt の中身は mew(改行)mew というものになります。"1>>mew.txt"が、「標準出力の出力先はmew.txtだよ。ファイルが存在してたら末尾に追記してね」を意味するわけです。さらにもう一度

$ ruby print_mew.rb 1>mew.txt

とすると、mew.txtは上書きされてしまい、「mew」とだけ書かれたファイルになります。

ちなみに、標準出力をリダイレクトする際は、「1」を省略した書き方も可能です。

$ ruby print_mew.rb > mew.txt

標準入力のリダイレクト

当然、標準入力もリダイレクトすることが可能です。そのためには、"<"という記号を使います。

試しに、さっき作った mew.txt というファイルを標準入力としてみましょう。

$ ruby stdin.rb 0<mew.txt
mew

"0<mew.txt"が、「mew.txtを標準入力(0)の入力ソースとするよ」を意味しているわけですね。mew.txtの内容がstdin.rbによって読み込まれ、ターミナルに書き出されたかと思います。

これも、0を省略した書き方が可能です。

$ ruby stdin.rb < mew.txt
mew

当然ながら、複数のリダイレクトを同時に行うことも可能です

$ ruby stdin.rb 0<mew.txt 1>mew_copy.txt

上記の場合、stdin.rbの標準入力はmew.txtとなり、標準出力は mew_copy.txt となります。

stdin.rbの内容は標準入力を読み込んで標準出力にそのまま書き出すものなので、mew_copy.txtという新しいファイルに、mew.txtの内容、つまり「mew」 が書き込まれることになります。

標準エラー出力のリダイレクト

標準入出力について見てみたので、標準エラー出力についても見てみましょう。

# stdout_stderr.rb
# puts:標準出力(stdout)に出力
puts "this is stdout"
# warn:標準エラー出力(stderr)に出力
warn "this is stderr"

普通にstdout_stderr.rbを実行すると、標準出力も標準エラー出力もターミナルに向いているので、どちらもターミナルに出力されます。

では、以下のようにしてみましょう。

$ ruby stdout_stderr.rb 1>out.txt 2>err.txt

"1>out.txt" で「標準出力(1)をout.txt」に、"2>err.txt" で「標準エラー出力(2)をerr.txt」に向けています。

すると、out.txtには "this is stdout"が、err.txt には"this is stderr"が書き出されているかと思います。

ちなみに、"2>&1"みたいにして標準エラー出力を標準出力へ向けることもできます。

$ ruby stdout_stderr.rb 1>out.txt 2>&1

&を付けることによって、「この1ってのは、1っていう名前のファイルじゃなくて標準出力を表す数字だよ!」ってことを言っているわけですね。さあ、またまた新しい疑問がわいてきました。なんで&付けるとそれがファイル名じゃなくて標準出力ってことになるの? そもそもなんで0とか1とか2とかって謎っぽい数字使ってるの? 疲れてきたので、そのあたりは次回にまわします。

リダイレクトの順序

$ ruby stdout_stderr.rb 1>out.txt 2>&1

とすると、プロセス内で標準出力に書き出したものも標準エラー出力に書き出したものも out.txt に出力されます。しかし、

$ ruby stdout_stderr.rb 2>&1 1>out.txt

とすると、標準エラー出力に対する出力は、依然としてコンソールに出力されてしまいます。

このような動きをするのはなぜでしょうか?その説明をするためには、「ファイルディスクリプタ」というものを知る必要があります。これについては後で詳しく説明しますので、今は「そういうもんなんだな」と思っておいてください。

パイプ

パイプについても簡単にみておきましょう。シェル上では、パイプは「|」という記号で実現されます。

$ command_a | command_b

とすると、command_aの標準出力に出力された内容がcommand_bの標準入力に入力されます。この時、command_aの出力が全部終わってなくても(EOFに達しなくても)、command_bのプロセスは「来たデータから順々に」処理していきます。データがcommand_aから出力されたら、すぐにcommand_bはそのデータを処理します。まだEOFが来てないけどcommand_aからの出力が来ないぞ、というときにはcommand_bはどうするでしょうか。そうですね、標準入力からのデータを読み込む部分で「ブロック中」になって、command_aが標準出力になにかを吐くのを待ち続けるわけです。けなげですね。ちなみに、このように入力と出力をパイプでつないで、「ファイルの終わりを待たずにきたデータから順々に」なにか処理をするのを、パイプライン処理、とか、ストリーム処理、と言います。

また、パイプはシェル上でふたつのプロセスの標準入出力をつなぐだけではなく、プロセス上でも新しい入出力のペアを作ることができます。RubyだったらIO.pipeを使うと実現できるでしょう。詳しくはrubyの公式リファレンスやpipe(2)を参照してください。

次回予告

次回はファイルの入出力について、もっと深くまで潜っていきますよ!ファイルディスクリプタの話をして、ソケットの話をします。そのあとようやくファイルディスクリプタとforkの話ができたらいいな!さーて、次回も、サービス!サービスゥ!

ファイルディスクリプタ

さて、前回、プロセスがファイルを通じて外部との入出力する様を見て見ました。今回はさらにプロセスとファイル入出力について詳しく見てみましょう。

前回はさらっと流してしまいましたが、実はプロセスは自分自身で実際にファイルを開いたりディスクに書き込んだりディスクからデータを読み出したりすることはありません。そういう低レイヤーの処理は、プロセスがシステムコールをOSに送ることで、OSが代わりに行ってくれます。そのあたりの話を、きちんと見て行きましょう。

さて、なにはともあれ、プロセスが入出力をしたいと思ったら、ファイルを開くところから始めないといけません。

番号札システムで理解するファイル操作

プロセスとOSの間のファイル操作は、図書館の貸し出しシステムのような「番号札」を使ったやり取りで行われています:

ファイルを開くとき: * プロセス:「この本(ファイル)を借りたいです」 * OS:実際に本棚からファイルを取り出して貸し出しカウンターに用意 * OS:「はい、3番の番号札です。この札で本を識別してください」 * プロセス:3番の番号札を受け取る

ファイルに書き込むとき: * プロセス:「3番の番号札の本に、これを書き足してください」 * OS:「3番ですね。はい、書き込み完了しました」

ファイルを閉じるとき: * プロセス:「3番の番号札の本、もう使わないので返却します」 * OS:「3番ですね。本を本棚に戻して、番号札も回収しました」

この「番号札」のことを、正式には「ファイルディスクリプタ」と呼びます。実際、ファイルディスクリプタは3、5、7のような整数値で表現されています。

以降、この概念に慣れ親しんでもらうために「ファイルディスクリプタ」という正式名称で呼んでいきます。

例を見てみましょう。今回もRubyを使います。

# fd.rb
# File.open:ファイルを書き込みモードで開く(openシステムコール)
file = File.open("nyan.txt","w")
# fileno:OSから登用されたファイルディスクリプタ(整数の番号札)を取得
puts file.fileno
# File.close:ファイルを閉じてファイルディスクリプタをOSに返却
file.close

1行目で、openシステムコールをOSに対して送っています。正常にopenされると、OSから発行されたファイルディスクリプタを内部に持ったfileオブジェクトが生成されます。2行目で、fileオブジェクトが保持しているファイルディスクリプタを取得してターミナルに出力しています。3行目で、fileを閉じていますが、これはRubyが内部でfileオブジェクトが保持しているファイルディスクリプタを使って、OSにcloseシステムコールを送っているわけです。IO#readlineとかIO#writeメソッドなんかも、内部ではIOオブジェクトが保持しているファイルディスクリプタを使って、読み込みや書き込みのシステムコールを送ったりしているわけですね。

さて、説明がすんだところで、実際にfd.rbを実行してみましょう。

$ ruby fd.rb
6

「nyan.txtが書き込みモードで開かれたもの」についてるファイルディスクリプタが、6番なのが確認できましたね。(環境によって異なる数字が表示される場合があります)

特別なファイルディスクリプタ:標準入出力

さて、勘のいいひとはそろそろ例の標準入力は0、標準出力は1、標準エラー出力は2、という謎の数字の正体について、感付きつつあるのではないでしょうか。

そうです。実は、プロセスが生まれた時から持っている3つの「口」には、あらかじめ決まったファイルディスクリプタが割り当てられているのです:

つまり、どのプロセスも「0番のfd=キーボードからの入力」「1番のfd=画面への出力」「2番のfd=エラーメッセージ用の画面」という3つのファイルディスクリプタを最初から持っているということですね。実際に確かめてみましょう

# std_fds.rb
# 標準入出力のファイルディスクリプタを確認
puts $stdin.fileno  # 標準入力 => 0
puts $stdout.fileno # 標準出力 => 1
puts $stderr.fileno # 標準エラー出力 => 2

おー。

つまり、前回出てきた & という記号は、「ファイルパスじゃなくてファイルディスクリプタを指定してるよ」という意味の記号だったわけですね!そして、なぜリダイレクトのときに標準入力や標準出力にあのような数字が使われているのかが理解できたと思います。

&記号の意味をファイルディスクリプタで理解すると: * 2>&1 → 「ファイルディスクリプタ2番を1番と同じ場所に向けて」 * 1>file.txt → 「ファイルディスクリプタ1番をfile.txtに向けて」

オープンファイル記述

さて、今はプロセスの側からがファイルディスクリプタをどう扱っているかについて見てみましたが、今度はOSの側から見てみましょう。

OSが管理する「貸出台帳」システム

図書館で番号札を発行する司書さん(OS)は、どの本がどんな状態で貸し出されているかを把握しておく必要がありますよね。OSも同じで、以下の管理業務を行っています:

OSの管理業務: 1. プロセスに「ファイル開いて」って言われたら実際にファイルを開く 2. そのファイル専用の「貸出台帳」(詳細情報メモ)を作成 3. 台帳に詳細情報を記録: - 読み込み/書き込みどちらのモード? - ファイルの場所はどこ? - 現在どこまで読み込んだ/書き込んだ? 4. プロセス専用の番号札を発行 5. 「この番号札はこの貸出台帳と対応している」という関係を記録 6. 番号札をプロセスに渡す

この「貸出台帳」が、正式には「オープンファイル記述」と呼ばれるものです。

以降、この概念にも慣れ親しんでもらうために「オープンファイル記述」という正式名称で呼んでいきます。

ファイルディスクリプタだけでは「3番のファイル」としか分からないけど、オープンファイル記述があることで「3番のファイルは現在5行目まで読んでいて、読み込み専用で開いている」といった詳細な状況がOSに分かるわけですね。

オープンファイル記述がないと、プロセスから「次の行読み込んでよ」って言われても「ふぇぇ、次の行ってどこ〜〜〜〜〜」ってなっちゃいます。ファイルディスクリプタとオープンファイル記述の対応関係が分からないと、「5番のファイルディスクリプタに書き込んでよ」って言われても「ふぇぇ、どのオープンファイル記述を見ればいいのか忘れちゃったよ〜〜〜」ってなっちゃいます。

これで、たとえばpidが100番のプロセスから「ファイルディスクリプタ5番のファイルの、次の行読み込んでよ」と言われても、「ふぇぇ」ってならずに、OSは以下のように対応できます:

  1. 「100番のプロセスさんのファイルディスクリプタ5番に対応するオープンファイル記述はこれだな」
  2. 「オープンファイル記述には/path/to/fileの3行目まで読み込んだって書いてあるな」
  3. 「じゃあこのファイルの4行目を読み込めばいいね!」
  4. 「読み込み完了!オープンファイル記述も4行目まで読んだって更新しておこう」
  5. 「はい、データをお返しします!」

イメージを図にすると、こんな感じになります。

ファイルディスクリプタの作成 ファイルへの書き込み

ファイルディスクリプタとオープンファイル記述はforkでどうなる?

さて、では、forkしたとき、ファイルディスクリプタやオープンファイル記述はどうなるのでしょうか?

先に答えを言ってしまいましょう。forkした場合、ファイルディスクリプタは複製されますが、複製されたファイルディスクリプタは同一のオープンファイル記述を参照します。

図書館の例で説明すると:

fork前: * 親プロセス:「3番のファイルディスクリプタ」を持ち、「『夏目漱石全集』の100ページまで読んだ」というオープンファイル記述に対応

fork後: * 親プロセス:「3番のファイルディスクリプタ」を持つ * 子プロセス:「3番のファイルディスクリプタ」(複製)を持つ * 両方とも同じオープンファイル記述「『夏目漱石全集』の100ページまで読んだ」を共有

つまり、OSは新しいプロセス用に新しいファイルディスクリプタは発行するけど、そのファイルディスクリプタは同じオープンファイル記述に紐づけられているということです。オープンファイル記述は、親プロセスと子プロセスで共有されることになります。

そのため、forkしたときに同じファイルディスクリプタで親プロセスと子プロセス両方がファイル操作をすると、おかしなことになることがあります。

オープンファイル記述が共有されることで起きる現象

例を見ましょう。

# fork_fd.rb
# -*- coding: utf-8 -*-

# ファイルを読み込みモードで開く
read_file = File.new("nyan.txt","r")

# ファイルをopenしたあとにforkしてみる
# この時点でファイルディスクリプタは複製されるが、オープンファイル記述は共有される
pid = Process.fork

if pid.nil?
  # 子プロセス:親と子で同じファイルを交互に読み込む
  lines = []
  while line = read_file.gets  # 親が読んだ行は子では読めない
    lines << line
  end
  # 子プロセスの結果をchild.txtに書き込み
  write_file = File.new("child.txt","w")
  write_file.write(lines.join)
  write_file.close
else
  # 親プロセス:親と子で同じファイルを交互に読み込む
  lines = []
  while line = read_file.gets  # 子が読んだ行は親では読めない
    lines << line
  end
  # 親プロセスの結果をparent.txtに書き込み
  write_file = File.new("parent.txt","w")
  write_file.write(lines.join)
  write_file.close
end
# ファイルを閉じる(親と子両方で実行)
read_file.close

子プロセスと親プロセスで、nyan.txtから一行ずつ入力を受け取っています。

もしもオープンファイル記述まで複製されているならば: * 親プロセスが一行読み込んだとき → 親のオープンファイル記述が一行分進む * 子プロセスのオープンファイル記述は独立しているので影響を受けない * 結果:親も子も同じファイル内容を全部読み込める

実際はオープンファイル記述が共有されているので: * 親プロセスが一行読み込んだとき → 共有のオープンファイル記述が一行分進む * 子プロセスが次に読み込もうとすると → オープンファイル記述はすでに一行分進んでいるので、その行はもう読めない * 結果:親と子でファイルの内容を交互に読み込むことになる

では実際に確かめて見ましょう。nyan.txtに以下の内容を書き込んだ上で、fork_fd.rbを実行してみましょう

nyan
nyan nyan
nyan nyan nyan
nyan nyan nyan nyan
nyan nyan nyan nyan nyan
nyan nyan nyan nyan nyan nyan

実行します

$ ruby fork_fd.rb

さて、結果はどうなったでしょうか?オープンファイル記述が複製されずに共有されていることが実感できたかと思います。

ファイルディスクリプタは複製される

では今度は、ファイルディスクリプタは複製されているのを見てみましょう

# -*- coding: utf-8 -*-
# ファイルディスクリプタが複製されることを確認するサンプル
file = File.open("nyan.txt","r")

# ファイルをopenしてからforkする
# ファイルディスクリプタは複製されるが、オープンファイル記述は共有
pid = Process.fork

if pid.nil?
  # 子プロセス:親がファイルを閉じたあとでも読み込めるかテスト
  sleep 1 # 親プロセスがfileを閉じるのを待つ

  # 親プロセスがファイルディスクリプタを閉じても、
  # 子プロセスは複製されたファイルディスクリプタを持っているので読み込める
  puts file.readlines.join

  file.close # 子プロセスもファイルディスクリプタをOSに返却
else
  # 親プロセス:先にファイルを閉じる
  file.close # ファイルディスクリプタをOSに返却
  Process.wait(pid) # 子プロセスの終了を待つ
end

実行してみると、親プロセスがすでに番号札をOSに返してしまっても、子プロセスは複製された番号札を持っているので問題なくファイル操作ができているのが見て取れると思います。

このあたりのイメージを図にするとこんな感じです。

forkされたときのイメージ オープンファイル記述が共有されている

どうするのがベストプラクティスなの?

すでにfileがopenされている状態でforkすると、以上に見たように予期せぬ動作で混乱することがあります。そのため、forkした場合、親プロセスで使わないファイルは親プロセスですぐ閉じる、子プロセスで使わないファイルは子プロセスですぐ閉じるとすると、最も問題が起きにくいと思います。子プロセスでファイルを閉じたとしても、親プロセスでファイル使いたい場合に問題なく扱える(またはその逆も)のは、上に見た通りですからね

リダイレクトの順序ふたたび

さて、forkした際のファイルディスクリプタ、オープンファイル記述の振る舞いについては上に見たとおりです。では今度は前回謎の挙動として上げておいた、「リダイレクトの順序」について見てみましょう。

まずは、リダイレクトの順序の謎はどのようなものだったか簡単に復習してみましょう。

$ ruby stdout_stderr.rb 1>out.txt 2>&1

とすると、プロセス内で標準出力に書き出したものも標準エラー出力に書き出したものも out.txt に出力されるが

$ ruby stdout_stderr.rb 2>&1 1>out.txt

とすると、標準エラー出力に対する出力は、依然としてコンソールに出力されてしまう、というのがその謎の挙動でしたね。

このような挙動が何故起こるのか。それは、リダイレクトが実際にどのように実現されているのかを理解すると見えてきます。

リダイレクトはファイルディスクリプタの複製である

1>out.txt とすると、標準出力に対して出力した出力が、なぜコンソールにではなく out.txt に出力されるのか、その動きを見てみましょう。実は、1>out.txt というのは、「out.txtを書き込みモードで開いて、そのファイルディスクリプタを複製したものを fd:1(標準出力) とする」という意味なのです。

さて、ではここで、標準出力になにかを出力してみましょう。標準出力に対する書き込みは fd:1 に対する書き込みです。今、fd:1 は、out.txt を指していますね。こんな具合で、標準出力に対する書き込みは、out.txt に書き込まれることになるわけです。

では今度は、 2>&1 としたときのことを考えてみましょう。これは、「fd:1 を複製したものをfd:2 とする」という意味になりますね。これにより、fd:2 に対する書き込みは、fd:1 と同じ、ターミナルへ出力されることになります。

では、合わせ技を行ってみるとどうなるでしょうか。まずは意図通り動くパターンから見てみます。

$ command 1>out.txt 2>&1

まず、"1>out.txt" が評価されます。それによって、fd:1 は、out.txtを指すことになります。つぎに、"2>&1" が評価されます。この時点でfd:1 は out.txt を指していますから、fd:2 もout.txtを指すようになります。これで、無事に fd:1 (標準出力)に対する書き込みも out.txt に書かれるし、fd:2 (標準エラー出力)に対する書き込みも、out.txt に書かれるようになりました。Yay!

次に意図通りでないパターンを見ましょう。

$ command 2>&1 1>out.txt

まず、"2>&1"が評価されます。fd:2 は fd:1を複製したものになりますね。このとき、fd:1 はまだ変更されていないため、デフォルトのターミナルを指しています。というわけで、fd:2 はターミナルを指すことになります。次に、"1>out.txt" が評価されます。out.txt を書き込みモードで open して、そのファイルディスクリプタの複製が fd:1 になります。これで fd:1 は out.txt を指すようになりました。今、ファイルディスクリプタはどうなっているでしょうか? fd:1はout.txtを指していますが、fd:2はターミナルを指していますね。ここで、標準エラー出力(fd:2)に対して書き込みを行えば、当然、その出力結果はターミナルに出力されることになるわけです。Oops!

次回予告

ソケットの話してpreforkサーバーを自分で書いてみるつもり

preforkサーバーを作ってみよう

さて、前回は、fork するとファイルディスクリプタ(以下fdと表記)は複製されるけどオープンファイル記述は共有されるというのを見ました。これを利用して、preforkモデルのサーバーを実際に書いてみましょう。

tcp socketはファイルである

以前見たとおり、Linuxではプロセスの入出力はファイルを通じて行います。とうぜん、ネットワークごしに入出力する tcp socket もファイルです。ここで「ファイルです」が意味するのは、プロセスがソケットを通じて入出力をしようと思えば、socket の fd を通じて write や read を行うということですね。では、実際に socket がファイルであるところを見てみましょう

# -*- coding: utf-8 -*-
require "socket"

# 12345 portで待ち受けるソケットを開く
listening_socket = TCPServer.open(12345)

# ソケットもファイルなので fd がある
puts listening_socket.fileno

# ひとまずなにもせず閉じる
listening_socket.close

上記のような Ruby スクリプトを実行してみると、openしたソケットがfdを持つことが確認できるかと思います。

クライアントの接続を受け入れる

今は socket を開いてなにもせずにすぐ閉じてしまいましたが、今度はクライアントの接続を受け入れてみましょう。

# -*- coding: utf-8 -*-
require "socket"

# 12345 portで待ち受けるソケットを開く
listening_socket = TCPServer.open(12345)

puts listening_socket.fileno

# acceptでクライアントからの接続を待つ
# 接続されるまでブロックする
puts "accepting..."
socket = listening_socket.accept
puts "accepted!"

# 接続されると新しいsocketが作られる
# このsocketを通じてクライアントと通信する
# あたらしいsocketなのでfdの番号がlistening_socketと違う
puts socket.fileno

# なにもせずクライアントとのコネクションを切る
socket.close

# 待ち受けソケットも閉じる
listening_socket.close

上記のような Rubyスクリプトを適当な名前で作って、実行してみましょう。listening_socket の fd が出力されたあとに、accepting…と出力されて、そこで止まってしまいプロンプトが帰ってこないかと思います。なぜこういう動きをするか、いままでこのドキュメントを読み進めてきたみなさんはもう理解できますね。listen している socket で accept を呼び出すと、プロセスはそこでブロックして、クライアントからの接続を待ちます。そこでブロック中になっているため、プロセスがそれ以上進まないわけですね。

では、今度はそのままターミナルをもうひとつ開いてコンテナにログインして、ここにコネクションを貼ってみましょう。

# べつのターミナルでコンテナにログインして
$ telnet localhost 12345

上記のように、 telnet コマンドで、さっきのプロセスが待ち受けてる 12345 ポートに接続してみましょう。一瞬で接続が切られてしまうかと思います。

一方、今度はさっきプロンプトが返ってこないままになっていたターミナルを再度見てみてください。 accepted! のあとに、listening_socket の fd とはまた違う数字の fd が出力されて、プロンプトが返ってきたかと思います。これは、telnetでクライアントから接続されたことにより、accept の行でブロック中になっていたプロセスが動き出したためです。accept はクライアントから接続されるとブロック中から抜け出し、新しい socket を作り出して返します。サーバーのプロセスは、この新しい socket を通じてクライアントと通信をします。この socket にたいして write をすればクライアントへデータを送ることになるし、この socket から read をすれば、クライアントからの入力を受け取るという感じですね。とうぜん、この socket を close するとクライアントとのコネクションは切断されます。

今回はなにもせずに socket を close したので、クライアント側(telnetコマンドを打った側)ではすぐにサーバーからコネクションが切られてしまったわけですね。

クライアントから送られてくるデータを読み込む

さっきはなにもせず socket を close してしまいましたが、今度はクライアントからデータが送られてきたらそれを読む、という動きにしてみましょう。

# -*- coding: utf-8 -*-
require "socket"

listening_socket = TCPServer.open(12345)

# クライアント受け入れ無限地獄
loop do
  puts "accepting..."
  socket = listening_socket.accept
  puts "accepted a client!"

  # クライアントからの入力受け取り無限地獄
  loop do
    # クライアントからの入力を1行読む
    # 入力されるまでブロックする
    line = socket.gets
    line.gsub!(/[\r\n]/,"") #改行コード捨てる

    # exitと入力されてたらソケット閉じてループを抜ける
    if line == "exit"
      socket.close
      puts "closed a connection!"
      break
    end

    # そうでなければ標準出力に出力
    puts line
  end
end

はい、ちょっと書き換えてみました。

ターミナルを立ち上げてコンテナにログインして、これを実行してみましょう。このターミナル上で動いてるのがサーバープロセスになります。今は accepting… が出力されたところでプロセスがブロックしてると思います。ここまではさっきとおなじですね。では、またべつのターミナルを開いて、telnetコマンドでサーバープロセスに接続してみましょう。

$ telnet localhost 12345

今度は切断されないと思います。

ではまたサーバープロセスが走ってるほうのターミナルを見てみましょう。"accepted a client"と出力されて、そこでプロセスがブロックしていると思います。line = socket.gets のところで、クライアントからのデータを読み込もうとしていますが、クライアントがまだなにもデータを送っていないのでここでブロックしているわけですね。

では今度は telnet のほうのターミナルで、なんかを入力して、改行してみましょう。

再度サーバープロセスのほうを見てみると、今 telnet で入力した一行が、標準出力に書き出されているのが見て取れると思います。

では telnet のほうに戻って(何度も往復してたいへんだ!)、今度は exit と入力して改行してみましょう。すると、サーバープロセスが socket を close したことにより、接続が切れるかと思います。

サーバープロセスのほうを見てみると、"closed a connection!" が出力されたあと、また "accepting…" が出力されて、ブロックしてると思います。これは、break でクライアントからの入力受け取り無限地獄を抜けたはいいけれど、今度はクライアント受け入れ無限地獄loopによりまた listening_socket.accept しているところでブロックしてるわけですね。

動きを確認したら、サーバープロセスのほうで Ctrl + C を入力して、プロセスを終了してあげましょう。

いまは puts line で標準出力にクライアントからの入力を出力していますが、この行を socket に対する書き込みにすれば、いわゆるエコーサーバーとして動くプロセスになります。そのあたりは宿題とするので、自分で書き換えて動きを見てみてください。

このサーバーは出来損ないだ、たべられないよ

さて、これでクライアントから接続を待って、クライアントに接続されたらそのクライアントとネットワーク越しに入出力することができました。しかし、このサーバーには欠陥があります。わかりますか?

そう、このままでは、同時にひとつのクライアントしか処理できないのです。クライアントからの接続を accept したあとは、このプロセスは「クライアントの入力受け取り無限地獄」にいます。その無限地獄にいる限り、このプロセスは次の listening_socket.accept に到達することはありません。なので、「クライアントの入力受け取り無限地獄」を抜けるまでは新しく接続してこようとするクライアントを受け入れることができないのです。これは困りましたね。

じっさい、このサーバープロセスを立ち上げた状態で、さらにターミナルをふたつ立ち上げてコンテナにログインして、両方で

$ telnet localhost 12345

をしてみると、先に telnet したほうは普通に動くのだけれど、もういっこのほうはいくら入力してもサーバープロセスがうんともすんとも言わないのが見て取れると思います。

明日の同じ時間にここに来てください。本当のサーバーってやつを見せてあげますよ

べつに用意する食材もないので、明日の同じ時間を待つ必要はありません。段階的にコードを作っていきましょう。

まず、フォークの部分だけ作ってみます:

# -*- coding: utf-8 -*-
require "socket"

number_of_workers = 3
listening_socket = TCPServer.open(12345)

# 子プロセスを複数作る
number_of_workers.times do
  pid = fork

  if pid
    # 親プロセス:子プロセスを作り続ける
    puts "forked child process: #{pid}"
    next
  else
    # 子プロセス:ここでサーバー処理をする
    puts "I'm child process: #{Process.pid}"
    exit # ひとまず何もせず終了
  end
end

# 親プロセスは子プロセスの終了を待つ
Process.waitall

これを実行すると、3つの子プロセスが作られて終了するのが確認できると思います。

次に、子プロセスでaccept処理をするように書き換えます:

# -*- coding: utf-8 -*-
require "socket"

number_of_workers = 3
listening_socket = TCPServer.open(12345)

number_of_workers.times do
  pid = fork

  if pid
    # 親プロセス:次々にforkで子プロセスを作る
    next
  else
    # 子プロセス:クライアントからの接続を待つ
    puts "Child #{Process.pid} is accepting..."
    socket = listening_socket.accept
    puts "Child #{Process.pid} accepted a client!"

    # 簡単なメッセージを送って終了
    socket.puts "Hello from child #{Process.pid}!"
    socket.close
    exit
  end
end

# 親プロセスは子プロセスの終了を待つ
Process.waitall

この状態で実行して、別のターミナルから telnet localhost 12345 で接続すると、メッセージが表示されてすぐに切断されるはずです。

最後に、完全なpreforkサーバーに仕上げます:

# -*- coding: utf-8 -*-
require "socket"

number_of_workers = 3
listening_socket = TCPServer.open(12345)

number_of_workers.times do
  pid = fork

  if pid
    # 親プロセス:次々にforkで子プロセスを作る
    next
  else
    # 子プロセス:クライアント受け入れ無限地獄
    loop do
      puts "Child #{Process.pid} accepting..."
      # 子プロセスは全部ここでブロックする
      socket = listening_socket.accept
      puts "Child #{Process.pid} accepted a client!"

      # クライアントの入力受け取り無限地獄
      loop do
        line = socket.gets
        # クライアントが接続を切った場合はnilが返る
        break if line.nil?

        line.gsub!(/[\r\n]/, "")

        if line == "exit"
          socket.close
          puts "Child #{Process.pid} closed a connection!"
          break
        end

        # エコーサーバーとして動作
        puts "Child #{Process.pid} received: #{line}"
      end
    end
  end
end

# 子プロセスは無限ループしてるからここには届かない
# 親プロセスでは子プロセスの終了を待ち続ける
Process.waitall

listening_socket を作ったあとに、3回 fork するようにしてみました。親プロセスでは fork したあとに何もしないで子プロセスの終了を待ちます。一方、子プロセスでは、 accept を呼んでブロックしていますね。

さて、ここで前回の内容が役に立ちますよ。

listening_socket はファイルでした。そのため、fd を持ちます。そして、forkした場合、fd は複製されるけど、複製された fd は複製もとと同じオープンファイル記述を参照しているのでしたね。

というわけで、今、listening_socket を作ったあとに fork したことで、オープンファイル記述、つまり「ソケットは12345 portで待ち受けてるよ」と書かれた「ファイルどうなってたっけメモ」を全てのプロセスで共有している状態になっているわけです。ここまではいいですか?

そして、親プロセスではその listening_socket に対して何もせず、子プロセスで accept していますね。この3つの子プロセスは、「クライアントからの接続を獲得して新しい socket を作ろう」と身構えてブロックしている状態なわけです。ここで、あるクライアントが 12345 ポートに対して接続してきたとしましょう。なにが起こりますか?

3つの子プロセスは、それぞれがクライアントからの接続を受け入れて新しい socket を作ろうとしますが、オープンファイル記述が共有されているため、クライアントからの接続を受け入れられるのはたったひとつの子プロセスだけです。前回の内容を思い出して下さい。file を open したあと fork して、両方のプロセスでその file を読み込んだ場合、片方のプロセスでしか読み込むことができていなかったと思います。これと同じことが accept でも起こっているわけですね。

さて、首尾よく accept できて新しいソケットを獲得した子プロセスは、クライアントからの入力受け取り無限地獄へと突入します。というわけで、今接続してきたクライアントとのやり取りは、この子プロセスにまかせることができました。一方、残念ながら accept できなかった他の子プロセスは、さっきとおなじところでブロックしたままです。

さて、ここに、さらに新しいクライアントが接続してきた場合はどうなるでしょうか?子プロセスのうちひとつはまだクライアントからの入力受け取り無限地獄にいますが、ふたつのプロセスは accept でブロック中になっています。こんどはこのふたつのプロセスのうちのどちらかが accpet に成功して新しいソケットを作ってクライアントとやりとりすることになるわけです。

こんなふうに、あらかじめ子プロセスをいくらか fork しておいて、その子プロセスでクライアントからの接続を受け入れて処理するような仕組みを、「prefork」といいます。先に(pre)forkしておくサーバーってことですね。

さて、これで無事、同時に複数のクライアントからの接続を受け入れることが可能になりました。今回は 3 回forkしたので、同時接続数は 3 つまでですね。サーバープロセスの他にもターミナルをたくさん立ち上げて、それぞれで telnet localhost 12345 してみてください。3つまでは同時に処理できるけど、4つを超えると同時に処理できてないことが見て取れるかと思います。

今までの話で、preforkサーバーが書けて、さらにどうしてそんなことが可能なのかも理解できましたね!

preforkサーバーの利点と欠点

さて、上に見たように、preforkサーバーはひとつのプロセスがひとつのクライアントを受け持つようなアーキテクチャになっています。このアーキテクチャには明確な利点と欠点があります。

欠点:リソース効率の問題

1. 同時処理数の制限 - worker(子プロセス)の数 = 最大同時接続数 - 上の例では3つのプロセスなので、4つ目のクライアントは待機状態になる - 大量の同時接続に対応するには、その分だけプロセスを事前に生成する必要がある

2. メモリ使用量の問題 - プロセス1つあたり数MB〜数十MBのメモリを消費 - 1000の同時接続 = 1000個のプロセス = 数GB〜数十GBのメモリが必要 - スレッドやイベント駆動型と比べてメモリ効率が悪い

3. プロセス生成・切り替えのオーバーヘッド - プロセスの生成・破棄にはそれなりのCPU時間が必要 - プロセス間のコンテキストスイッチもコストが高い

利点:堅牢性とシンプルさ

1. アーキテクチャの単純さ - 各プロセスが独立しているため、コードの理解・デバッグが容易 - 複雑な排他制御(ロック)が不要 - プロセス間でメモリを共有しないため、データ競合が起きない

2. 障害の影響範囲が限定的 - 1つのプロセスがクラッシュしても、他のクライアントには影響しない - メモリリークが発生しても、そのプロセスが終了すれば完全にクリーンアップされる - バグがあるコードでも、影響は1つの接続に限定される

3. セキュリティの向上 - プロセス間の分離により、セキュリティホールの影響を限定化 - システムコールレベルでの分離が効いている

4. 安定性 - 実績のあるアーキテクチャ(Apache HTTPDなどで長年使用) - プロセスの独立性により、予期しない相互作用が少ない

適用場面

preforkモデルは以下のような場面で威力を発揮します:

逆に、数万〜数十万の同時接続が必要な場合は、Node.jsのようなイベント駆動型やGoのようなgoroutineベースのアーキテクチャの方が適しています。

次回予告

次回はちょっと話を戻して、forkした際に親が先に死んだり終わったりしたらどうなるのとかそういう話をしたいなって思います。

プロセスのライフサイクル:生から死まで

さて、前回までで fork とかファイルとかのことはだいたいわかってきたかと思います。今回は、プロセスの「生から死まで」の一生について詳しく見ていきましょう。特に、「親が死んだ子供は養子になるしかない」「子供が親の見てないところで死ぬとゾンビになってしまう」という、プロセスの終了にまつわる複雑な状況について理解を深めます。

プロセスのライフサイクル全体像

まず、プロセスの一生を整理してみましょう:

生成 → 実行 → 終了 → 回収
 ↓      ↓      ↓      ↓
fork   running exit   wait

1. 生成(fork) - 親プロセスがforkシステムコールで子プロセスを作成 - 子プロセスは親のメモリ空間をコピーして独立した環境を獲得

2. 実行(running) - プロセスが実際に処理を行っている状態 - 走行中、待ち状態、ブロック中の3つの状態を循環

3. 終了(exit) - プロセスが処理を完了し、exitシステムコールで終了 - しかし、この時点ではまだ完全には消滅していない

4. 回収(wait) - 親プロセスがwaitシステムコールで子の終了を確認 - この段階で初めてプロセスが完全に消滅

重要なのは、プロセスはexitしただけでは完全に消滅しないということです。親プロセスによる「回収」が必要なのです。

孤児プロセス:親が死んだ子供は養子になるしかない

プロセスツリーにおいて、親プロセスが子プロセスより先に死んでしまった場合、どうなるでしょうか?この状況を「孤児プロセス」と呼びます。実例で見てみましょう。

# 親が先に死んで子が孤児になるサンプル
pid = fork

if pid
    # 親プロセス:waitpidで子を待たずに先に終了
    sleep 1  # 1秒待ってから終了
    exit     # 子をwaitせずに親が先に死んでしまう
else
    # 子プロセス:親が生きている間と死んだ後の親のPIDを確認

    # Process.ppid:親プロセスのPIDを取得
    puts Process.ppid  # 親が生きている時の親のPID

    # 親が死ぬまで待つ(2秒 > 1秒なので親は先に死ぬ)
    sleep 2

    # 親が死んだ後の親プロセスPID(initプロセスの1になる)
    puts Process.ppid
end

さて、上のような Ruby スクリプトを実行すると、結果はどうなるでしょうか。

実行結果の流れ: 1. 子プロセスの1回目の Process.ppid:親プロセスが生きているので、そのPIDが表示される 2. 1秒後:親プロセスが終了し、プロンプトが戻ってくる 3. さらに1秒後:2回目の Process.ppid が実行され、プロンプトが戻った画面に「1」と表示される

なぜ「1」が表示されるのでしょうか?

initプロセスによる養子縁組

親プロセスに先立たれた子プロセス(孤児プロセス)は、initプロセス(PID=1)が代わりに親となって面倒を見てくれるのです。これは単なる設計上の親切ではなく、システムの安定性のために必要不可欠な仕組みです。

なぜinitが養子縁組をするのか: - すべてのプロセスには親が必要(プロセスツリーの整合性維持) - 子プロセスが終了したとき、誰かがwaitして回収する必要がある - 孤児プロセスを放置すると、システムリソースの管理が困難になる

initプロセスは「プロセスの里親」として、孤児となったすべてのプロセスを引き取り、適切に管理します。

ゾンビプロセス:親に看取られない子の悲劇

孤児プロセスとは逆の状況もあります。子プロセスが終了したにもかかわらず、親プロセスがwaitしてくれない場合です。この状況で生まれるのが「ゾンビプロセス」です。

ゾンビプロセスとは何か

ゾンビプロセスは以下の特徴を持ちます:

つまり、ゾンビプロセスは「死んでいるけど成仏できない」状態なのです。コードで見てみましょう

# ゾンビプロセスを作るサンプル
pid = fork

if pid
    # 親プロセス:子のPIDを表示してから無限ループ
    puts pid  # 子プロセスのPIDを表示

    # 無限ループで忙しくて子プロセスをwaitしない
    # このため子が死んでも回収されずゾンビになる
    loop do
        sleep
    end
else
    # 子プロセス:すぐに終了するが親にwaitされない
    exit  # 即座に終了(しかしゾンビになる)
end

上記のようなスクリプトを zombie.rb として保存して、バックグラウンド実行してみましょう

$ ruby zombie.rb &

親プロセスの puts pid が利いて、子プロセスのpidが出力されたかと思います。さて、この親プロセスは、まだバックグラウンドで無限ループしています。一方、子プロセスは即 exit しているので、もう実行が終了しています。しかし、親はこの終了を wait していません。この子プロセスは、実行がおわってもう死んでいるのに、誰にも看取られていない(wait されていない)状態です。

そこで、先ほどターミナルに表示された pid がどうなっているのか、ps コマンドで確認してみましょう。

$ ps <さっきターミナルに表示されたpid>

どうなりましたか?環境によって多少の違いはあるかもしれませんが、私の環境では

  PID TTY      STAT   TIME COMMAND
 3668 pts/2    Z      0:00 [ruby] <defunct>

と表示されました。STAT の部分に Z と出ていますね。これは、このプロセスがゾンビプロセスとなっていることを表します。

ゾンビの成仏:initプロセスによる救済

それでは、無限ループ中の親プロセスを fg でフォアグラウンドに戻して、Ctrl + Cで止めましょう。その状態で再度 ps でプロセスの状態を見てみると、さっきまでゾンビだったプロセスも、無事に成仏してなくなっていることが確認できると思います。

何が起こったのか:

  1. 親プロセスの終了:Ctrl+C で親プロセスが終了(子をwaitせずに死亡)
  2. ゾンビプロセスの孤児化:ゾンビ状態だった子プロセスが孤児プロセスになる
  3. initによる養子縁組:initプロセスが孤児となったゾンビプロセスの親になる
  4. 即座のwait:initプロセスが即座にwaitを実行してゾンビプロセスを回収
  5. 完全な消滅:ゾンビプロセスが無事に成仏

この仕組みにより、最終的にはすべてのゾンビプロセスがinitプロセスによって回収されることが保証されています。

プロセスライフサイクルの教訓

今回学んだプロセスのライフサイクルから、以下の重要なポイントが理解できます:

1. プロセス終了の2段階構造

プロセスの終了は単純な「消滅」ではなく、以下の2段階で行われます:

この2段階構造により、親プロセスは子プロセスの終了ステータスを確実に受け取れます。

2. 異常終了パターンとその対策

孤児プロセス(親が先に死ぬ) - 問題:子プロセスの管理者がいなくなる - 解決:initプロセスが自動的に養子縁組して管理

ゾンビプロセス(子が死んでも親がwaitしない) - 問題:プロセステーブルにエントリが残り続ける
- 解決:親プロセス終了時にinitが孤児ゾンビを回収

3. 適切なプロセス管理の重要性

プログラマーとして以下を心がけるべきです:

4. initプロセスの役割

initプロセス(PID=1)は単なる「最初のプロセス」ではなく、以下の重要な役割を持ちます:

このように、Unix系OSのプロセス管理は非常によく設計されており、様々な異常状況でもシステムの安定性が保たれるようになっています。

次回予告

次回はようやくシグナルについて書く予定です。共有メモリの話と、スレッドの話もその後にできればしたいけど、気力ないかもしれないので次回が最終回の可能性が微レ存……

シグナルとkill

さて、前回までで fork とプロセスの関係についてはなんとなく概要が把握できたんじゃないかなと思います。今回は、シグナルについてです。

プロセスに外から影響を与えたい(シグナル編)

プロセスが外界とコミュニケーションを取るための方法として、ファイルディスクリプタを通じた入出力というものがあることは前回までで見てきたとおりです。じつは、プロセスが外界とコミュニケーションを取る方法としてもうひとつ、「シグナル」というものがあります。第二回で見たとおり、プロセスは生成されたあとは実行中となり、処理が終わるまでは一心不乱に決められた動きを行っています。しかしたとえば、無限ループに陥ってしまったプロセスなどは、外から「あっちょっと君、ストップ!ストップ!」という感じで止めてあげられる仕組みがないと困りますよね。そういう感じで、外からプロセスに対して「割り込み」を行うための仕組みが「シグナル」です。

なにはともあれ、ためしてみましょう。

シグナルを送ってみる

まずはプロセスを作りましょう。

$ ruby -e 'loop { sleep }' &
$ ps

毎度おなじみ、sleep するだけの ruby プロセスです。ps でpid を確認しておきましょう。

このプロセスに対して、シグナルを送ってみます。

$ kill -INT <さっき確認したpid>

kill というのが、プロセスに対してシグナルを送るコマンドです。今回は -INT を指定することで、「SIGINT」というシグナルを送ってみました。「SIGINT」の他にもいろんなシグナルがありますが、今は置いておきます。さて、ではここでもう一度 ps コマンドでプロセスの様子を見てみましょう。

$ ps

すると、さきほどまで存在していた ruby プロセスが無くなっていることがわかると思います。これはいったいどうしたことでしょうか。実は、SIGINTというシグナルを受け取ると、デフォルト値ではそのプロセスは実行を停止するのです。sleep し続けていたプロセスに SIGINT というシグナルを送ったことによりプロセスに「割り込み」をして、そのプロセスの実行を止めてしまったわけですね。

シグナルを受け取ったときの動作を変えてみる

さきほど、「デフォルト値では」と言いましたが、ということは、シグナルを受け取ったときの動作を変更することだってできるわけです。やってみましょうか。

# papas.rb
# SIGINTへのカスタムハンドラーを設定するサンプル

# Signal.trap:指定したシグナルを受け取ったときの動作を定義
# SIGINTを受け取ったときはブロックの中身を実行(デフォルトの終了動作を上書き)
Signal.trap('INT') do
    warn "ぬわーーーーっっ!!"  # 標準エラー出力にメッセージ表示
end

# 無限ループでスリープし続ける(SIGINTでも終了しない)
loop do
    sleep
end

papas.rb という名前で上のようなスクリプトを作成して、バックグラウンドで実行してみましょう

$ ruby papas.rb &

さて、それではこのプロセスに対して、SIGINTを送ってみましょう。

$ kill -INT <"ruby papas.rb" の pid>

標準エラーに「ぬわーーーーっっ!!」が表示されたかと思います。そして再度 ps してみると、さっきは SIGINT を受け取って停止していたプロセスが、今回はまだ生きていることが見て取れるかと思います。これで、何度 SIGINT を送っても「ぬわーーーーっっ!!」と叫ぶだけで、死なないプロセスの完成です。パパスも適切にシグナル処理さえしていればゲマに殺されることもなかったというのに……。

さて、このままではこのプロセスは生き続けてしまうので、SIGTERMというシグナルを送信して適切に殺してあげましょう。

$ kill -TERM <"ruby papas.rb" の pid>

これで無事にパパスは死にました。

シグナルにはどんなものがあるの?

上に見たように、シグナルには SIGINT 以外にもいろいろないろいろなシグナルがあります。man 7 signal や man kill に一度目を通しておくと良いでしょう。それぞれのシグナルに、受け取ったときのデフォルトの動作が定義されています。

とりあえずここでは、signal(7) から、 POSIX.1-1990 で規定されているシグナルの種類を引いておきましょう。

Signal     Value     Action   Comment
-------------------------------------------------------------------------
SIGHUP        1       Term    Hangup detected on controlling terminal
                              or death of controlling process
SIGINT        2       Term    Interrupt from keyboard
SIGQUIT       3       Core    Quit from keyboard
SIGILL        4       Core    Illegal Instruction
SIGABRT       6       Core    Abort signal from abort(3)
SIGFPE        8       Core    Floating point exception
SIGKILL       9       Term    Kill signal
SIGSEGV      11       Core    Invalid memory reference
SIGPIPE      13       Term    Broken pipe: write to pipe with no readers
SIGALRM      14       Term    Timer signal from alarm(2)
SIGTERM      15       Term    Termination signal
SIGUSR1   30,10,16    Term    User-defined signal 1
SIGUSR2   31,12,17    Term    User-defined signal 2
SIGCHLD   20,17,18    Ign     Child stopped or terminated
SIGCONT   19,18,25    Cont    Continue if stopped
SIGSTOP   17,19,23    Stop    Stop process
SIGTSTP   18,20,24    Stop    Stop typed at tty
SIGTTIN   21,21,26    Stop    tty input for background process
SIGTTOU   22,22,27    Stop    tty output for background process

Signal のところがシグナルの名前、Value というところがそのシグナルを表す番号(kill -n pid でプロセスにそのシグナルを送ることができます)、Action のところがそのシグナルを受け取ったときのデフォルトの動作です。Term ならばプロセスを終了し、Coreならばコアダンプを吐いて終了します。Ignならばそのシグナルを無視します(なにもしない)し、Stopならば実行を一時停止、Contならば一時停止していたプロセスを再開します。Commentのところに、どのようなときにそのシグナルが送られてくるかが書かれていますね。たとえば SIGCHLD を見てみると、Child stopped or terminatedと書かれています。つまり、子プロセスが止まったり止められたりしたときに、その親プロセスはSIGCHLDを受け取るようになっているわけですね。

SIGCHLDを活用した子プロセス回収

前章で学んだゾンビプロセスの問題を解決する実践的な方法として、SIGCHLDシグナルを活用した子プロセスの非同期回収があります。

子プロセスが終了すると、親プロセスには自動的にSIGCHLDシグナルが送られます。このシグナルをキャッチして適切にwaitを行うことで、ゾンビプロセスの発生を防ぐことができます:

# SIGCHLDハンドラーによる子プロセス回収のサンプル
Signal.trap('CHLD') do
  # 複数の子プロセスが同時に終了する場合に備えてループで回収
  loop do
    begin
      # waitpid(-1, Process::WNOHANG):ノンブロッキングで任意の子プロセスをwait
      # 子プロセスがなければ即座にnilを返す
      pid = Process.waitpid(-1, Process::WNOHANG)
      break unless pid  # 回収すべき子プロセスがなければ終了
      puts "Child process #{pid} has been reaped"
    rescue Errno::ECHILD
      # 子プロセスが存在しない場合の例外をキャッチ
      break
    end
  end
end

# 複数の子プロセスを生成
5.times do
  if fork
    # 親プロセス:何もしない(SIGCHLDハンドラーが子を回収)
  else
    # 子プロセス:ランダムな時間後に終了
    sleep rand(3)
    exit
  end
end

# 親プロセスは他の作業を継続
puts "Parent process continues working..."
sleep 10

この手法により、親プロセスは子プロセスの終了を明示的に待つ必要がなく、子プロセスが終了した瞬間に自動的に回収が行われます。特にWebサーバーのようにリクエストごとに子プロセスを生成するアプリケーションでは、この仕組みが重要になります。

その他のハマりポイント

微妙なハマりポイントとして、SIGHUP や SIGPIPE があるので、そこだけ少し説明しておきましょう。

まずは SIGHUP についてですが、ログインシェルが死んだときに、そのログインシェルが起動したプロセスにはSIGHUPが送られてきます(じつはこれは正確な説明ではないのだけれど、このあたりの正確な説明は次回できたらします)。これがなにを意味するかというと、たとえば ssh でサーバーにログインして、バックグラウンドでなにかを動かしたまま logout したりすると、そのバックグラウンドプロセスに SIGHUP が送られます。SIGHUP のデフォルトの動作は Term なので、そのバッググラウンドプロセスは死んでしまいます。これを防ぐためには、 nohup コマンドを使ってプロセスを起動するか、プロセス側で SIGHUP を受け取ったときの動作を変更する必要があります。

つぎに SIGPIPE についてです。SIGPIPEは、壊れた pipe に対して書き込みを行ったときに受信されるシグナルです。これが問題を引き起こすことが多いのが、ネットワークサーバーを書いているときです。なんらかのトラブルなどですでに切断されてしまっているソケットに対してなにかを書き込みしようとすると(いくらでもその理由は考えられます)、プロセスは SIGPIPE を受け取ります。SIGPIPE のデフォルトの動作はTermなので、この時点でサーバーは突然の死を迎えることになるわけです。

_人人人人人_
> SIGPIPE <
 ̄YYYYY ̄

動かし続けることを前提としたプロセスでは、このあたりのシグナルをきちんとハンドリングしてあげないとハマることが多いので、頭の片隅に置いておくといいかもしれません。

次回へ続くなぞ

さて、シグナルについて基本的なことは見て来れたかと思います。では、forkなどと組み合わせて使った時にはどういう動きをするのでしょうか?見てみましょう。

まずは以下のようなスクリプトを用意してみます。

# fork_and_sleep.rb
# プロセスグループの動作を確認するためのサンプル

# forkで子プロセスを作成(親と子両方が続きを実行)
fork

# 親プロセスも子プロセスも無限ループでスリープ
loop do
    sleep
end

forkして子プロセスを作ったあと、親プロセスも子プロセスもスリープし続けるものですね。バックグラウンドで実行します。

$ ruby fork_and_sleep.rb &

ps コマンドで様子を見てみましょう

$ ps f
  PID TTY      STAT   TIME COMMAND
16753 pts/2    Ss     0:00 -bash
16891 pts/2    S      0:00  \_ ruby fork_and_sleep.rb
16892 pts/2    S      0:00  |   \_ ruby fork_and_sleep.rb
16928 pts/2    R+     0:00  \_ ps f

「f」を付けて ps を実行すると親子関係が一目でわかります。この場合は 16891 が親プロセス、16892 が子プロセスですね。では、fg でフォアグラウンドに戻して、Ctrl + C を押してみましょう。Ctrl+C は、プロセスに対してSIGINTを送信します。

OKですか? そうしたら、ここで再度 ps を実行してみましょう

$ ps f
  PID TTY      STAT   TIME COMMAND
16753 pts/2    Ss     0:00 -bash
17140 pts/2    R+     0:00  \_ ps f

子プロセスも一緒に消えていますね。では、今度は fg -> Ctrl+C のコンボではなく、kill -INT で SIGINT を送ってみましょう。

$ ruby fork_and_sleep.rb &

$ ps f
  PID TTY      STAT   TIME COMMAND
16753 pts/2    Ss     0:00 -bash
17288 pts/2    S      0:00  \_ ruby fork_and_sleep.rb
17289 pts/2    S      0:00  |   \_ ruby fork_and_sleep.rb
17293 pts/2    R+     0:00  \_ ps f
$ kill -INT 17288 # 親プロセスにSIGINTを送る

$ ps f
  PID TTY      STAT   TIME COMMAND
16753 pts/2    Ss     0:00 -bash
17352 pts/2    R+     0:00  \_ ps f
17289 pts/2    S      0:00 ruby fork_and_sleep.rb

「!?」 今度は子プロセスが残っています(親プロセスが死んだからinitの子供になっており、ツリーの表示も変わっています)。

さて、なぜこのようなことが起こるのでしょうか。この挙動を理解するには、「プロセスグループ」という新しい概念を学ぶ必要があります。

というわけで次回予告

次回はプロセスグループについて見てみましょう。多分次回が最終回!

see also

Perl Hackers Hub 第6回 UNIXプログラミングの勘所(3)

プロセスグループ と フォアグラウンドプロセス

前回の謎を解くカギ:「プロセスグループ」という概念

前回はプロセスとシグナル、そしてシグナルを明示的にプロセスに送るためのコマンド kill について見ました。そして最後にひとつ謎が残ったわけですが、今回はその謎を解いて行きましょう。

前回の謎のおさらい: - fg + Ctrl+C では親子プロセスが一緒に死んだ - kill -INT では親プロセスだけが死んで子プロセスが残った

この違いの秘密は「プロセスグループ」という概念にあります。

プロセスグループって何?

まずは基本事実から

さて、じつは今まで一度も意識したことはありませんでしたが、プロセスというのはかならずひとつのプロセスグループというものに属します

プロセスグループとは、簡単に言うと「関連するプロセスたちの集まり」です。会社でいう「部署」みたいなものですね。

見てみましょう。

$ ruby -e 'sleep' &

$ ps o pid,pgid,command
 PID  PGID COMMAND
1620  1620 -bash
1638  1638 ruby -e sleep
1639  1639 ps o pid,pgid,command

毎度おなじみ sleep し続ける ruby プロセスをバックグラウンドで実行して、ps を "o pid,pgid,command" 付きで実行してみました。「pidとpgidとcommandを表示する」くらいの意味です。おや、見慣れない PGID というものがありますね。これが、プロセスグループのidです。こんな感じで、プロセスがかならずひとつのプロセスグループに属していることが見て取れるかと思います。なんだか今は PID と同じ数字が PGID のところにも表示されていて、この PGID ってあまり意味や意義がわからない感じですね。

では、ここで、fork と組み合わせてみましょうか。

# fork.rb
# プロセスグループの基本的な動作を確認するサンプル

# forkで子プロセスを作成(子は親と同じプロセスグループに属す)
fork

# 親も子も無限スリープ
sleep

上記のような、 fork して sleep し続けるだけの fork.rb というスクリプトを作ってバックグラウンドで実行してみましょう。

$ ruby fork.rb &

$ ps o pid,pgid,command f
 PID  PGID COMMAND
1620  1620 -bash
1646  1646  \_ ruby fork.rb
1647  1646  |   \_ ruby fork.rb
1652  1652  \_ ps o pid,pgid,command f

今回は ps に f オプションを付けて tree 状に表示してみました。

さて、こうして見てみると、親プロセスであるプロセス(pid 1646)は PID と PGID が同じ数字ですが、そこから fork で生成された子プロセス(pid 1647)は、PID と PGID が別の数字になっています。そして、子プロセスのほうの PGID は、fork元である親プロセスの PGID になっているのがわかるでしょうか。

こんな感じで、実は fork された子プロセスは、親プロセスと同じプロセスグループに属するようになります。逆の言い方をすると、forkで子プロセスを作ることによって、「自分と同じプロセスグループに属するプロセス」が一個ふえるわけですね。

ちなみに、プロセスグループにはリーダーが存在して、PGID と同じ数字の PID のプロセスが、プロセスグループのリーダーです。forkすると、同じグループに属する子分ができる、みたいな感じですね。

プロセスグループをいじってみよう

さて、今かんたんに「fork すると子プロセスは自分と同じプロセスグループに属するようになる」と言いましたが、これはちょっとおかしいですね。そうです、以前見たように、すべてのプロセスは pid 1 のプロセスから fork で作られたのでした。そうなると、すべてのプロセスは pid 1 のプロセスと同じプロセスグループに属することになってしまいます。すべてのプロセスが同じグループに属すなら、グループの意味がないですね。だから、forkしたあと、プロセスグループをいじる仕組みが必要になってきます。それが setpgrp システムコールです。では例を見てみましょう。

# fork_setpgrp.rb
# 子プロセスが新しいプロセスグループを作るサンプル

pid = fork

raise "fork failed" if pid.nil?

if pid
    # 親プロセス:元のプロセスグループのリーダーのまま
    sleep
else
    # 子プロセス:新しいプロセスグループを作成

    # Process.setpgrp:引数なしで呼び出すと、
    # 自分の新しいプロセスグループを作成してそのリーダーになる
    # これにより子プロセスの PGID は自分の PID と同じになる
    Process.setpgrp
    sleep
end

上記のようなスクリプトを fork_setpgrp.rb という名前で保存して、バックグラウンドで実行、ps で確認してみましょう

$ ps o pid,pgid,command f
 PID  PGID COMMAND
1620  1620 -bash
1666  1666  \_ ruby fork_setpgrp.rb
1667  1667  |   \_ ruby fork_setpgrp.rb
1673  1673  \_ ps o pid,pgid,command f

今度は、子プロセスは親プロセスと同じ PGID ではなくなりました。setpgrp システムコールを引数なしで呼び出したことにより、今までグループ内で子分役をやっていた子プロセスが、新しく自分のグループを作り、リーダーになっていることが見て取れるかと思います。なんだかベンチャー界隈でよく聴く独立譚みたいな話ですね。

ちなみに、PGID は、親の側からいじることもできます。

# 親プロセスが子プロセスのプロセスグループを変更するサンプル
pid = fork

raise "fork failed" if pid.nil?

if pid
    # 親プロセス:子プロセスのプロセスグループを変更

    # Process.setpgrpを引数付きで呼び出す
    # 第1引数:変更したいプロセスのPID
    # 第2引数:新しいプロセスグループID(ここでは子のPIDと同じ)
    pgid = pid
    Process.setpgrp(pid, pgid)
    sleep
else
    # 子プロセス:新しいプロセスグループのリーダーになる
    sleep
end

こういう感じで親プロセスのほうで引数付きで setpgrp を呼び出すことで、子プロセスの PGID を設定することもできます。

いったんまとめ

こんな感じで、プロセスが fork で子プロセスを作ったとき、その時点ではその子プロセスは親プロセスと同じプロセスグループに属しています。プロセスグループを変更したいときには、この子プロセスの PGID を setpgrp システムコールでいじってあげれば良いわけですね。

ちなみに、シェルから起動されたプロセスは、シェルが勝手に setpgrp を呼んでくれるので、それぞれがプロセスグループのリーダーとなっています。

プロセスグループ全体に kill をしてみよう

さて、いままでの話だけでは、「プロセスグループってのがあるのはわかったけど、そんなもんがあってなにがうれしいの」という感じがしますね。うれしいことのひとつとして、kill でプロセスグループに属する全てのプロセスに一気にシグナルを送れる、というものがあります。kill で pid を指定する部分に、"-" を付けてあげると、pid ではなくて pgid を指定したことになります。やってみましょう。

$ ruby fork.rb &

$ ps o pid,pgid,command f
 PID  PGID COMMAND
1678  1678 -bash
1699  1699  \_ ruby fork.rb
1700  1699  |   \_ ruby fork.rb
1701  1701  \_ ps o pid,pgid,command f
$ kill -INT -1699 # 1699 ではなくて -1699 としている

$ ps o pid,pgid,command f # 一気にふたつのプロセスが消えている
 PID  PGID COMMAND
1678  1678 -bash
1702  1702  \_ ps o pid,pgid,command f

前回の謎に回答する

ではここで、前回の謎に回答しましょう。前回謎だった挙動は、「fg でプロセスをフォアグラウンドにしてから Ctrl+C で SIGINT を送信したときは子プロセスごと殺されたのに、 kill -INT でバックグラウンドのプロセスに SIGINT を送信したら親プロセスだけが殺される」という挙動でしたね。

勘のいいひとはすでにお気づきかもしれないですが、実は、「フォアグラウンド」とされる範囲は、プロセス単位ではなくて、プロセスグループ単位で決まっているのです。いくつか、例を見てみましょう。

# fork_and_trap_sigint.rb
# 同じプロセスグループ内の親子プロセスが一緒にSIGINTを受け取るサンプル

# SIGINTハンドラーを設定(デフォルトの終了ではなく例外を発生)
Signal.trap('INT') do
    raise "got SIGINT!"  # このメッセージが親と子両方で表示される
end

# forkで子プロセスを作成(同じプロセスグループに属す)
fork

# 親も子もスリープ(Ctrl+Cで両方にSIGINTが送られる)
sleep

上記のようなスクリプトをフォアグラウンドで実行して、Ctrl+C でSIGINTを送ってみましょう。すると、"got SIGINT!" がふたつ標準エラーに出力されるはずです。これは、子プロセスと親プロセスが同じプロセスグループに属しているため、このふたつのプロセスがフォアグラウンドで実行されているからですね。

では今度は、プロセスグループが別の場合を見てみましょう。

# fork_and_setpgrp_and_trap_sigint.rb
# 子プロセスが異なるプロセスグループに属してSIGINTを受け取らないサンプル

# SIGINTハンドラーを設定
Signal.trap('INT') do
    raise "got SIGINT!"  # 親プロセスだけで表示される
end

pid = fork
raise "fork failed" if pid.nil?

if pid
    # 親プロセス:フォアグラウンドプロセスグループに所属
    sleep  # Ctrl+CでSIGINTを受け取る
else
    # 子プロセス:新しいプロセスグループを作成

    # Process.setpgrpで新しいプロセスグループのリーダーになる
    # これにより子プロセスはフォアグラウンドプロセスグループから抜ける
    Process.setpgrp
    sleep  # Ctrl+CでSIGINTを受け取らない
end

上記のようなスクリプトをフォアグラウンドで実行して、Ctrl+C でSIGINTを送ってみましょう。すると、"got SIGINT!" が今度はひとつだけ出力されるはずです。これは、子プロセスが親プロセスのプロセスグループを抜けて別のプロセスグループになったため、フォアグラウンドから抜けてしまったためです。

別の例も見てみましょう。

# read_stdin_in_child.rb
# 同じプロセスグループ内の子プロセスが標準入力を受け取るサンプル

pid = fork
raise "fork failed" if pid.nil?

if pid
    # 親プロセス:標準入力を閉じて子の終了を待つ
    STDIN.close  # 親は標準入力を使わない
    Process.waitpid(pid)  # 子プロセスの終了を待機
else
    # 子プロセス:フォアグラウンドプロセスグループに属すので標準入力を受け取れる
    STDIN.each_line do |line|
        print line  # エコーサーバーとして動作
    end
end

上記のようなスクリプトを作成し、フォアグラウンドで実行してみましょう。親プロセスは子プロセスが終わるまで待ってるのでそこでブロックしています。子プロセスは標準入力からの入力を受け取ろうとそこでブロックしています。

ここでターミナルになんか文字を打ち込めば、子プロセスがその入力を受け取ってエコーしてくれます。

ではこれをsetpgrpとの合わせ技でやるとどうなるでしょう?

# setpgrp_and_read_stdin_in_child.rb
# 異なるプロセスグループの子プロセスが標準入力を受け取れないサンプル

pid = fork
raise "fork failed" if pid.nil?

if pid
    # 親プロセス:標準入力を閉じて子の終了を待つ
    STDIN.close
    Process.waitpid(pid)
else
    # 子プロセス:新しいプロセスグループを作成

    # Process.setpgrpで新しいプロセスグループのリーダーになる
    # これにより子プロセスはフォアグラウンドプロセスグループから抜ける
    # 結果、ターミナルからの入力を受け取れなくなる
    Process.setpgrp

    # STDINからの入力をエコーしようとするが、
    # フォアグラウンドではないので入力を受け取れない
    STDIN.each_line do |line|
        print line
    end
end

上記のようなスクリプトをフォアグラウンドで実行してみましょう。さっきとは異なり、ターミナルになにかを打ち込んでもおうむがえししてこないのが見て取れると思います。これは子プロセスが親プロセスとは別のPGIDに属したことによって、フォアグラウンドで実行されているプロセスグループから抜けたためですね。

さて、これで前回謎だった挙動にも説明がつきましたね。これで、プロセスグループの解説はおしまいにします。

おわりに

これにてこのシリーズはおしまいです。いかがだったでしょうか? 一度プロセスまわりについてまとめておきたいという動機で書き始めたのですが、これを書きながらわたしも理解があやふやなところが洗い出せたりして、なにかと有意義でした。

もしもこのドキュメントが役に立つと思っていただけたなら、勉強会とかそういうのであなたが属すコミュニティや会社に役立ててもらえたらとても嬉しいです。そのとき、「使ったよ!」とコメント欄とかメールとかで知らせてくれると、単純にわたしが喜びます(言わなくても自由に使っていただいてかまわないですけど)。