Rubyでプロセスとスレッドを学ぶ
ActiveRecordのデーターベースアクセス
さっきのRubyのmapをデータベースアクセスの際に使うデメリットをいまいち言語化できてなかったのでまとめる。
- mapはそもそもRubyの世界におけるArrayクラスのメソッド
- ActiveRecordは、SQLにおけるデータベースアクセスとデータマッピングを抽象化したメソッドがたくさん定義されているライブラリ。これらのメソッドの内部でデータベースアクセスとデータベースから取得したレコードをRubyのオブジェクトに変換している(おそらく)。ActiveRecordはRubyで使える。
- DBにある10万件の投稿から1つの特定の投稿を取得する際に、①10万件の中から1つの特定の投稿をActiveRecordのメソッド(またはSQL)のみを用いて取得するか、もしくは、②10万件のデータをActiveRecordのメソッドで取得してからmapメソッドで1つの特定の投稿を取得するかは、同じように見えて意外と違う。
- 前者は1件の投稿だけDBから取得して、その1件の投稿オブジェクトをメモリに展開する。後者は10万件の投稿をDBから取得して、10万件の投稿オブジェクトをメモリに展開してから1件の投稿オブジェクトを取得している。前者のメモリ使用量は大したことないが、後者のメモリ使用量は多い。後者の場合データが増えるごとに比例してメモリ使用量も多くなる
- メモリ使用量がとんでもなく増えると、OOMエラー(Linuxカーネルがメモリリソースを多く消費しているプロセスを強制的に殺すエラー)が発生したり、アプリケーションの応答速度が下がったりする可能性がある。例えばAPIサーバーのプロセスが死ぬと、ユーザーに適切なデータが返されなくてデータを扱うフロントの画面がうまく表示されないので、せっかくのビジネスチャンスを失ったりする。プロセスが死んでいる間、ビジネス的な損失を受ける可能性がある。この場合、コードを最適なものに修正するか、サーバのメモリのスペックを上げるか(金で殴る)が選択肢として挙げられると個人的には思う。
プロセスの厳密な定義を深掘りたい
プロセス
プロセスは、OSから見た際のプログラムの実行単位。OSから見てプログラムはプロセスとして管理されている。もっと厳密にいうと、「1つまたは複数のスレッドが実行されるアドレス空間と、これらのスレッドの実行に必要なシステムリソースのこと」
プロセスは自分が独占したメモリの中で動いている。その中で何をしても他のプロセスには影響を与えない。
OSは限られたCPUでプロセスをうまく実行するために、OSは適切にプロセスを切り替えている。
CPUコアの数だけ、並列処理ができる
psコマンドで実行中のプロセスを見れる
ps
PID TTY TIME CMD
638 ttys000 0:03.51 -zsh -g --no_rcs
64897 ttys003 0:01.03 -zsh -g --no_rcs
65703 ttys004 0:00.91 -zsh -g --no_rcs
プロセスのライフサイクル
プロセスが何らかの方法で生成される
↓
処理中(実行中、待ち状態、ブロック中の3パターンがある)
↓
終了
待ち状態はCPUでいつでも処理できる状態
ブロック中はIO待ちだから、CPUで処理できんよって状態
fork
通常、プロセスは、親プロセスがforkというシステムコールをOSに送ることで、生成される。forkを実行するとOSは親プロセスを複製して子プロセスを生成する。つまり、この時、メモリ上のデータが複製されている。
forkによってプロセスは生成されるため、基本的に全てのプロセスには「自分を生んだ親プロセス」が存在する。
forkした子プロセスはforkを実行した位置以降の処理を実行するので、そこは注意する。
全ての祖先となる最初のプロセスをinitプロセスと呼ぶ。プロセスは木構造の親子関係を持っている。この親子関係を「プロセスツリー」と呼ぶ。
macだとlaunchedというプロセスがinitプロセスと同等のプロセスである。
プロセスidが1であることが分かる。
ps ax | grep launchd
1 ?? Ss 58:58.94 /sbin/launchd
exec
あるプロセスがexecというシステムコールを実行すると、execの内容でそのプロセス自体の内容を書き換えて実行することができます。
- forkで親プロセスを複製したプロセスを作成
- そのプロセスでexecを実行して、別の内容のプロセスとして書き換えて実行する
この手順を踏むことで、親プロセスとは違うプロセスをどんどん生成することができる。
Kernel.#fork
プロセスの複製を作れる。親プロセスでは子プロセスのプロセスidを、子プロセスではnilを返す。
puts "forking..."
# forkメソッドを呼び出す
# 親プロセスでは子プロセスのpidが取得できる。
# 複製された子プロセスでは、pidはnilである
pid = Process.fork
p pid
# ここに来てるということは、正常にプロセスが複製された。
# この時点で親プロセスと子プロセスが *別々の環境で*
# 同時にこのプログラムを実行していることになる。
puts "forked!"
if pid.nil?
# 子プロセスはこっちを実行する
# execメソッドで、Rubyのプロセスを無限ループでsleepするプロセスに置き換える
# ここで子プロセスをexecしている
exec "ruby -e 'loop { sleep }'"
else
# 親プロセスはこっちを実行する
# 子プロセスが終了するのを待つ
# 親プロセスだからpidはnilではない
Process.waitpid(pid, 0)
end
実行結果
ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/sample.rb
forking...
81598
forked!
nil
forked!
psを実行した結果
確かに子プロセスが実行されていることが確認できた
ps
PID TTY TIME CMD
64897 ttys003 0:01.52 -zsh -g --no_rcs
81585 ttys003 0:00.12 ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/sample.rb
81598 ttys003 0:00.10 ruby -e loop { sleep }
65703 ttys004 0:01.19 -zsh -g --no_rcs
Process.pidでそのプロセスのプロセスidを出力することができた。
puts "forked!"
puts Process.pid
ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/sample.rb
forking...
forked!
84073
forked!
84086
pstreeコマンドを使うと、そのプロセスからフォークされたプロセスを知ることができる。
調査対象のプロセスidを指定する
pstree 84073
-+= 84073 yuuki_haga ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/sample.rb
\--- 84086 yuuki_haga ruby -e loop { sleep }
execを実行しないと、同じコマンドを実行している複製プロセスとして、OSによって管理されている。
puts "forking..."
# forkメソッドを呼び出す
pid = Process.fork
# forkに失敗すると返り値はnil
# raise "fork failed." if pid.nil?
# p pid
# ここに来てるということは、正常にプロセスが複製された。
# この時点で親プロセスと子プロセスが *別々の環境で*
# 同時にこのプログラムを実行していることになる。
puts "forked!"
puts Process.pid
if pid.nil?
# 子プロセスはこっちを実行する
# execメソッドで、Rubyのプロセスを無限ループでsleepするプロセスに置き換える
# exec "ruby -e 'loop { sleep }'"
puts "child process"
sleep
else
# 親プロセスはこっちを実行する
# 子プロセスが終了するのを待つ
# 親プロセスだからpidはnilではない
Process.waitpid(pid, 0)
end
ps
PID TTY TIME CMD
64897 ttys003 0:01.66 -zsh -g --no_rcs
85275 ttys003 0:00.14 ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/sample.rb
85288 ttys003 0:00.00 ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/sample.rb
65703 ttys004 0:01.47 -zsh -g --no_rcs
親プロセスを消すと子プロセスも消えるがそれは後々深ぼる
&をつけるとバックグラウンドプロセスとして起動できる。
ruby -e "sleep" &
[1] 76342
ターミナルを通して入力を受け付けることができなくなる。
fgを実行すればフォアグラウンドプロセスに戻せる
ジョブはシェルが管理するプログラムのグループのこと
基本は1プログラム1ジョブ
シェルからコマンドを叩いてプロセスを生成する場合、親プロセスはシェルのプロセスである。
ps
PID TTY TIME CMD
64897 ttys003 0:01.74 -zsh -g --no_rcs
86122 ttys003 0:00.13 ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/sample.rb
86135 ttys003 0:00.10 ruby -e loop { sleep }
65703 ttys004 0:01.67 -zsh -g --no_rcs
pstree 64897
-+= 64897 yuuki_haga -zsh -g --no_rcs
\-+= 86122 yuuki_haga ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/sample.rb
\--- 86135 yuuki_haga ruby -e loop { sleep }
ps axはシステム全体のプロセス。psはターミナルに関連づけられたプロセスを表示する
プロセスとファイル入出力
プロセスに外から何かを入力したり、プロセスが外に何かを出力する方法として、「ファイルの入出力」というのがあります。たとえば、ファイルに書かれたデータをプロセスがメモリー上に読み込んでなんか処理をするとか、処理を行った結果をテキストファイルに書き込みをするとか。例を見てみましょう。
自分の中だけで完結しているプロセスだとあんま意味ない。プロセスに外から何かを入力して、プロセスが外に何かを出力するのを観察してみる
ディスクにあったデータを、プロセス内部のメモリ上に展開して、
メモリ上に展開されたデータを別のファイルとしてディスクに出力した。
file = File.open("nyan.txt", "r")
# ファイルのデータはもともとディスクに存在している。プロセスがもともとメモリー内に持っているものではない
# このディスクに存在しているファイルのデータを、readlinesで変数に代入することで、
# プロセスの外部に存在しているディスクのデータを、プロセスの内部のメモリーに読み込んでいる
lines = file.readlines # ファイルの中身を全部読み込む
file.close
copy_file = File.open("nyan_copy.txt", "w")
copy_file.write(lines.join)
file.close
Linuxでは全てがファイル
Linuxでは、プロセスに関する全ての入出力をファイルと同じインターフェースで扱うことができる。プロセスがターミナルからの入力を受け取りたかったり、ネットワーク越しに入力をもらって、ネットワーク越しに出力したりなどをファイルと同じインターフェースで扱える。OS側で用意してくれる。
プログラム上で、ファイルをIOする際に一瞬プロセスは「ブロック中」になっている。
こんな感じで、「実際はdisk上のファイルじゃないもの」も、「disk上のファイルとおなじように」扱える。そういう仕組みがLinuxには備わっています。今はそれが「すべてがファイル」の意味だと思ってください。
標準入力、標準出力
プロセスへの入力元は標準入力を設定することで変更することができる。標準入力はデフォルトではターミナルに設定されている。
プロセスへの出力元は標準出力を設定することで変更することができる。標準出力はデフォルトではターミナルに設定されている。
標準入力も標準出力(どちらもデフォルトでターミナルが設定されている)もファイルと同じインターフェースでプロセスから操作できる。標準入力も標準出力もファイルディスクリプタが設定されていて、プロセスはそのファイルディスクリプタとシステムコールを使うことで、書き込んだり読み込んだりできる。
リダイレクト
リダイレクトを使うと、標準入出力に別のファイルを指定できる。
シェル上では、標準入力は0、標準出力は1、標準エラー出力は2という数字で表される。
↓標準出力のリダイレクト
# 標準出力にファイルを指定
# ファイルを生成しつつ。プロセスからの出力をファイルにアウトプットする
# 1を省略することもできる
ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/print_mew.rb 1>hina.txt
↓標準入力のリダイレクト
file = $stdin
# IOを待っているので、プロセスが「ブロック中」になっている。
lines = file.readlines
file.close
# rubyの組み込みグローバル変数 $stdout には、「標準出力」と言われるものが、
# すでにFile.openされた状態で入っています。この「標準出力」の出力先は、デフォルトではターミナルをさします
file = $stdout
file.write(lines.join)
file.close
# 0を省略することもできる
ruby /Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/stdout.rb 0<hina_hina.txt
mew
stdin_file = $stdin
# IOを待っているので、プロセスが「ブロック中」になっている。
lines = stdin_file.readlines
stdin_file.close
# rubyの組み込みグローバル変数 $stdout には、「標準出力」と言われるものが、
# すでにFile.openされた状態で入っています。この「標準出力」の出力先は、デフォルトではターミナルをさします
stdout_file = $stdout
stdout_file.write(lines.join)
stdout_file.close
# プロセスがファイルを標準入力としていて、プロセスの標準出力を設定することで、プロセスのアウトプット先がファイルになる
ruby std_in_out.rb 0<hina.txt 1>hoge.txt
標準エラー出力
プロセスからアウトプットされたエラーは、プロセスの標準エラー出力に設定されたファイルに出力される。
puts "this is stdout"
warn "this is stderr" # warnは標準エラー出力に引数を出力する
ruby stdout_stderr.rb 1>out.txt 2>err.txt
# こうもかける
# &をつけることで、この1は1っていう名前のファイルじゃなくて、標準出力を表す数字だ!」と表すことができる
ruby stdout_stderr.rb 1>out.txt 2>&1
パイプ
command_a | command_b
command_aのプロセスの出力結果が、commnad_bのプロセスの入力になる。
この際に、comnnad_aのプロセスの処理が完全に終わらなくても、出力があるなら、comnnad_bのプロセスに入力される。commnad_bのプロセスは、commnad_aからの入力が来るまで、プロセスの状態が「ブロック中」になる。
ちなみに、このように入力と出力をパイプでつないで、「ファイルの終わりを待たずにきたデータから順々に」なにか処理をするのを、パイプライン処理、とか、ストリーム処理、と言います。
へー
command_a.rb
puts "start command_a"
stdin_file = File.open("hoge.txt", "r")
lines = stdin_file.readlines
stdin_file.close
stdout_file = $stdout
stdout_file.write(lines.join)
stdout_file.close
command_b.rb
puts "start command_b"
stdin_file = $stdin
lines = stdin_file.readlines
stdin_file.close
stdout_file = $stdout
stdout_file.write([*lines, "command_b"].join)
stdout_file.close
確かに、最初は、並列実行されて、あとは、ruby commnad_b.rbのプロセスが、IOがあるからブロック状態になったな。
ruby command_a.rb | ruby command_b.rb
start command_b
start command_a
mew
hinana
command_b%
システムコール
システムコールとは、アプリケーションプログラムがカーネルを通してハードウェアにアクセスするためのインターフェースである。システムコールはアプリケーションプログラムとカーネルの間に存在する。
ファイルディスクリプタ
実はプロセスは自分自身で実際にファイルを開いたりディスクに書き込んだりディスクからデータを読み出したりすることはありません。そういう低レイヤーの処理は、プロセスがシステムコールをOSに送ることで、OSが代わりに行ってくれます。
プロセスは低レイヤーの処理を自分でするわけではなくて、プロセスがシステムコールを実行することで、OSに低レイヤーの処理を実行させている。
OSはプロセスから「ファイルを開け」というシステムコールを受け取ると、実際にファイルを開いて、そのファイルを表す識別子(ファイルディスクリプタ)を作成してプロセスに返す。プロセスはファイルディスクリプを使って、「このファイルディスクリプタで表されるファイルにこれを書き込め」というシステムコールを送る。OSはファイルディスクリプタで表された、既に開いているファイルに対して書き込みを行う。
書き込みが終了したら、プロセスは不要になったファイルディスクリプタをcloseというシステムコールでOSに返却する。OSはファイルディスクリプタが返却されたので、「もうこのファイルは使わないのか」と判断して、ファイルを閉じる。
つまり、ファイルディスクリプタとは、OSが開いているファイルの識別子である。
file = File.open("kuga.txt", "w")
puts file.fileno # => 9
file.close
1行目では、openシステムコールをOSに対して送っている。正常にopenされるとファイルディスクリプタを内部に持ったfileオブジェクトが生成される
2行目では、fileオブジェクトが保持しているファイルディスクリプタを取得してターミナルに出力する
3行目では、fileを閉じているが、これはRubyが内部でfileオブジェクトが保持しているファイルディスクリプタを使って、OSにcloseシステムコールを送っている。
プロセスから標準入出力が指し示すファイルのファイルディスクリプタを見てみる。
puts $stdin.fileno
puts $stdout.fileno
puts $stderr.fileno
# => ruby std_fds.rb
# 0
# 1
# 2
オープンファイル記述
プロセスに「ファイル開いて」って言われたら開いてあげる
ファイルを開いたら、そのファイル専用の「ファイルの状況どうなってるっけメモ」を作る
OSは、「ファイル開いて」っていうシステムコールを受け取ると、オープンファイル記述を作り出して自分で保持しておきます。さらに、システムコールを送ってきたプロセスのidに対して、新しい番号札(ファイルディスクリプタ)を返します。このとき、オープンファイル記述とプロセスidと番号札の関連も、自分の中に保持しておきます。
このオープンファイル記述子がファイルの状況を管理するメモのようなもの。
プロセスをフォークした場合、ファイルディスクリプタは複製されて、複製されたファイルディスクリプタは、同一のオープンファイル記述を参照する(オープンファイル記述は複製されない)
複製されたファイルディスクリプタってことは、同じ番号で複製されたってことか。フォークで複製したプロセスはpidはもちろん違う
ソケットもファイルと同じインターフェースで操作することができる。
プロセスからソケットに対して書き込んだり、ソケットからプロセスに入力したい場合、ソケットを表すfdと、read, writeのシステムコールをプロセスから実行すれば良い。
require "socket"
# 12345 portで待ち受けるソケットを開く
listening_socket = TCPServer.open(12345)
# ソケットもファイルなので、fdがある
puts listening_socket.fileno # => 10
# とりあえず閉じる
listening_socket.close
プロセスが死ぬ(プロセスが終了する)とは、そのプロセスの実行が終了することを指します。プロセスは、プログラムが実行された際に作成され、そのプログラムの実行が完了したり、エラーが発生して強制的に終了したりすると、そのプロセスは終了します。
親プロセスが死んだら、残された子プロセスはinitプロセス(macだとlaunchdプロセス)を親として、紐付けられることが分かった
pid = Process.fork
if pid.nil?
# exec 'ruby -e p "hello"'
# 子プロセス
# 親プロセスのidを取得する
puts "親プロセスid: #{Process.ppid}"
# 親が死ぬまで2秒まつ
sleep 2
# 親プロセスが死んだ後のppid
puts "親プロセスid: #{Process.ppid}"
sleep
else
# 親プロセス
sleep 1
# rubyプログラムを終了させる
# つまり、実行中のプロセスがなくなるので、プロセスが死ぬ
exit
end
pstree 1
-+= 00001 root /sbin/launchd
\--- 11837 yuuki_haga ruby kill_parent.rb
ps
PID TTY TIME CMD
11837 ttys003 0:00.00 ruby kill_parent.rb
64897 ttys003 0:04.37 -zsh -g --no_rcs
親プロセスにプロンプトが対応していることが分かった。
ゾンビプロセス
子プロセスが実行終了しているにもかかわらず、親プロセスに wait されないとプロセスが回収されず、ゾンビプロセスとして残ってしまう
pid = Process.fork
if pid.nil?
# 子プロセス
# 子プロセスは即死する
exit
else
# 親プロセス
# 子プロセスのpidを出力
puts pid
loop do
sleep
end
end
シグナル
プロセスはファイルディスクリプタを通じて外界と入出力のやり取りをする以外に、シグナルを利用して外界とやりとりする
killはプロセスに対してシグナルを送るためのコマンド
ruby -e "loop { sleep }" &
kill -INT 15663
Traceback (most recent call last):
3: from -e:1:in `<main>'
2: from -e:1:in `loop'
1: from -e:1:in `block in <main>'
-e:1:in `sleep': Interrupt
[1] + 15663 interrupt ruby -e "loop { sleep }"
killコマンドでSIGINTというシグナルをrubyプロセスに送ったことで、rubyのプロセスが死んだことが確認できる。
SIGINTシグナルをプロセスが受け取ると、デフォルト値では、そのプロセスは実行を停止する
SIGINTシグナルをプロセスが受け取った時に、挙動を変更したいなら、Signalモジュールのtrapメソッドを使う
trap("INT") do
warn "ぬわーーーーっっ!!";
end
loop do
sleep;
end
SIGINTシグナルは、プロセスの実行に対して割り込みをかけるシグナル。
SIGTERMシグナルは、プロセスの実行を終了するシグナル
SIGKILLシグナルは、プロセスを強制終了するシグナル
プロセスグループ
プロセスは、必ず一つのプロセスグループというものに所属している
ps -o pid,pgid,command
PID PGID COMMAND
13247 13247 -zsh -g --no_rcs
19375 19375 ruby -e sleep
64897 64897 -zsh -g --no_rcs
現時点だと、PIDと同じ値でPGIDが表示されている
子プロセスは、親プロセスと同じプロセスグループに所属することが分かる。
# fork.rb
Process.fork
sleep
ps -o pid,pgid,command -f
PID PGID COMMAND UID PPID C STIME TTY TIME
13247 13247 -zsh -g --no_rcs 501 572 0 11:01PM ttys000 0:02.24
20540 20540 ruby fork.rb 501 64897 0 11:42PM ttys003 0:00.14
20553 20540 ruby fork.rb 501 20540 0 11:42PM ttys003 0:00.00
64897 64897 -zsh -g --no_rcs 501 572 0 3:11PM ttys003 0:04.86
プロセスグループには、リーダーが存在していて、そのリーダーは、PIDとPGIDが同じプロセスである
プロセスグループのメリットは、killコマンドでプロセスグループを指定することで、プロセスグループに所属しているプロセスを一気に全て消すことができることである。kill で pid を指定する部分に、"-" を付けてあげると、pid ではなくて pgid を指定したことになる。
ps -o pid,pgid,command -f
PID PGID COMMAND UID PPID C STIME TTY TIME
13247 13247 -zsh -g --no_rcs 501 572 0 11:01PM ttys000 0:02.31
20540 20540 ruby fork.rb 501 64897 0 11:42PM ttys003 0:00.14
20553 20540 ruby fork.rb 501 20540 0 11:42PM ttys003 0:00.00
64897 64897 -zsh -g --no_rcs 501 572 0 3:11PM ttys003 0:04.86
kill -INT -20540
ps -o pid,pgid,command -f
PID PGID COMMAND UID PPID C STIME TTY TIME
13247 13247 -zsh -g --no_rcs 501 572 0 11:01PM ttys000 0:02.39
64897 64897 -zsh -g --no_rcs 501 572 0 3:11PM ttys003 0:04.87
参考記事
ワーカープロセス深掘りたい