dshimizu/blog/alpha

とりとめのないITブログ

「[試して理解]Linuxのしくみ ~実験と図解で学ぶOSとハードウェアの基礎知識」を読んだ

最近Linuxを触る機会が減ってきているけど、Linuxについてきちんと勉強していないなぁと思い、改めてきちんと勉強しようと思い始めた。 Linuxカーネルに関する本はたくさんあるけどどれも分厚くて、いくつか読んではいるけどどれも断片的にしか読んでおらず、まずはこの本をちゃんと読みきろう、と思い読んだ。

章立ては下記の通り8章で構成されている。ただ、各章は独立しているので、順番に読む必要は無いので自分の興味ありそうなところから読んでも良い。

  • 第1章 コンピュータシステムの概要
  • 第2章 ユーザモードで実現する機能
  • 第3章 プロセス管理
  • 第4章 プロセススケジューラ
  • 第5章 メモリ管理
  • 第6章 記憶階層
  • 第7章 ファイルシステム
  • 第8章 ストレージデバイス

タイトルの通り、実験コードを使って実際のLinuxの動きを見る、という形になっており、また、図も用いた解説がされているので結構わかりやすいと思う。 入門書としてはおすすめな書籍だと思った。

読書ノート

* タイトル: [試して理解]Linuxのしくみ ~実験と図解で学ぶOSとハードウェアの基礎知識
* 著者名: 武内覚
* 出版社: 技術評論社
* 単行本: 288ページ
* 発売日: 2018/2/23

* 読了日: 2019/7/25
---

# 読書メモ

## 第1章 コンピュータシステムの概要

* 外部デバイスの操作はLinuxにおける重要な機能の1つ。デバイスドライバというプログラムでまとめられている。
    * 外部デバイスとは入出力装置やストレージ装置(HDD,SSDなど)やネットワークデバイスなどのこと。
* CPUはカーネルモード、ユーザモードがある。
    * デバイスドライバカーネルモードで動作しており、通常のプロセスは一般的にはユーザモードで動作している。
* デバイスにアクセスする場合や、メモリ管理、プロセス管理の処理を行う際にはカーネルモードに移行する必要がある。
* OSの核となる処理をまとめたプログラムをカーネルと呼んでいる。OSとはカーネルのことだけを指すかというとそうではない。


## 第2章 ユーザモードで実現する機能

* OSはカーネル以外にもユーザーモードで動作する様々なプログラムから構成される。それらプログラムはライブラリという形をとることもあるし、単独のプロセスとして動作することもある。
* プロセスはデバイスドライバを含めたカーネルが提供する機能(プロセス生成、ハードウェア操作等、カーネルの処理など)を使いたい場合、システムコールと呼ばれる特殊な処理を介してカーネルに処理を依頼する。
    * システムコールが発行された時、CPUで割り込みイベントが発行され、ユーザモード→カーネルモード→ユーザモードと遷移する。
    * ユーザプロセスからシステムコールを介さずに直接CPUのモードを変更する方法はない。それをさせないためにOSカーネルが存在している。
* LinuxにはGNUプロジェクトが提供している標準Cライブラリであるglibcシステムコールのラッパー関数を含む。さらにPOSIXの規格で定義されている関数も提供する。
    * ユーザプログラムからは各言語に対して用意されているシステムコールのラッパー関数を呼び出すだけで済む。
    * システムコールは通常の関数とは違って直接呼び出せず、アーキテクチャ依存のアセンブリコードを使う必要があるがそれは手間なので glibc が用いられる


## 第3章 プロセス管理

* プロセスの生成にはfork(), execve()という2つの「関数」が用いられる。内部的にはclone(),execve()という「システムコール」を呼び出している。
    * fork()関数は「同じプログラムの処理を複数のプロセスに分けて処理する」という場合で使用される
    * execve()関数は「まったく別のプログラムを生成する」という場合に使用される
* まったく別のプロセスを新規生成する場合は、親となるプロセスからfork()を発行して、生成された子プロセスがexec()を呼び出して置き換わる、といういわゆるfork & execの流れになる場合が多い。
* プログラムの終了には _exit()関数(内部的にはexit_group()システムコールを呼ぶ)を使用する。


## 第4章 プロセススケジューラ

* Linuxカーネルは、複数のプロセスを同時に動作させる(正確には、させているように見せかける)ための「プロセススケジューラ」という機能を持っている。
    * 1つのCPUで同時に処理するプロセスは1つだけである。
    * 複数のプロセスを実行可能な場合、個々のプロセスを適当な長さの時間ごとにCPU上で順番に処理する。
