「Linuxのしくみ」の読書メモ
業務でかれこれ6年LinuxOS上で開発をしてるが、Linuxの基本的な知識がまだまだ足りないと考えている。とても良い本であると評判を聞き、この本で基礎から学習しなおしたいと考えた。 実機での環境を推奨しているが、まずは仮想環境で確認しながら進めてみる。実行結果が異なる箇所は脳内で補完してすすめてみる。
- 開発環境構築 30m
- docker, ubuntu
- MacOS HighSierra
- Docker
- Ubuntu 16.04LTS
- コンピュータシステム概要
- ユーザモードで実現する機能
- プロセス管理
- プロセススケジューラ
- メモリ管理
- 記憶階層
- ファイルシステム
- ストレージデバイス
Linuxはアプリケーションがデバイスへ直接操作できないよう、 デバイスドライバというインターフェースを用意している。これによりデバイスへの共通操作はインターフェースを通じて操作させる事を実現しているのと、アプリケーション側にデバイス操作をするための詳しい仕様を把握しなくても良いよう設計されている。CPUにはカーネルモード、ユーザモードという2つのモードがある。 デバイスドライバはカーネルモードで動作する。 カーネルモードで動作する一連のプログラムをまとめたものを カーネルと呼んでいる。カーネルが提供するプログラムを使用する場合は、 システムコールと呼ばれる処理を通じて、カーネルに処理を依頼する。カーネルはCPU, メモリなどリソース管理もしていて、各種プロセスに割当ても行っている。 プロセスからストレージデバイスにアクセスする場合、直接アクセスする事も可能であるが、通常はファイルシステムというインターフェースを通じて操作する。
各種プロセスは、プロセス生成・ハードウェア操作する際、 システムコールを通じてカーネルに処理を依頼する。システムコールの種類は次の通り
- プロセス生成、削除
- メモリ確保、解放
- プロセス間通信
- ネットワーク
- ファイルシステム操作
- ファイル操作
ユーザプロセスからシステムコールを呼び出すと、CPUでは割り込みイベントは発生しユーザモードからカーネルモードになり、システムコールに応じた処理を行う。実行後はユーザーモードへと戻るという仕組み。
strace
コマンドでプログラムを実行するとシステムコールされた内容がトレースできる。strace
の出力は1行に1つのシステムコールが対応している。
apt install sysstat
でsar
コマンドを有効にした。
sarコマンドでCPUが%userでユーザプロセス、%systemでカーネルプロセスでどの割合使用したかを読み取れる。%systemが数十など大きな値になっている時は負荷が高く良くない状態である事が多い。
strace
に-Tオプションをつけると、システムコールにかかった時間をマイクロ秒で記録できる。これは%systemの数値が高い時に調査する場合に役に立つ。
高級言語からシステムコールを呼び出すには、アセンブリコードから呼び出す必要があるが、そうしなくて良いようにカーネルはシステムコールのラッパー関数を用意している。各高級言語からこのラッパー関数を呼び出すことでシステムコールが実行できる。
C言語ではISOで規格された標準ライブラリがあり、通常はGNUプロジェクト提供のglibc
をC言語ライブラリとして使用する。
Linuxではプロセス生成に2つの目的がある。
- 同一プログラムの処理を複数プロセスに分けて処理する
- Webサーバの複数リクエストの処理など
- まったく別のプログラムを生成する
- bashから各プログラムの新規作成
この目的を達成するためfork()
、execve()
関数がある
-
fork関数
- 同じプログラムを複数プロセスに分けて処理する
- 子プロセス用のメモリを確保し、親プロセスのメモリをコピーする
-
execve関数
- 実行したプログラムとは別のプログラムのプロセスを生成する
- filenameで指定されたプログラムを実行する
-
_exit関数
- プロセスに割り当てたメモリを全て回収する
Linuxカーネルは複数プロセスを同時に動作させるため(正確には同時ではない)の プロセススケジューラという機能を持っている。 普段Linuxを使用する場合には特段意識する必要はない。スケジューラは次のようにプロセスの実行を管理している
- 1つのCPU上で同時に処理するプロセスは1つだけ
- 複数プロセスが実行可能な場合、1つ1つのプロセスを適当な時間ごとにCPU上で順番に処理するラウンドロビン方式で動作する
論理CPU上で動作するプロセスが切り替わる事を コンテキストスイッチと呼ぶ。コンテキストスイッチはプロセスがどのコードを実行中であってもタイムスライスが切れると切り替わりが発生する。 この理解がないと次のようなコードを見た時に勘違いが発生しがち。
void foo(void)
{
hoge();
bar();
}
// bar()の処理はhoge()の直後になるはず!
// コンテキストスイッチが発生したらhoge()の処理後に待つこともあり、このようになるとは限らない
プロセスには大まかに次のような状態がある。
- 実行状態
- 論理CPUを使用している
- 実行待ち状態
- CPU時間が割り当てられるのを待っている
- スリープ状態
- イベント発生待ち状態。イベント発生までCPU時間は使用しない
- 3分待つ
- キーボード、マウスの入力を待つ
- ストレージへの読み書き終了を待つ
- ネットワークによりデータ送受信を待つ
- イベント発生待ち状態。イベント発生までCPU時間は使用しない
- ゾンビ状態
- プロセスが終了した後に親プロセスが終了になるのを待っている
CPUの時間あたりのアイドル状態(何もしていない状態)が、sar -P ALL {second}
で確認できる。
右端の%idleがアイドル状態であった割合を示す。
次のコマンドでは1秒あたりのCPUアイドル状態がわかる
sar -P ALL 1
taskset -c {CPU-Number} {実行プログラム}
で指定したCPUでプログラムを実行する事ができる。
- スループット
- 時間単位の総処理量。処理量が高いほど性能が良い
- レイテンシ
- 処理の開始から終了までの経過時間。短いほど良い
スループット、レイテンシはCPUに限らず、ストレージデバイスなどの性能でも重要なものとなる。
grep -c processor /proc/cpuinfo
- CPU上で同時に処理できるのは、いつでもプロセス1つのみ
- 複数プロセスが実行可能な場合、1つ1つのプロセスを適当な長さの時間(タイムスライス)で区切り、ラウンドロビンで処理する
- マルチコアCPU環境では、複数プロセスを同時に動かさないとスループットが上がらない。
- コア数がn個あるから性能もn倍。というのは最良のケースである
- 単一論理CPUと同様、プロセス数を論理CPU数より多くしてもスループットは上がらない
プロセスがCPUの割当時間の優先度を変更するコマンド。 -19から20までの数値で指定(通常は0)。優先度を下げるのはどのユーザーでも出来るが、優先度を上げるのはroot権限を持つユーザーのみ。 優先度が高いプロセスはCPUの割当時間を多く配布される。
カーネルはメモリ管理システムを持っており、プロセス、カーネル自身のメモリ使用の管理を行っている。
メモリの搭載量、現在の使用量はfree
コマンドでわかる。
またsar -r {second}
で時間間隔のメモリ統計情報が得られる。
解放可能なメモリ領域がなくなると、メモリ管理機能は適当なプロセスを強制終了して、メモリ領域を開放する。これを OOM killerと呼ぶ。特定のプロセスをOOM killerから外すことは可能であり、sysctlのvm.panic_on_oom
をデフォルト0から1に変更し、OOM発生時はシステム強制終了という事も出来る。
カーネルがプロセスにメモリを割り当てるタイミングは次の通り
- プロセス生成時
- プロセス生成後、追加で動的メモリを割り当てる時
プロセス生成後、プロセスから動的にメモリ領域を追加する場合はシステムコールを行う。カーネルは必要なメモリサイズを切り出し先頭アドレスを返す。しかし、メモリの動的追加には次のような問題点 がある。
- メモリの断片化
- 別用途のメモリ領域にアクセスできてしまう
- マルチプロセスの扱いが困難になる
このような問題点を解決するため、 仮想記憶という仕組みが存在する。
仮想記憶の仕組みは、システムに搭載されているメモリにプロセスから直接アクセスさせるのではなく、仮想アドレスを用いて間接的にアクセスさせるもの。 プロセスから見えるメモリアドレスは「仮想アドレス」、システムの実際のメモリを「物理アドレス」と呼ぶ。アドレスによってアクセス可能な範囲を「アドレス空間」と呼ぶ。
物理アドレスを仮想アドレスにマップし、プロセスは仮想アドレスへアクセスする。
仮想アドレスから物理アドレスへの変換は「ページテーブル」という表を使用する。仮想記憶においては全てのメモリを ページという単位で区切って管理している。ページに対応するデータを「ページエントリ」と呼び、ページエントリには仮想アドレスと物理アドレスの対応情報が入っている。
ページエントリに存在しないアドレスにアクセスすると、 ページフォールトが発生し「SIGSEGV」シグナルを通知しプロセスは強制終了させられる。
- プロセス生成時にはメタ情報、コード情報、データ情報を物理メモリにコピーする
- プロセスのためのページテーブルを作り仮想アドレスと物理アドレスをマッピングする
- 所定のアドレスから実行を開始する
C言語のmalloc()はLinuxのmmap()をコールしている。 mmap()を通してメモリ領域を事前に確保している。pythonからメモリ確保する場合も内部ではmalloc()をコールし、mmap()が最終的にはコールされてメモリ確保を行っている。
- ファイルマップ
- read(), lseek(), write()などシステムコールが呼ばれる。開いたファイル情報はmmap()でメモリにマッピングされ、最終的にはストレージのファイルに書き込まれる。
- デマンドページング
- 仮想メモリと物理メモリをマッピングするが、物理メモリはまだ領域確保しない仕組みの事。プロセスが初めてアクセスをした時に物理メモリに領域を確保する。初めてのアクセス時はページフォールトを発生させている
プロセスの実行中にメモリ確保に失敗する事がある。これには仮想メモリの枯渇と物理メモリの枯渇がある。 仮想メモリの枯渇はプロセスが仮想アドレス空間の範囲を使い切った際に発生する。物理メモリの容量は関係なく、x86アーキテクチャでは4Gバイト。x86_64アーキテクチャでは128Tバイトとなっている。 これを超える事は稀である。 物理メモリの枯渇は領域が足りない場合に発生する。
-
コピーオンライト(CoW) fork()で親プロセスから子プロセスを生成する場合は、ページテーブルのみをコピーし生成する。この時、読み込み権限だけ付与される。ページテーブルを読むだけであれば親も子も共有された物理メモリを読むだけだが、どこかを更新する場合は共有が解除され、親プロセスのメモリを子プロセス用にコピーし、そこのデータに書き込みを行う。
-
スワップ システムの物理メモリが枯渇すると、メモリの一部データをストレージデバイスに保存する。この保存領域をスワップ領域と呼ぶ。スワップ対象となるメモリデータは「近いうちに使わないであろう」データをカーネルがアルゴリズムに基づいて決める。 スワップ領域の確認は
swapon --show
コマンドで確認ができる -
階層型ページテーブル ページテーブルはフラットな構造よりも階層型構造で構築した方が、メモリ領域が節約できる。メモリ不足に陥った際に物理メモリの増加ではなく、プロセスの作りすぎによる仮想メモリの肥大化が原因になっている場合がある。この場合は並列度を下げるなどし仮想メモリの大量消費を防ぐ必要がある。
-
ヒュージページ ヒュージページとは、通常より大きなページの事。これを使うことによりプロセスのページテーブルに必要なメモリ量を減らすことが出来る。 この仕組を使うことによりfork()の高速化が見込める。
コンピュータの動作の流れはざっくり言うと次のようなもの
- 命令をもとにメモリからレジスタにデータを読み出す
- レジスタ上のデータをもとに計算する
- 計算結果をメモリ上に書き出す
レジスタ上の計算処理は高速だが、メモリからデータを読み出す処理がボトルネックとなる場合がある。そこでCPU内蔵のキャッシュメモリ
が役に立つ。(CPUの外についているモノもある)
キャッシュメモリの役割はメモリから読み出したデータを、CPU内蔵のキャッシュメモリに書き出す。そのデータをレジスタ上で処理するため高速に処理される。この際、カーネルは関与しない。
レジスタでデータの書き換えが合った場合は、レジスタ->キャッシュメモリ->メモリへとデータが書き込まれる。 キャッシュメモリに使用するデータが存在する場合は、高速に処理が行えるという仕組みである。
x86_64アーキテクチャのCPUでは、キャッシュメモリは階層型構造となっている。L1, L2, L3(LはLevel)と階層が分けられていて、L1が最もレジスタに近く高速となっている。Level番号が大きくなるほどレジスタから遠くなり低速といなる。
CPUには仮想アドレスから物理アドレスへの変換情報を保持し、キャッシュメモリ同等の速度を持つ Translation Lookaside Bufferという領域がある
ストレージに存在するデータをメモリ上にキャッシュし、高速にアクセスするための仕組みが ページキャッシュ。ページキャッシュは物理メモリのページキャッシュ領域に保持され、全てのプロセスからアクセスが可能となっている。ページキャッシュが足りなくなり、ダーティなぺージを破棄し出すとストレージデバイスへのアクセスが発生しシステム性能が劣化しはじめる。
CPUの処理時間には計算処理のみとイメージしてしまうが、実際には「計算処理とキャッシュメモリからのデータ転送待ち時間」が含まれたものである。この転送時間を有効活用するため ハイパースレッドという機能がCPUに搭載されている場合がある。この機能の動作はプロセスの挙動によって大きく変動するものでシステム性能が上がる場合もあれば、性能が下がる場合もあるため、この機能を有効にするか無効にするかは、性能比較してから検討する必要がある
Linuxではストレージデバイス上のデータにアクセスするのに、直接ストレージデバイスにアクセスするわけではなくファイルシステム
を介して間接的にアクセスする。ファイルシステムではユーザーにとって意味のあるデータを「名前、位置、サイズ」などのメタ情報をファイルという単位で管理する。
ファイルシステムは1つだけではなく、「ext4」「XFS」「Btrfs」など複数のファイルシステムが存在する。
ファイルシステムを操作するためのシステムコールIFは次のようになっている
- ファイルの作成、削除
- create(), unlink()
- ファイルを開く、閉じる
- open(), close()
- ファイルからデータを読み出す
- read()
- ファイルに書き込む
- write()
- ファイル内の特定データ位置に移動
- lseek()
- ファイルシステム依存の特殊処理
- ioctl()
df
コマンドで得られるストレージ使用量は、ファイルの合計サイズだけではなくメタデータの合計サイズも含むため注意が必要。
小さなサイズのファイルを大量に作成すると、メタ情報も大量に生成される。
ファイル容量を無制限に使用できると、他の用途で使用できないなどシステム全体が正しく動作できないためクォータ機能によって制限をかける事が出来る。
- ユーザークォータ
- ユーザー毎に容量を制限する。/homeが圧迫されないような事態を防げる
- ディレクトリクォータ
- あるプロジェクトのディレクトリに容量制限をする
- サブボリュームクォータ
- ファイルシステム内のサブボリューム毎に容量を制限する
システム運用ではファイルシステム内容に不整合が生じる事がある。データをストレージデバイスに書き込んでいる際に、システム強制終了が発生した場合などである。 例えば次のようなコマンドを実行した際の動作を追ってみる
mv /bar /foo/
- /barを/foo/へのリンクを貼る
- /からbarのリンクを削除する
上記の1と2の動作はアトミックに行う必要があるが、途中で強制終了した場合は不整合が起きてしまう。この不整合を防止する仕組みとしてジャーナリング
、コピーオンライト
がある
ジャーナリングは、ファイルシステムから操作するアトミック処理を、ジャーナリング領域に一度書き出し、書き出した内容を元にファイルシステム操作を行う仕組みである。 ジャーナリング領域中にシステム強制終了が発生した場合は、ジャーナリング領域のデータを破棄する事で操作前の状態に戻すことができ。
ジャーナリング領域から実データを操作中に強制終了が発生した場合は、システム再起動時にジャーナリング領域のデータを元に処理を行う。これにより操作後の状態にする事が出来る。
ext4やXFSなど従来型のファイルシステムは、一度ファイルを作成したら原則的に配置位置は同じになる。ファイルの更新のたびにストレージデバイス上の同じ場所に新しいデータを書き込む。
それに対して、Btrfsなどのコピーオンライト
型のファイルシステムは、 **一度ファイルを作成しても更新する毎に別の場所にデータを書き込む。**別のデータに書き込み後、以前のデータを消すことでファイル更新時の不整合を防ぐ仕組みである。
しかし、これらの仕組みを持っても不整合が起きてしまう場合がある。
不整合が発生した場合は定期バックアップからリストアするか、fsck
コマンドで復元できることがある。しかし、fsck
コマンドで完全復旧できるわけではない。
Linuxでは通常ファイルとディレクトリのファイルが2種類ある。
これ以外にもデバイスファイルと言って、ハードウェア上のデバイスもファイルとして扱っている。デバイスアクセスにはioctl()
を使用する。
キャラクタデバイスは、読み出しと書き込みは出来るがシークが出来ないという特徴がある。 キャラクタデバイスには次のようなモノがある
- bashを使用した操作
- キーボード
- マウス
ブロックデバイスは、ファイルの読み書き以外にランダムアクセスができる。ブロックデバイスにはHDDやSSDなどの代表的なストレージデバイスがある。
tmpfsではストレージデバイスではなくメモリ上にデータを保存するため高速にデータ操作が出来る。しかし、電源を切るとデータは揮発してしまう。
これまでのファイルシステムはローカルマシン上のデータを扱うものだったが、ネットワークを介してリモートホスト上のファイルにアクセスするnfs
がある。Windowsではcifs
となる。
カーネル内の情報を取得するため、様々なファイルシステムが存在する。
- procfs
- システムのプロセス情報を得るためのファイルシステム。/procにマウントされる
- sysfs
- procfs内にプロセス以外のデータを配置される事が多かったため作られたファイルシステム。/sysにマウントされる
- cgroupfs
- 通常/sys/fs/cgroupにマウントされる。1つのプロセス、あるいは複数個のプロセスからなるグループに対してリソースに制限をかけるファイルシステム。CPUやメモリの割合などを制限する。dockerなどのコンテナ管理、仮想マシンを制限するために使用される。
- Btrfs
- ext4やXFSはUNIXの頃からあるファイルシステムである。Btrfsは従来型のファイルシステム+LVMのようなボリュームマネージャーである。
Btrfsではサブボリューム単位でスナップショットを採取できる。スナップショットではデータのフルコピーではなく、データを参照するメタデータの作成とスナップショット内のダーティページのWriteBackで済むため通常のコピーよりも高速である。
BtrfsではRAID構成を組める。
Btrfsではストレージ内のデータが破損した場合に検知し、RAID構成であれば修復可能である。検知機能を持たないファイルシステムでは、破損があってもそのまま運用継続をしてしまう。
データを磁気情報で表現し、磁気ディスクに記憶するストレージデバイスの事。プラッタの回転やアームが磁気ヘッドを移動するため機械的な動作によりレイテンシが遅くなる。
ブロックデバイス層のI/Oスケジューラは、ブロックデバイスへのアクセス要求を一定期間溜めてから次のように加工してI/O要求をすることにより、性能向上を目指すもの。
- マージ
- 複数の連続するセクタへのI/O要求を1つにまとめる
- ソート
- 複数の不連続なセクタへのI/O要求をセクタ番号順に並べ替える
I/Oスケジューラのお陰でプログラム実装者が意識せずとも、I/Oの性能が出るようになっている。
read-aheadという仕組みにより、Linuxではデータは連続して配置されていると判断しまとめてデータを読み込み速度を上げる方法がある。概要するデータが連続していなかった場合は、先読みしたデータは捨てる。
SSDがHDDに比べて速いと言われる所以は、データアクセスに機械的な動作がなく電気的な動作だけで済むからである。
- プログラムを作成する際は、データを連続させる。または近い場所に配置する事を意識する
- 連続する領域へのアクセスは複数回に分けずに、ひとまとめにする
- ファイルにはなるべく大きなサイズでシーケンシャルアクセスする