どくしょめも Linuxのしくみ

2023-03-11 08:38
2023-03-13 07:51

Linux のしくみ を読んで気になった点や記憶に留めておきたい点をメモるページ

Linux の基礎

カーネルはなぜ必要なのか

例えば複数のプロセスがストレージデバイスを利用する状況下では命令の実行順序によってデータの破損や意図せぬデータのやりとりが発生してしまう可能性がある。またすべてのプログラムが制限なくデバイスへアクセスできる状態は問題を複雑にする。こうした問題を解決するため Linux ではハードウェア(主に CPU)の助けをかりてデバイスへはプロセスから直接アクセスできないようにし、デバイスへのアクセスは必ずカーネルを介する ような構造をとっている。

プロセス/カーネル/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 コマンドにてすべてのプロセスの以下のような情報が確認できる。

  • USER: プロセスの所有者
  • PPID: 親プロセスの PID
  • PID: プロセスに割り当てられる一意な識別子
  • PGID: プロセスグループに割り当てられる一意な識別子
  • TPGID: 制御端末グループに割り当てられる一意な識別子
  • SID: セッションに割り当てられる一意な識別子
  • %CPU: CPU 時間の占有率
  • %MEM: 物理メモリの占有率
  • VSZ: プロセスが確保した仮想メモリのサイズ(Virtual Set Size)
  • RSS: プロセスが確保した物理メモリのサイズ (Resident Set Size)
  • TTY: プロセスが紐づく仮想端末ファイル名(端末に紐づいていない場合は ? となる)
  • STAT: プロセスの状態
  • START: プロセス実行開始時刻
  • TIME: プロセスが CPU を占有した時間
  • COMMAND: プロセスを起動した際のコマンド

また man ps コマンドで確認できるとおりプロセスは以下のような状態を持つ。

  • D: IO 中などの理由で割り込み不可能なスリープ状態のプロセス
  • R: 実行キュー上にあり、現在実行中もしくは実行可能なプロセス
  • S: イベント完了待ちなどの理由で割り込み可能なスリープ状態のプロセス
  • T: 親プロセスからのシグナルによりトレースされているため停止中のプロセス
  • t: トレース中のデバッガにより停止中のプロセス
  • X: 終了したプロセス(通常、表示されることはない)
  • Z: 終了したが親プロセスによりリソースが解放されていないプロセス
  • <: 優先度の高いプロセス
  • N: 優先度の低いプロセス
  • L: リアルタイム処理やカスタム IO のためメモリのページをロックしているプロセス
  • s: セッションリーダーのプロセス
  • l: マルチスレッド化されているプロセス
  • +: キーボードや端末画面による対話的操作を占有するフォアグラウンドジョブ(シェルごとに1つしか存在しない)

プロセスとジョブの関係性

プロセスはカーネルの立場から見た処理の単位だが、似たような概念に ジョブ というものがある。ジョブはシェルのセッションからみた処理の単位にあたる。

そもそも セッション はユーザーが端末エミュレータにアクセスした場合または 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 関数を使う
    • 並列処理や IO 受付のためのプロセスを用意するユースケース
  • あるプログラムのから別のプログラムを実行する: fork 関数と exec 関数を使う
    • ターミナルから新しいプログラムを実行するなどのユースケース

以下それぞれ簡単な使用例を確認していく

fork 関数を使ったプロセスの複製

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 関数を利用した親プロセスからの fork した子プロセス終了待ち受け

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 関数を使った新しいプログラムの実行

あるプログラムが他のプログラムの実行をトリガーしたい場合は 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

常駐するプロセスが大量のゾンビプロセスを発生させている場合、カーネルのパフォーマンスに悪影響を及ぼすことがある。このようなゾンビプロセスの発生を防ぐために一般的には以下のような方法がある。

  1. fork したら必ず wait する: あたりまえ体操
  2. ダブル fork し孫プロセスで処理を開始したのち、子プロセスを終了させる: 孫プロセスの親プロセスはすぐにいなくなってしまうため孤児プロセスとなり、自動的に init の子プロセスとなるため、カーネルにより処理終了後すぐに始末される
  3. sigaction 関数を使う: wait させる必要がないことを宣言するようなもの

sigaction 関数を用いてゾンビ化しない子プロセスを作る

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 コマンドで確認でき、代表的なものと期待される標準的な動作として以下のシグナルがある。

  • SIGHUP(1): 端末のハングアップを意味し、デフォルトではプロセスを終了させる
  • SIGINT(2): プログラムへの割り込みを意味し、デフォルトではプロセスを終了させる(Ctrl + C)
  • SIGQUIT(3):プログラムの終了を意味し、プロセスを終了させる(Ctrl + Q)
  • SIGKILL(9): プログラムの強制終了を意味する

SIGINT を無視する実装を行う

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 コマンドでプロセスが 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 で稼働する複数のプロセスはカーネルの プロセススケジューラ により順番に 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 上での各並列度における処理の進み方は以下のグラフのようになる。

並列度1の場合のプロセススケジュールの様子

並列度2の場合のプロセススケジュールの様子

プロセスの優先度を変更するシステムコールとして nice , setpriority があり -20〜19 の間で値を設定できる(-20 が最高優先度)。4 つの子プロセス中ひとつだけ優先度を上げるような以下のような実装を追加すると nice の挙動が検証できる。

    elif pid == 0:
        if i == CONCURRENCY - 1:
            os.nice(-5)
        load(i)
        exit(0)

以下のように優先度をあげた処理 1 のプロセスが先に進んでいることがわかる。

並列度4の場合のプロセススケジュールの様子

複数一の論理 CPU で複数のプロセスがどのように実行されるか

複数の論理 CPU で稼働する複数のプロセスは並列に処理できる。単一の論理 CPU で検証した際に利用したコードの `os.sched_setaffinity(0, {0}) をコメントアウトし、2 vCPU の t2.medium で検証したところ下記のようなデータが得られた。

memo

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

About

ウェブ界隈でエンジニアとして労働活動に励んでいる @gomi_ningen 個人のブログです

Copyright © 53ningen.com