* 論理CPU上で動作するプロセスが切り替わることを「コンテキストスイッチ」と呼ぶ。
    * プロセス0とプロセス1が存在する場合に、タイムスライスのタイミングでコンテキストスイッチが発生する。
    * コンテキストスイッチはプロセスがいかなるコードを実行中で合っても、タイムスライスが切れると発生する。
* あるプログラムを実行した場合、そのプログラム内のプロセス間だけでCPU時間を分配している。
* プロセスは以下の状態を遷移している。
    * 実行状態(R): 論理CPUを使っている
    * 実行待ち状態(R): CPU時間が割り当てられるのを待っている
    * スリープ状態(S or D): 何らかのイベントが発生するのを待っている イベント発生までCPU時間は使わない
    * ゾンビ状態(Z): プロセスが終了した後に親プロセスの終了状態を受け取るのを待っている。
* 論理CPU p0 が動作しない期間、論理CPU p0 上ではアイドルプロセスという特殊なプロセスが動作している。
    * アイドルプロセスのCPUの特殊な命令を用いて論理CPUを休止状態にし、1つ以上のプロセスが実行可能状態にになるまで消費電力を抑えた状態で待機する。
* スループット
    * 単位時間当たりの仕事量。高いほど良い。
* レイテンシ
    * それぞれの処理の開始から終了までの経過時間。短いほど良い。
* 実際のシステムにおいては、様々なプロセスがそれぞれの処理に応じて、いろいろな状態に遷移する。論理CPU上でどのプロセスが動作しているのかも変化する。
    * 論理CPU上で動作するプロセスは1つだけ、スリープ状態においてはCPU時間を使わない。
* マルチコアCPU環境では、複数のプロセスを同時に動かさないとスループットは上がらない。また、「コアがN個あるから性能がN倍!」というのは最良のケース。


## 第5章 メモリ管理

* 空きメモリが少なくなってくると、メモリ管理システムはカーネル内の開放可能なメモリ領域を解放するが、それでもメモリ使用量が増えるとメモリが足りず「Out Of Memory(OOM)」という状態になる。
    * この場合、メモリ管理システムは適当なメモリを強制終了(kill)することによってメモリ領域を解放する「OOM Killer」という機能がある。
    * vm.panic_on_oomというカーネルパラメータを0->1にすることでOOM発生時にOOM Killerではなくシステムを強制終了するように変更できる。
* カーネルがプロセスにメモリを割り当てるタイミングは以下の通りである。
    * プロセス生成時
    * プロセス生成後、追加で動的メモリを割り当てる時
        * プロセス生成後、プロセスから動的にメモリ領域を追加する場合はシステムコールを行う。カーネルは必要なメモリサイズを切り出し先頭アドレスを返す。
* メモリの動的追加には次のような問題点がある。
    * メモリの断片化
        * メモリの獲得と開放を繰り返したときに、ばらばらの位置でメモリの空きができていることによって連続したメモリを確保できず、空き領域があるのにメモリを獲得できない状態。
        * ばらばらな空き領域を一組で扱えれば良いが実際には難しい。
    * 別用途のメモリ領域にアクセスできてしまう。
        * プロセスはカーネルやほかのプロセスが私用しているアドレスを指定しさえすればそれらの領域にアクセスできてしまいデータの漏洩や破壊のリスクがある。
    * マルチプロセスの扱いが困難になる。
        * 同じプログラムをもう一つ起動し、メモリにマップしようとすると、複数のプログラムが同じメモリ領域にアクセスすることになって片方が起動できない等の問題が生じる
* 現在のCPUには、このような問題点を解決するため、 仮想記憶という仕組みが存在する。
* 仮想記憶は、システムに搭載されている物理メモリにプロセスから直接アクセスさせるのではなく、仮想アドレスというアドレス空間を用いて間接的にアクセスさせる方法。 
    * プロセスから見えるメモリアドレスは「仮想アドレス」、システムの実際のメモリを「物理アドレス」と呼ぶ。
    * アドレスによってアクセス可能な範囲を「アドレス空間」と呼ぶ。
    * /proc/${pid}/mapsの出力に記載されているアドレスはすべて仮想アドレスとなる。
