Linux のしくみ を読んで気になった点や記憶に留めておきたい点をメモるページ
例えば複数のプロセスがストレージデバイスを利用する状況下では命令の実行順序によってデータの破損や意図せぬデータのやりとりが発生してしまう可能性がある。またすべてのプログラムが制限なくデバイスへアクセスできる状態は問題を複雑にする。こうした問題を解決するため Linux ではハードウェア(主に CPU)の助けをかりてデバイスへはプロセスから直接アクセスできないようにし、デバイスへのアクセスは必ずカーネルを介する ような構造をとっている。
一般的な CPU には カーネルモード と ユーザーモード がある。カーネルモードでは特段制限なく命令が実行できるが、ユーザーモードではデバイスにアクセスを行うなどの一部の命令を実行できないようなっている。
一般のプロセスはユーザーモードで命令をしつつ、システムコール を通じてカーネルにしかできない操作をカーネルに依頼する。カーネルは CPU の助けを借りてカーネルモードでリソースアクセスなどが生じる命令の交通整理を担えることとなる。
システムコールには一例として以下のようなものがある
strace
コマンドを用いることによりあるプログラムにてどのようなシステムコールが発行されているのか確認できる。例えば次のような Hello, World! コードを strace コマンドを噛ませて実行すると write(1, "Hello!\n", 7Hello!)
というシステムコールが発行されていることがわかる。 strace
コマンドに -c オプションをつけると各システムコールの回数を表示でき、-T オプションをつけると各システムコールの実行に要した時間を表示できる。
package main import ( "fmt" ) func main() { fmt.Println("Hello!") }
sar
コマンドを用いると論理 CPU がユーザーモード/カーネルモードそれぞれどの程度の割合で稼働しているのか統計データの確認ができる。例えば while ループを回しっぱなしにするとユーザーランドでの実行割合が高くなる。システムコールを発行するループを回すと %system の割合が高くなる。
$ cat loop.py while True: pass $#taskset コマンドにより指定した論理 CPU でプロセスを起動する $ taskset -c 0 python loop.py & [1] 3508 $#sar -P <論理CPU> <統計間隔> <統計回数>: $ sar -P 0 1 5 ... 05:57:02 AM CPU %user %nice %system %iowait %steal %idle 05:57:03 AM 0 100.00 0.00 0.00 0.00 0.00 0.00 ... 05:57:07 AM 0 100.00 0.00 0.00 0.00 0.00 0.00 Average: 0 100.00 0.00 0.00 0.00 0.00 0.00 $ kill 3508
ソフトウェアは共通の処理をまとめたライブラリを使って実装されることがほとんど。ライブラリのなかでも特に多くのプログラムが使うであろう処理は OS が提供していることがある。一般的に Linux での標準 C ライブラリとして libc(glibc) が使用されている。libc はシステムコールのラッパー関数も提供しており、この部分でアーキテクチャ間の差異を吸収することができる。
あるプログラムがどのようなライブラリをリンクしているかは ldd
コマンドで確認できる。
ライブラリにはコンパイル時にオブジェクトファイルにライブラリを組み込みスタンドアロンで動作させる 静的ライブラリ と、どのライブラリのどの関数を呼び出すのかという情報のみをオブジェクトファイルに組み込み実行時にライブラリから関数の実体をロードする 共有ライブラリ がある。仕組みからわかるとおり前者のほうが生成されるバイナリのサイズは小さい。
$ sudo yum install gcc glibc-static -y $ cat hello.c #include <stdio.h> int main() { printf("Hello"); return 0; } $ gcc hello.c -o hello_shared $ gcc -static hello.c -o hello_static $ $ ls -al ... -rwxrwxr-x 1 ec2-user ec2-user 8176 Nov 8 06:35 hello_shared -rwxrwxr-x 1 ec2-user ec2-user 701496 Nov 8 06:35 hello_static $ ldd hello_shared linux-vdso.so.1 (0x00007ffcca950000) libc.so.6 => /lib64/libc.so.6 (0x00007ff4e0801000) /lib64/ld-linux-x86-64.so.2 (0x00007ff4e0bae000) $ ldd hello_static not a dynamic executable
コンピュータの電源をいれるとファームウェア(BIOS/UEFI など)によりハードウェアの初期化が行われたのちブートローダー(GRUB など)が実行される。ブートローダーはまずはじめに OS カーネルを起動し、OS カーネルがすべてのプロセスのルートとなる init プロセス(systemd)を起動する。したがって、他のプロセスは init プロセスの子プロセスとして実行される。これは pstree -p
コマンドで手軽に確認できる。
ps
コマンドにてすべてのプロセスの以下のような情報が確認できる。
また man ps
コマンドで確認できるとおりプロセスは以下のような状態を持つ。
プロセスはカーネルの立場から見た処理の単位だが、似たような概念に ジョブ というものがある。ジョブはシェルのセッションからみた処理の単位にあたる。
そもそも セッション はユーザーが端末エミュレータにアクセスした場合または ssh などにより端末にログインしたときのログインセッションに対応しており、同一のセッション ID を持つプロセスをそのセッションにおけるジョブであると定義できる。各プロセスのセッション ID は ps jx
コマンドで確認できる。
$ ps jx PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 3745 3829 3745 3745 ? -1 S 1000 0:00 sshd: ec2-user@pts/2 3829 3830 3830 3830 pts/2 3830 Ss+ 1000 0:00 -bash 4417 4451 4417 4417 ? -1 S 1000 0:00 sshd: ec2-user@pts/0 4451 4452 4452 4452 pts/0 4594 Ss 1000 0:00 -bash 4452 4594 4594 4452 pts/0 4594 R+ 1000 0:00 ps jx
ジョブは Linux との入出力を行う端末と対話可能な フォアグラウンドジョブ とそれ以外の バックグラウンドジョブ に分類できる。ジョブの一覧は jobs
コマンドにて確認可能で bg %<job_id>
コマンドと fg %<job_id>
コマンドでそれぞれジョブをフォアグラウンドにしたり、バックグラウンドにしたりといった操作が可能となる。
$ sleep 1000 & [1] 4567 $ jobs [1]+ Running sleep 1000 & $ echo hoge hoge $ fg %1 sleep 1000
bash など最初に端末を開いた際のプロセスを セッションリーダー とよび、同一セッション内のすべてのジョブの SID はセッションリーダーの PID となる。端末を閉じたりログアウトしたりした場合、そのセッションのすべてのプロセスに SIGHUP シグナルが送られる。したがって端末を閉じた際にも処理を継続したい場合には SIGHUP シグナルを無視するプロセスを生成する nohup
コマンドを使う必要がある。
$ nohup sleep 1000 & [1] 5029 $ nohup: ignoring input and appending output to ‘nohup.out’ $ kill -1 5029 $ ps PID TTY TIME CMD 5006 pts/1 00:00:00 bash 5029 pts/1 00:00:00 sleep 5030 pts/1 00:00:00 ps $ kill -9 5084 [1]+ Killed nohup sleep 1000 $ ps PID TTY TIME CMD 5006 pts/1 00:00:00 bash 5086 pts/1 00:00:00 ps
また各コマンド実行に対して プロセスグループ というものも作成される。たとえば cat tmp.csv | grep hoge
というコマンドを実行した際には cat tmp.csv
ジョブと grep hoge
が同一のプロセスグループとなり、同じ PGID が割り当てられる。ひとつのコマンドもしくは複数のパイプライン実行に使われるプロセスの集まりに対して生成されるグループということもできる。kill -<PGID>
コマンドでプロセスグループにシグナルを送ることが可能。
新しいプロセスを作るシチュエーションは主に以下の 2 つに分類できる
以下それぞれ簡単な使用例を確認していく
fork 関数を呼び出すと子プロセスのメモリ領域を用意し親プロセスのメモリをコピーしたのち、親子双方のプロセスが fork 関数の値を返して処理に復帰するふるまいとなる。たとえば以下のコードは fork 関数呼び出し直後から親子双方のプロセスで実行される。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int main() { fork(); sleep(1000); return 0; }
fork 関数呼び出し後、sleep していることは ps コマンドで確認できる。
$ ps f PID TTY STAT TIME COMMAND 36720 pts/3 Ss 0:00 -bash 37315 pts/3 S 0:00 \_ ./fork 37316 pts/3 S 0:00 | \_ ./fork 37386 pts/3 R+ 0:00 \_ ps f ...
waitpid 関数で親プロセスは子プロセスの終了を待ち受けることができる。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/wait.h> int main() { pid_t pid = fork(); int test = 0; switch (pid) { case -1: printf("fork failed"); break; case 0: test = 10; printf("child: %d\n", test); sleep(1); printf("sleep 1s...\n"); sleep(2); printf("sleep 2s...\n"); exit(0); break; default: waitpid(pid, NULL, 0); printf("parent %d\n", test); break; }; return 0; }
実行すると下記のようになる。プロセスの複製時にメモリの内容がコピーされている(正確にはコピーオンライト)ため子プロセスで test 変数の値をいじっても親プロセスの変数に影響を及ぼさない。プロセス間で値を共有する際には プロセス間通信 という仕組みを使う必要がある。
$ gcc test.c && ./a.out child: 10 sleep 1s... sleep 2s... parent: 0
あるプログラムが他のプログラムの実行をトリガーしたい場合は fork でプロセスを複製したのち、子プロセス側で新しい処理を開始する exec 関数を呼び出せばよい。exec 関数は実行ファイルのプログラムをメモリ上に配置しその実行を開始する命令となる。たとえば下記のように親プロセスから fork したプロセス上で echo を実行できる。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/wait.h> int main() { pid_t pid = fork(); switch (pid) { case -1: printf("fork failed"); break; case 0: // child process execl("/bin/echo", "/bin/echo", "Hello,", "World!", NULL); break; default: // parent process sleep(10); break; } return 0; }
単純に fork した際はプロセスの複製であったため親子のプロセスの CMD は同じであったが、exec すると指定されたコマンドの内容が実行されていることが ps コマンドでわかる。
$ ./exec & [1] 40178 Hello, World! $ ps f PID TTY STAT TIME COMMAND 39607 pts/1 Ss 0:00 -bash 40178 pts/1 S 0:00 \_ ./exec 40179 pts/1 Z 0:00 | \_ [echo] <defunct> 40180 pts/1 R+ 0:00 \_ ps f
親プロセスは子プロセスの終了を waitpid 関数でキャッチできるということは裏を返せば子プロセスの終了後も関連する情報がシステム上に残っているということになる。実際、/proc 下の各ファイルは残ったままになる。したがって親プロセスは子プロセスの終了を waitpid 関数でキャッチしてあげなければリソースがリークすることとなる。
このように親プロセスが子プロセスの終了を待ち受けずに関連する情報がシステム上に残っている子プロセスを ゾンビプロセス とよぶ。ゾンビプロセスはたとえば下記のコードをバックグラウンドジョブで実行し親プロセスが終了するまでの 10 秒間観測できる。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/wait.h> int main() { pid_t pid = fork(); switch (pid) { case -1: printf("fork failed"); break; case 0: // child process exit(0); break; default: sleep(10); break; } return 0; }
なお、子プロセスが wait される前に親プロセスが終了した場合、その子プロセスを孤児プロセスとよび、カーネルにより init プロセスの子プロセスとして扱われるようになる。init プロセスは定期的に wait 待ちのゾンビプロセスを待ち受けリソースを解放する仕組みになっている。
親プロセスが終了すると同じプロセスグループ ID を持つプロセスはすべて終了するため、ゾンビプロセスのリソース情報も解放される。
上記のコードをバックグラウンドジョブとして実行し 10 秒以内にプロセスの情報を確認すると確かに子プロセスの STAT はゾンビプロセスであることを表す Z と記されている。
$ ./zombi & $ ps f PID TTY STAT TIME COMMAND 39607 pts/1 Ss 0:00 -bash 40497 pts/1 S 0:00 \_ ./zombi 40498 pts/1 Z 0:00 | \_ [zombi] <defunct> 40499 pts/1 R+ 0:00 \_ ps f
常駐するプロセスが大量のゾンビプロセスを発生させている場合、カーネルのパフォーマンスに悪影響を及ぼすことがある。このようなゾンビプロセスの発生を防ぐために一般的には以下のような方法がある。
sigaction 関数を使って子プロセスを wait しないことを宣言すると fork した子プロセスはゾンビ化しない。ただし wait を呼び出すとエラーとなるので注意が必要。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/wait.h> int main() { pid_t pid = fork(); switch (pid) { case -1: printf("fork failed"); break; case 0: // child process exit(0); break; default: sleep(10); break; } return 0; }
実行してみると子プロセスは親プロセスの wait を待たずにプロセス一覧から消え去っていることがわかる。
$ ./sig & [1] 42714 $ ps f PID TTY STAT TIME COMMAND 42492 pts/2 Ss 0:00 -bash 42714 pts/2 S 0:00 \_ ./sig 42716 pts/2 R+ 0:00 \_ ps f
端末が割り当てられておらず、ログインセッションと切り離された独自のセッションを持ち、親プロセスが init となっている常駐プロセスを デーモンプロセス とよぶ。
// TODO
// TODO
プロセスは基本的に実装された内容にしたがって実行されていくが外部からの通知である シグナル により処理を変化させる、あるいは終了させるといったこともできる。
どのようなシグナルがあるかは man signal
コマンドで確認でき、代表的なものと期待される標準的な動作として以下のシグナルがある。
signal 関数を用いると各シグナルに対するプロセスの振る舞いを再定義できる。例えば SIGINT を無視するプロセスを作るためには以下のように実装すればよい。
#include <stdio.h> #include <stdlib.h> using namespace std; int main() { signal(SIGINT, SIG_IGN); while (true) ; return 0; }
一般のプロセスであれば Ctrl + C(SIGINT) でプロセスを終了させられるが、上記の内容を実行すると Ctrl + C でプロセスを終了できない。kill コマンドで SIGKILL を送ることにより終了できる。
$ sleep 100 ^C $ $ ./sigignore ^C^C^C^C^C^C^C #=> SIGINT が無視されているので別の端末から PID を特定して kill する必要がある
ただし SIGKILL はプロセスを殺すためのシグナルなので signal 関数で振る舞いを変えることはできない。
Linux では タイムスライス という単位時間ごとに複数のプロセスに対して順番に CPU を使用させる形となっている。この役割を担うのがカーネルの プロセススケジューラ となる。
time コマンドを使うとプロセスの実際の実行時間(real)、ユーザーランドでの CPU 使用時間(user)、カーネルランドでの CPU 使用時間(sys)を計測できる。
例えば下記のように sleep コマンドは基本スリープしており CPU を使用しないので user, sys ともにほぼ 0 に近い値になる。また単純に 10000 回足し算を行う際には特段システムコールは必要ないため user の値が支配的になる。その一方でひたすら /dev/null に書き込むコマンドは多数のシステムコールを発行することになるため sys の値が大きくなる。
$ $ time sleep 3 real 0m3.001s user 0m0.001s sys 0m0.000s $ load() { for ((i=0; i < 1000000; i++)) do :; done } $ $ time load real 0m2.895s user 0m2.895s sys 0m0.000s $ $ time for ((i=0; i < 1000000; i++)); do echo hello > /dev/null; done real 0m12.972s user 0m8.986s sys 0m3.859s
単一の論理 CPU で稼働する複数のプロセスはカーネルの プロセススケジューラ により順番に CPU による演算の機会が与えられる。プロセスに与えられる演算時間の単位を タイムスライス とよぶ。スケジューラがタイムスライスの単位で順番に複数のプロセスに処理の機会を与えていることは以下のようなコードで確認できる。
import os import time # 並列度 CONCURRENCY=2 # for t2.micro LOOP=5000 def load(n): p = {} f = open(f"{n}.dat", "w") for i in range(100): p[i] = time.perf_counter() for j in range(LOOP): pass for i in range(100): f.write(f"{(p[i] - start) * 1000}, {i}\n") f.close() os.sched_setaffinity(0, {0}) start = time.perf_counter() for i in range(CONCURRENCY): pid = os.fork() if pid < 0: exit(1) elif pid == 0: load(i) exit(0) for i in range(CONCURRENCY): os.wait()
出力されたデータは gnuplot -e "set terminal png; set autoscale x; set autoscale y; plot '0.dat', '1.dat';" > /tmp/result.png
コマンドで手軽に可視化できる。1 vCPU の t2.micro 上での各並列度における処理の進み方は以下のグラフのようになる。
プロセスの優先度を変更するシステムコールとして nice
, setpriority
があり -20〜19 の間で値を設定できる(-20 が最高優先度)。4 つの子プロセス中ひとつだけ優先度を上げるような以下のような実装を追加すると nice の挙動が検証できる。
elif pid == 0: if i == CONCURRENCY - 1: os.nice(-5) load(i) exit(0)
以下のように優先度をあげた処理 1 のプロセスが先に進んでいることがわかる。
複数の論理 CPU で稼働する複数のプロセスは並列に処理できる。単一の論理 CPU で検証した際に利用したコードの `os.sched_setaffinity(0, {0}) をコメントアウトし、2 vCPU の t2.medium で検証したところ下記のようなデータが得られた。
start=`date +%s%3N` for ((i=0; i < 2; i++)); do taskset -c 0 ./load.sh $i $start & done sleep 10 echo -n "" > ./data for i in $(seq 0 1); do cat /tmp/$i >> data; done
TODO