* 仮想アドレスから物理アドレスへの変換は、カーネルが使うメモリ内に保存されている「ページテーブル」という表を用いる。
* 仮想記憶ではすべてのメモリをページという単位で区切って管理している。変換はページ単位で実施される。
* ページテーブルの中の1つのページに対応するデータを「ページテーブルエントリ」と呼び、仮想アドレスと物理アドレスの対応情報が入っている。
* ページのサイズはCPUアーキテクチャ毎に決められている。x86_64は4k
* ある仮想アドレス空間にアクセスすると、CPUはカーネル処理を介さずに自動的にページテーブルの内容を参照して対応する物理メモリへのアクセスに変換する。
    * 仮想アドレス空間は固定で、かつページテーブルのエントリには、それぞれのページについて、それらに紐づいている物理メモリが存在するかを示すデータが存在する
    * ある仮想アドレス空間を超える仮想アドレスにアクセスすると「ページフォールト」という割り込みが発生し、実行中の命令が中断されて、カーネル内の「ページフォールトハンドラ」という処理が働く。
    * カーネルページフォールトハンドラにおいて、プロセスによるメモリアクセスが不正なものであることを検出し、「SIGSEGV」というシグナルを用いてプロセスに通知し、強制終了させる。
* プロセスへのメモリ割り当ては以下の順序で行われる。
    * プロセス生成時にはメタ情報、コード情報、データ情報を物理メモリにコピーし、その後プロセスのためのページテーブルを作り仮想アドレスと物理アドレスマッピングし、最後に所定のアドレスから実行を開始する。
    * プロセスが追加のメモリを要求した時は、カーネルに新規にメモリを割り当て、対応するページテーブルを作成した上で、割り当てたメモリ(の物理アドレス)に対応する仮想アドレスをプロセスに返す。
* C言語malloc() 関数は Linuxmmap() システムコールを呼び出している。
    * mmap() はページ単位でメモリを獲得するが、malloc() 関数はバイト単位でメモリを獲得する。
    * バイト単位でのメモリ獲得を実現するために、 glibc は事前に mmap() システムコールによってカーネルから大きなメモリ領域を獲得しておき、プログラムからmalloc()実行時に、その領域から必要な量を切り出してプログラムに返す処理をしている。獲得していたメモリの空きがなくなれば、再度 mmap() を発行して新たなメモリ領域を獲得する。
        * これはユーザモードで動作する OS の機能( glibcmalloc() )が通常のプログラムに提供する機能の一例
    * 自分が使用しているメモリ領域を報告するプログラムで、そのプログラムが出力する値と Linux から見たプロセスのメモリ使用量が異なる場合があるが、これは前者が mmap() で書くとしたバイト数の統計を指すのに対して、後者はプロセス生成時や malloc() によって獲得したメモリの統計を指すため。
* プログラムのソースコードから直接のメモリ管理を隠蔽している Python などのスクリプト言語においても、各オブジェクト生成において最終的には内部のC言語malloc() -> mmap() によってメモリを獲得している。
* 仮想メモリにより以下の問題を解決している
    * メモリの断片化
        * プロセスのページテーブルによって、物理メモリ上では断片化している領域をプロセスの仮想アドレス空間上では大きな1つの領域として見せることができるため、物理メモリの断片化の問題を隠蔽して解消できる。
    * 別用途のメモリ領域にアクセスできてしまう。
        * 仮想アドレス空間はプロセスごとに作られ、ページテーブルもプロセスごとに作られる。各プロセスは独立した仮想アドレス空間を保つため他のプロセスのメモリにアクセスできなくなる。
        * カーネルのメモリは、実装上の都合で全てのプロセスの仮想アドレス空間にマップされている。
            * カーネルのメモリに対応するページテーブルエントリは、CPUがカーネルモードで実行している時にのみアクセスできる「カーネルモード専用」という情報が付加されているため、ユーザモードで動かすプロセスから盗み見たり破壊することはできない。
    * マルチプロセスの扱いが困難になる。
        * 仮想アドレス空間はプロセスごとに存在するため、各プログラムは他のプログラムとのアドレスの干渉を気にせずそれぞれのアドレス空間で動作するようにプログラムを作れば良い。そのプログラムのメモリがどの物理アドレスに存在するかは気にしなくて良い。
* 仮想記憶を応用したしくみ
    * ファイルマップ
        * プロセスがファイルにアクセスする場合、ファイルオープン後にread(), write(), lseek()システムコールを使うがこれに加えてファイルの領域を仮想アドレス空間にマップする機能がある。
        * mmap()を使ってファイルの内容をメモリに読み出して仮想アドレス空間にマップし、メモリアクセスと同じ方法でアクセス可能隣、所定のタイミングでストレージデバイス上のファイルに描き戻される。
    * デマンドページング
        * メモリ割り当て以降、またはプロセスが終了するまで使われないメモリ領域が存在するため、無駄にメモリを使用することになるが、この問題を解決するために、プロセスの仮想アドレス空間内の各ページに対応する物理アドレスは、当該ページに最初にアクセスした時に割り当てる。
        * 各ページには、「プロセスには未割り当て」、「プロセスに割り当て済みで物理メモリも割り当て済み」、「プロセスに割り当て済みだが物理メモリは未割り当て」という状態がある。
            * プロセス生成直後はプロセスの仮想アドレス空間内のコード領域やデータ領域に対応するページに「プロセスがこの領域を獲得した」という情報を記録する。物理メモリは未割り当て。
            * プログラムがエントリポイントから実行を開始する際に、エントリポイントに対応するページ用の物理メモリを割り当てる。この時CPUがページテーブルを参照し、エントリポイントが属するページに対応する仮想アドレスが物理アドレスに紐づいていないことを検出し、CPUにおいてページフォールトが発生する。ページフォールトハンドラが、アクセスしたページに物理アドレスを割り当てた上でページテーブルを書き換え、ユーザーモードに戻ってプロセスが実行を継続する。この時プロセスはページフォールトが発生したことは検出されない。
            * プロセスがmmap()によって動的にメモリを獲得した場合、獲得したメモリにアクセスしたタイミングで物理メモリを割り当てる。
        * プロセスがmmap()などによってメモリを確保することを「仮想メモリを確保した」といい、その後確保した物理メモリにアクセスして物理メモリに紐づけられることを「物理メモリを確保した」という。
        * プロセスの実行中にメモリの獲得に失敗して異常終了する場合、仮想メモリの枯渇と物理メモリの枯渇という2種類がある。
            * 仮想メモリの枯渇は物理メモリがいくら空いていても発生する。x86では仮想アドレス空間が4Gしかなかったため頻繁に発生していたがx86_64ではアドレス空間が128Tとなったため発生は稀になった。
    * コピーオンライト
        * fork()関数で親プロセスから子プロセスを生成する場合は、ページテーブルのみをコピーして生成する。この時、親も子も全ページの書き込み権限を示すページテーブル内のフィールドを無効化する。ページテーブルを読むだけであれば親も子も共有された物理メモリを読むだけだが、どこかを更新する時ページフォールトが発生してCPUがカーネルモードになりページフォールトハンドラが動き、アクセスされたページを別の場所にコピーして書き込みしようとしたプロセスに割り当てた上で内容を書き換える。親プロセス、子プロセスは共有が解除されたページに対応するページテーブルエントリを更新する。
    * スワップ
        * システムの物理メモリが枯渇すると、ストレージデバイスの一部をメモリの代わりとして使用する。システムの物理メモリが枯渇した時に物理メモリを獲得した際、既存の使用中の物理メモリの一部をストレージデバイスに退避することで空きメモリを作り出す。この時退避する領域をスワップ領域という。カーネルのページテーブルとは別に用意されているスワップ領域用の場所に記録する。
        * 対象となるメモリデータは「近いうちに使わないであろう」データをカーネルアルゴリズムに基づいて決める。 
    * 階層型ページテーブル
        * x86_64アーキテクチャにおいて、仮想アドレス空間の大きさは128Tバイトであるが、1ページの大きさは4Kバイト、ページテーブルエントリ8Kバイトであり、ここからプロセス1つあたりのページテーブルに256Gバイト(8バイト * 128Tバイト * 4kバイト)という巨大なメモリが必要となる。
        * これを避けるためにx86_64アーキテクチャのページテーブルは階層構造になっている。例えば2階層構造の場合、ページテーブルから下位のページテーブルを参照して物理メモリアドレスを参照するような形になっている。
        * 全プロセスのページテーブルに必要な合計メモリ量は階層型ページテーブルのほうが少なくなる。
        * x86_64アーキテクチャでは4階層構造になっている。
        * ページテーブルに使用している物理メモリ量は「sar -r ALL 1」で参照できる。
        * メモリ不足の原因が、割り当てられている物理メモリ量の増加ではなく、「プロセスの作りすぎ」や「仮想メモリを大量に扱うプロセスによるページテーブル領域の増加」である場合もある。
            * 後者が原因の場合はヒュージページで対処可能かもしれない。
    * ヒュージページ
        * プロセスの仮想メモリ使用量が増えるとページテーブルに使用する物理メモリ量も増える。この時、fork()システムコールも遅くなるため、ヒュージページを扱う。
            * fork()はコピーオンライトによるメモリ割り当てのため親プロセスの物理メモリ使用量は増加しないが、ページテーブルは親プロセスと同じサイズのものを新規作成するため。
        * ヒュージページは通常より大きなページテーブルであり、これによりプロセスのページテーブルに必要なメモリ量を減らすことができる。
            * 1個の仮想メモリページを大きくし、実際にマッピングする物理メモリアドレスのサイズも大きくなる。
        * mmap()のflag引数に「MAP_HUGETBLE」フラグを与えるなどして獲得可能。実際には既存のプログラムのヒュージページを有効化する、という使い方が多い。
        * トランスペアレントヒュージページ
            * 仮想アドレス空間内の連続する複数の4Kバイトページが所定の条件を満たせば、それらをまとめて自動的にヒュージページにしてくれるというもの。
            * 便利そうだが、複数のページをまとめてヒュージページにする処理、条件を満たさなくなった時にヒュージページを4Kバイトのページに分割する処理によって性能劣化の可能性もある。
            * /sys/kernel/mm/transparent_hugepage/enabledで制御できる。


## 第6章 記憶階層

* コンピュータの記憶階層、上の階層ほどサイズは小さく、単位バイトあたりの安価な反面、アクセス速度が高速となる。
    * レジスタ
    * キャッシュメモリ
    * メモリ
    * ストレージ
* キャッシュメモリ
    * コンピュータの基本動作
        1. 命令を元に、メモリからレジスタにデータを読み出す
        2. レジスタ上のデータを元に計算する
        3. 計算結果をメモリに書き出す
    * 昨今のハードウェアはレジスタ上の計算にかかる平均所要時間に対して、メモリアクセスの所要時間、レイテンシは極めて遅いものとなる。
        * 「2. レジスタ上のデータを元に計算する」が幾ら速くても、「1. 命令を元に、メモリからレジスタにデータを読み出す」と「3. 計算結果をメモリに書き出す」がボトルネックとなるため、処理速度はメモリアクセスのレイテンシに律速する。
    * レジスタ上の計算とメモリアクセス、両者の所要時間の差を埋めるために存在するのがキャッシュメモリ
        * キャッシュメモリからレジスタにアクセスする際のレイテンシは、メモリからのそれに対して数倍〜数十倍なことを利用して「1. 命令を元に、メモリからレジスタにデータを読み出す」と「3. 計算結果をメモリに書き出す」を高速する。キャッシュメモリの処理はカーネルは関与せず、ハードウェア内で完結する。
    * 階層型キャッシュメモリ
        * 最近のx86_64アーキテクチャのCPUは、キャッシュメモリは階層型構造を取っている。それぞれサイズ、レイテンシ、どの論理CPUの間で共有するか、などが異なる。
        * 階層型構造を構造する各キャッシュメモリはL1, L2, L3などの名前がついている。
        * どのレベルのキャッシュが存在するかはCPUに依存する。最もレジスタに近く、容量が少なく、かつ高速なのがL1キャッシュ。
        * キャッシュメモリの情報は、「/sys/devices/system/cpu/cpu[0]/cache/index[0]」といったディレクトリにあるファイルの中身を見ればわかる。
            * type: キャッシュ間のデータの種類。「Data」からはデータのみ、「Code」ならばコードのみ、「Unified」ならばコードもデータもキャッシュする。
            * shared_cpu_list:キャッシュを共有する論理CPUのリスト
            * size:サイズ
            * coherency_line_size:キャッシュラインサイズ
    * 参照の局所性
        * 時間的局所性
            * ある時点でアクセスされたデータは、近い将来に再びアクセスされる可能性が高い。例えばループ処理中のコード領域。
        * 空間的局所性
            * ある時点であるデータにアクセスされると、それに近い場所のデータにアクセスする可能性が高い。例えば配列要素の全走査。
* Translation Lookaside Buffer
* ページキャッシュ


## 第7章 ファイルシステム

* ストレージデバイスの機能は単純にいうと「ストレージデバイス内の指定したアドレスに対して、所定のサイズのデータを読み書きする」だけ
  * ファイルシステムが無ければ、自力でそれをやる必要がある
  * データ保存後も、読み出すデータのアドレス・サイズ等を何かしら保持しておく必要がある
* ストレージのどこにどんなデータがあるか、どこが空き領域かを管理する仕組みがファイルシステムである


## 第8章 ストレージデバイス

(主にハードディスクの仕組み・実験、最後に SSD の仕組みについて)