Linuxカーネル全体図とハードウェアの境界

サブシステムの関係、デバイスとの接点、Goランタイムから見たカーネル


Posted on 2026年 8月 15日 (土)
Tags linux, kernel, os, hardware, driver, golang, cowork-with-llm
linux, kernel, os, hardware, driver, golang, cowork-with-llm

Linuxカーネル全体図とハードウェアの境界

前に Goランタイムの進化史 をまとめたとき、最後の章でランタイムがOSをどう使っているかに触れた。ただ、その「OS側」は箱のままだった。ランタイムが epoll を呼ぶところまでは書けても、その先でカーネルが何をして、どうやってNICから届いたパケットがそこへ現れるのかは書いていない。

この記事はその箱を開ける。カーネルの全体図を描いて、サブシステム同士がどう会話しているかを整理し、そこからさらに下、NICやGPU、ストレージ、そしてファンや電源装置といった物理的な部品との接点まで降りる。最後に視点を戻し、Goランタイムがそれらの機能をどう使い、何を使っていないのかを実測しながら確認する。

書きながら意識したのは、それぞれの仕組みが「何に困って」生まれたのかを先に置くことだ。カーネルの機能はどれも、当時あった具体的な不便への回答として入っている。答えだけを並べると用語集になるが、問いから辿ると設計の理由が見えてくる。

ブートローダからカーネルが起動するまでの話は Arch Linuxの起動プロセス で扱ったので、この記事は「起動した後のカーネルが、何をどう動かしているか」に絞る。

まとめ

先に要点だけ挙げる。

  • Linuxカーネルはモノリシックで、すべてのサブシステムが同じアドレス空間で動く。境界を守るのが物理的な仕組みではなく、決められた関数を通す規律のほうになる
  • 外向きの境界は システムコールだけが安定しており、内部構造は毎リリース変わってよいことになっている。この非対称性がLinuxの拡張の速さを支えている
  • カーネルとデバイスの会話は MMIO・DMA・割り込み の3つに集約される。NIC、GPU、NVMeのいずれも、この3つの組み合わせで説明できる
  • デバイスを見つける方法はバス次第で、PCIは自己申告できるが、I2CやSPIにぶら下がる部品は ACPIやデバイスツリーという配線図 がないと存在すら分からない
  • 電力と熱は独立した機能ではなく、スケジューラの負荷見積もりから周波数、発熱、冷却まで一本の連鎖としてつながっている
  • 進化の方向は「並列化」→「隔離と可観測性」→「既存の仕組みの再構築」と動いてきた。分岐点は 2.6(O(1)とsysfs)、3.18(eBPF)、4.5(cgroup v2)、5.1(io_uring)、6.6(EEVDF)、6.12(PREEMPT_RTとsched_ext)
  • Goランタイムがやっているのは、カーネルへ入らずに済ませることと、入るときは影響を1スレッドに閉じ込めることの2つに尽きる。実測すると time.Now() は10万回呼んでもシステムコール0回になる
  • 検証環境:Linux 6.18.5(x86_64、PREEMPT_DYNAMIC、4 vCPU)、Go 1.26.6。仮想マシンのため温度やファンに関する /sys は存在しない。その旨は都度明示する

1. カーネルとは何を指すか

1.1 そもそも何に困っていたか

計算機が高価だった時代、プログラムは1本ずつ順番に流すものだった。この方式には無駄がある。プログラムが磁気テープの読み込みを待っている間、CPUは何もしていない。そこで「待っている間に別のプログラムを走らせよう」という発想が出てくる。これが多重プログラミングで、ここから一気に問題が増えた。

  • 複数のプログラムが同じメモリを踏み合う
  • 1本が暴走すると他が二度と動けない
  • ディスクやプリンタを同時に触ると内容が混ざる

つまり、資源が1組しかないのに使いたい人が複数いるという状況をどう捌くか、が最初の課題になる。カーネルはこの調停役として生まれた。ハードウェアの特権レベルを使い、自分だけがデバイスを直接触れるようにし、他のプログラムには「あなた専用の計算機があります」という嘘をつく。CPUは4個しかないのにプロセスは数百あり、RAMは有限なのに各プロセスは自分専用の広大なアドレス空間を持っていると信じている。この嘘を成立させ続けるのがカーネルの仕事になる。

担当範囲は次のとおり。

サブシステム責務主に影響する症状
プロセスとスケジューラタスクの生成とCPUへの割り当てレイテンシのばらつき、CPUを使い切れない
メモリ管理仮想アドレス、ページ割り当て、回収OOM Kill、スワップ、ページフォルトの多発
VFSとファイルシステムファイル抽象と実装の切り替えI/O待ち、メタデータ操作の遅さ
ブロック層入出力要求のキューイングと発行ストレージのスループット、レイテンシの分布
ネットワークスタックソケット、TCP/IP、フィルタ接続数の限界、パケットドロップ
割り込みとタイマデバイス通知の受け取りと時間管理割り込み過多、タイマ精度
デバイスドライバ個々のハードウェアの制御認識しない、性能が出ない
電源と熱周波数、アイドル、冷却サーマルスロットリング、電池のもち
セキュリティと隔離権限、namespace、cgroupコンテナの分離、資源制限

1.2 なぜモノリシックのまま来たのか

Linuxが モノリシックカーネル だというのは、これら全部が同じアドレス空間で、同じ特権レベルで動くという意味だ。

1990年前後の学術的な流行は逆で、サブシステムを別々のプロセスへ分けてメッセージで会話するマイクロカーネルが「正しい設計」とされていた。分離すればドライバのバグがシステム全体を落とさずに済む。1992年には Andrew Tanenbaum が Linus Torvalds に対して「モノリシックカーネルは時代遅れだ」と公開の場で指摘したやり取りも残っている。

それでもLinuxがモノリシックを選び続けた理由は単純で、メッセージのやり取りが関数呼び出しより桁違いに高いからだ。ファイルを1回読むたびにプロセス間通信を数往復させると、当時のハードウェアでは実用にならなかった。そのかわりLinuxは、分離を物理的な仕組みではなく 規律 で保つ方針を取っている。サブシステム同士は決められた関数を通してしか会話しないし、その約束を守っているかはコードレビューが見る。

この選択の代償は今も残っていて、ドライバのバグはカーネル全体を落とす。6.1から入った Rust のサポートは、この代償を型で軽くしようという試みになる。

ただし「一枚岩だから作り変えられない」わけではない。2.0で入ったローダブルモジュールにより、ドライバは実行中に読み込める。実際、カーネルのソースの過半は drivers/ が占めており、その大半はビルド時にモジュールとして切り出される。

2. カーネルの全体図

まず全体を1枚で見る。サブシステムを選ぶと、その責務、他のサブシステムとの関係、担当するハードウェア、代表的なシステムコールが表示される。光った線は呼び出しの向きへ流れる。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

この図から読み取ってほしい点は3つある。

1. 外向きの境界はシステムコールだけ

ユーザ空間からカーネルへ入る道はシステムコールしかない。そしてLinuxには「ユーザ空間を壊すな」という強い方針があり、いったん公開したシステムコールの挙動は原則として変えない。

この方針が要るのは、ディストリビューションが何年も前にビルドされたバイナリを配り続けるからだ。カーネルだけを上げたら古いプログラムが動かなくなる、という状態になると、誰もカーネルを上げなくなる。上げてもらえないカーネルは、セキュリティ修正すら届かない。20年前のバイナリが今のカーネルで動くのは、この約束を優先し続けた結果になる。

逆に、カーネル内部のAPIには互換性の保証がまったくない。関数のシグネチャも構造体の中身もリリースごとに変わる。だから out-of-tree のドライバは、カーネルを上げるたびにビルドし直さなければならない。外は固く、内は自由という非対称性が、内部の作り直しを可能にしている。ブロック層の一本化もCFSからEEVDFへの置き換えも、外向きの互換性を守っているからこそ踏み切れた。

2. 依存は一方向ではない

図で関係を辿ると、多くの関係が双方向だと分かる。メモリ管理はスケジューラを使ってページフォルト中のタスクを止め、スケジューラはメモリ管理を使ってコンテキストスイッチ時にページテーブルを切り替える。ファイルシステムとメモリ管理はページキャッシュを共有していて、どちらが上とも言えない。

この相互依存があるので、ひとつのサブシステムだけを見て性能を語るのは難しい。「ディスクが遅い」に見える症状の原因が、実はメモリ不足によるページキャッシュの追い出しだった、という話は珍しくない。

3. 抽象の階段が2段ある

ハードウェアの違いを吸収する層(VFS、net_device、ブロック層)と、そのさらに上でユーザ空間へ見せる層(ファイルディスクリプタ、ソケット)が分かれている。おかげでNVMeとUSBメモリのどちらに対しても、アプリケーションは同じ read を呼べばよい。

1
2
3
4
# サブシステムの入口はだいたい /proc と /sys に出ている
$ ls /proc/self/       # このプロセスから見たカーネルの状態
$ ls /sys/class/       # デバイスを機能の種類ごとに並べたもの
$ ls /sys/bus/         # デバイスを接続方法ごとに並べたもの

3. 進化の全体像

年表で全体を眺める。バージョンを選ぶと、その世代で何が問題だったかが先に出て、そのうえで入った変更と、いま使う側への影響が表示される。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

大きな流れは5つの時期に分けられる。それぞれの時期を動かしていた問題は違う。

  1. 単一プロセッサ期(0.01〜1.2):課題は「動く範囲を広げること」。移植性の獲得とVFSの導入で、1機種向けの実験からOSの形になった
  2. 並列化期(2.0〜2.4):課題は「増えたハードウェアを使い切ること」。複数CPU、増設した周辺機器、大きくなったディスクに追いつく時期
  3. 基盤確立期(2.6):課題は「規模が増えても壊れない構造にすること」。スケジューラもデバイスの見せ方も、場当たりの実装から一貫した仕組みへ作り直された
  4. 隔離と可観測性期(3.x〜4.x):課題は「1台を安全に分け合うことと、中を覗くこと」。クラウドとコンテナが前提になった時期
  5. 再構築期(5.x〜6.x):課題は「昔の前提が合わなくなった部分を作り直すこと」。SSD、高速NIC、大容量メモリが、20年前の設計判断を無効にした

後半になるほど「新機能の追加」より「既にあるものの作り直し」が増えるのは、この5番目の性格による。ブロック層の一本化、CFSからEEVDFへの置き換え、LRUの世代管理の刷新は、どれも外から見た機能は変わらないのに内部は別物になっている。

以降、サブシステムごとに掘り下げる。

4. プロセスとスケジューラ

4.1 なぜプロセスとスレッドを区別しないのか

UNIX系のOSは長らくプロセスしか持っていなかった。プロセスは重い。生成にはアドレス空間の複製が要り、通信にはパイプや共有メモリという別の仕組みが要る。そこで「アドレス空間を共有した軽い実行単位が欲しい」という要求が出てきて、スレッドという概念が生まれた。

問題は、それをカーネルへどう入れるかだった。プロセスとスレッドを別種のものとして実装すると、生成、終了、シグナル、待ち合わせのすべてに2組の仕組みが要る。Linuxが選んだのは、両者を区別せず、共有する範囲だけを引数で指定させる方法だった。

カーネルから見れば、プロセスとスレッドはどちらも同じ task_struct だ。違うのは clone を呼ぶときにどのフラグを渡したかだけになる。

  • アドレス空間を共有すれば(CLONE_VM)スレッドに見える
  • 共有しなければプロセスに見える
  • ファイルディスクリプタ表、シグナルハンドラ、namespace も個別に共有するか選べる

つまりプロセスとスレッドは連続した設定の両端であって、別種の概念ではない。この判断の効果は後になって効いてきた。コンテナが「プロセスだが名前空間だけ別」という中間物として成立するのは、共有する範囲を細かく選べる設計があったからだ。もし2種類を作り分けていたら、3種類目を足す羽目になっていた。

1
2
3
# スレッドは /proc/<pid>/task の下に並ぶ
$ ls /proc/self/task
$ grep -E "Threads|Cpus_allowed_list" /proc/self/status

4.2 スケジューラが4回作り直された理由

スケジューラは「次にどのタスクを走らせるか」を決めるだけの部品に見える。それでもLinuxの歴史でもっとも作り直された部分になる。作り直しの引き金は毎回、前の方式が抱えた具体的な不満だった。

O(n)(〜2.4)の問題:タスクが増えると選ぶこと自体が重い

実行可能なタスク全体を走査して、いちばん優先度の高いものを選ぶ方式だった。数十プロセスなら問題ないが、サーバで数百から数千のタスクを抱えるようになると、選ぶための走査がCPU時間を食い始めた。

O(1)(2.6.0)の問題:ヒューリスティックが誰にも予測できなくなった

優先度ごとのキューとビットマップを使い、タスク数によらず一定時間で選べるようにした。ただし「対話的なタスクは優先したい」という要求に応えるため、スリープした時間の長さなどから対話性を推定するヒューリスティックが積み上がった。条件が増えるほど挙動は複雑になり、なぜこのタスクが遅いのかを誰も説明できない状態になっていった。

CFS(2.6.23)の答え:指標を1つに絞る

Ingo Molnár が持ち込んだ CFS は、推定をやめて 仮想時間 という単一の指標に整理した。「理想的には全タスクが同時に少しずつ進むべきで、そこからのずれが最も大きいタスクを走らせる」という一文で説明できる。赤黒木で管理するので選択コストも抑えられる。特別扱いのルールが消え、挙動が説明可能になった。

EEVDF(6.6)の答え:締め切りを表現できるようにする

CFSが16年もったのは、公平さという指標が強力だったからだ。ただし公平さしか表現できないという限界もあった。「この処理は10ミリ秒以内に走り始めてほしい」という要求を、CFSへ伝える語彙が無い。EEVDFは公平さに加えて「いつまでに走るべきか」という締め切りの概念を持ち込み、対話的なタスクの応答を改善しやすくした。

sched_ext(6.12)の答え:方針そのものを差し替え可能にする

ワークロードによって最適なスケジューリング方針は違う。ゲーム機、データセンター、組み込みでは求めるものが正反対になることもある。しかしそれら全部の特殊な方針を本流のスケジューラへ入れると、O(1)のときと同じ複雑さに戻る。sched_ext は eBPF で書いたスケジューラを差し替えられるようにして、本体を汚さずに実験できる道を作った。

バージョン方式解決した問題
〜2.4O(n)
2.6.0O(1)選択コストがタスク数に比例する問題
2.6.23CFSヒューリスティックの複雑化と挙動の不透明さ
6.6EEVDF遅延の要求を表現できない問題
6.12sched_ext特殊な方針を本流へ入れられない問題

実務上の意味は、6.6以降でレイテンシ関連のチューニングパラメータの効き方が変わった ことにある。CFS時代の sched_latency_ns を前提にした設定が、そのまま通用するとは限らない。

1
2
# いまのカーネルでどのスケジューラ機能が有効か
$ zcat /proc/config.gz | grep -E "CONFIG_PREEMPT|SCHED_CLASS_EXT"
1
2
3
4
5
CONFIG_PREEMPT_BUILD=y
CONFIG_PREEMPT_NONE=y
CONFIG_PREEMPTION=y
CONFIG_PREEMPT_DYNAMIC=y
CONFIG_PREEMPT_RCU=y

PREEMPT_DYNAMIC にも経緯がある。カーネル内で実行中のタスクを横取りできるかどうかは、応答性とスループットの綱引きになる。デスクトップは横取りしてほしいし、バッチ処理のサーバは邪魔されたくない。長らくビルド時に決め打ちするしかなく、ディストリビューションは用途別に複数のカーネルを配っていた。いまは起動時のパラメータで選べる。

4.3 リアルタイムポリシーは何のためにあるか

音声処理やモーター制御では、「平均的に速い」ことに意味がない。決まった周期で必ず走ることが要求される。通常のスケジューラは全体の公平さを見るので、この保証を与えられない。そこで別系統のポリシーが用意されている。

1
$ chrt -m
1
2
3
4
5
6
SCHED_OTHER min/max priority	: 0/0
SCHED_FIFO min/max priority	: 1/99
SCHED_RR min/max priority	: 1/99
SCHED_BATCH min/max priority	: 0/0
SCHED_IDLE min/max priority	: 0/0
SCHED_DEADLINE min/max priority	: 0/0

普通のプロセスは SCHED_OTHER でEEVDFの管理下にある。SCHED_FIFOSCHED_RR はリアルタイム用で、こちらが1本でも走っている限り通常タスクは動けない。SCHED_DEADLINE はさらに進んで「この周期でこれだけのCPU時間が要る」と宣言する方式で、カーネル側は受け入れ可能かどうかを判断できる。

なお、リアルタイム用途では長年「本流のカーネルでは足りない」状態が続いていた。割り込みハンドラやスピンロックが応答時間の上限を壊すためで、PREEMPT_RT という別管理のパッチを当てるのが常識だった。6.12でこれが本流へ完全にマージされ、20年近く続いた二重管理が終わっている。

5. メモリ管理

5.1 なぜ物理アドレスを直接使わないのか

初期の計算機ではプログラムが物理アドレスを直接使っていた。ここには3つの困りごとがある。

  1. 別のプログラムのメモリを簡単に壊せる
  2. どこに読み込まれるか分からないので、アドレスを決め打ちできない
  3. 空きが合計では足りていても、連続した領域が取れないと確保できない(断片化)

仮想メモリはこの3つをまとめて解決する。プロセスが見ているアドレスは全部が嘘で、MMU(メモリ管理ユニット)がページテーブルを引いて物理アドレスへ変換している。ページ単位で対応付けるので、物理的に飛び飛びの領域を連続して見せられる。他人のページを対応表に載せなければ、そもそも触れない。

カーネルの仕事は、この対応表を管理することと、対応が無いアドレスへ触られたとき(ページフォルト)の処理になる。

ここで重要なのは、ページフォルトを異常とみなさず、通常の動作の一部として捉える点だ。むしろ積極的に使っている。

  • mmap で確保したメモリは、最初に触った時点で初めて物理ページが割り当てられる
  • 実行ファイルを丸ごと読むのではなく、実行された部分のページだけがディスクから読まれる
  • fork した直後の親子はページを共有し、どちらかが書き込んだときに初めてコピーされる

3番目がとくに分かりやすい。fork の直後に execve するのが典型的な使い方なので、律儀に全部コピーすると、そのほとんどが捨てられる。書き込みが起きるまで先延ばしすれば、その無駄がまるごと消える。

つまり「メモリを確保した」と「メモリを使った」は別の出来事になる。仮想メモリ(VSZ)ではなく実メモリ(RSS)を見るべき理由もここにある。

5.2 ページキャッシュとメモリの見え方

ディスクは遅い。同じファイルを2回読むなら、1回目の内容をメモリに残しておけば2回目はメモリの速度で返せる。この考えが素直に実装されているのがページキャッシュで、メモリ管理とファイルシステムが共有している領域になる。

1
$ grep -E "MemTotal|MemFree|MemAvailable|Cached" /proc/meminfo
1
2
3
4
MemTotal:       16461004 kB
MemFree:        12280880 kB
MemAvailable:   14716896 kB
Cached:          2553988 kB

MemFree が少なくても慌てる必要はない。ページキャッシュは必要になれば捨てられるので、実際に使える量は MemAvailable のほうが正しい。「Linuxはメモリを食い潰す」という誤解の大半はここから来ている。空きメモリを遊ばせておく理由が無い、というだけの話になる。

カーネル自身のオブジェクト(task_struct や inode)は slab アロケータが管理する。汎用のアロケータで小さな構造体を大量に確保すると断片化と管理コストが問題になるため、種類ごとに専用の置き場を用意する方式を取っている。こちらはページキャッシュと違って簡単には解放できない。

1
$ head -3 /proc/slabinfo

5.3 ページ回収とヒュージページの作り直し

メモリが足りなくなったとき、どのページを捨てるかを決めるのが回収の仕事になる。理想は「今後いちばん使われないもの」だが、それは分からないので、最近使われていないものを捨てる(LRU)。

Linuxは長らく「活動中」と「非活動」の2本のリストでこれを近似していた。メモリが数百MBだった頃はこれで足りたが、数百GBの機械では判定を誤るようになった。走査すべきページが多すぎて、リストの端まで見に行けないためだ。結果として、必要なページを捨てたり、不要なページを抱えたりする。6.1で入った MGLRU は、ページを世代で管理し直してこの精度と速度を改善した。

ヒュージページにも似た経緯がある。ページが4KBだと、大きなメモリを使うプログラムではアドレス変換の対応表(TLB)に載りきらない。すると変換のたび、対応表そのものをメモリから読み直す羽目になる。そこで2MBの大きなページを扱えるようにしたのが透過的ヒュージページになる。ただし今度は「2MBのうち数十KBしか使わない領域」で無駄が出る。6.8で入った複数サイズTHP(mTHP)は、この全部か無かの二択を緩めるものになる。

バージョン変更解決した問題
2.6.x逆マッピング、ヒュージページの前身物理ページから参照元を辿れず、回収が重かった
4.xcgroup メモリコントローラの整備プロセス単位でしかメモリを制限できなかった
5.16〜5.18folio への移行4KBページ単位の管理が、大きな連続領域では非効率だった
6.1MGLRU大容量メモリでLRUの判定精度が落ちていた
6.8複数サイズTHP(mTHP)2MB固定のヒュージページが粗すぎた

MGLRUは外から見た機能が変わらない改善の典型で、有効化しても新しいシステムコールは増えない。一方でスワップやページキャッシュの挙動は変わるので、そこに依存した監視のしきい値は見直しが要る。

1
2
# 透過的ヒュージページの設定を見る
$ cat /sys/kernel/mm/transparent_hugepage/enabled
1
always [madvise] never

madvise は「アプリが明示的に頼んだときだけヒュージページを使う」設定だ。always はメモリ効率の低下を招く場合があるため、データベースなどでは意図的に切ることもある。

6. ファイルシステムとストレージ

6.1 VFSが無かった頃に何が起きていたか

Linux 0.01 が扱えるファイルシステムは MINIX のもの1種類だけで、readwrite の実装がその形式を直接知っていた。ここで2種類目を足そうとすると、システムコールの実装そのものへ分岐を書き足すことになる。3種類目、4種類目と増えるほど本体が汚れ、片方の修正がもう片方を壊す。

1.0で入った VFS は、この依存を裏返した。ファイルシステム側が「開く」「読む」「書く」といった関数の一覧を登録し、システムコールはその一覧を呼ぶだけにする。本体は個々の形式を知らなくてよくなり、新しいファイルシステムを足すときも本体へ手を入れずに済む。

この抽象が効いたのは、ディスク上の形式に限らなかった点だ。何が登録されているかは /proc/filesystems で分かる。

1
$ head -20 /proc/filesystems
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
nodev	sysfs
nodev	tmpfs
nodev	proc
nodev	cgroup2
nodev	devtmpfs
nodev	debugfs
nodev	tracefs
nodev	sockfs
nodev	bpf
nodev	pipefs
nodev	hugetlbfs
	ext3
	ext2

nodev はブロックデバイスを必要としないという意味で、procsysfs のように「カーネルの内部状態をファイルに見せかけている」ものがこれにあたる。sockfspipefs まで並んでいるのが面白いところで、ソケットとパイプも内部的にはファイルシステムとして実装されている。だからどちらもファイルディスクリプタとして扱え、epoll でまとめて待てる。「すべてはファイル」という標語は、VFSという具体的な仕組みに支えられている。

6.2 ブロック層はなぜ作り直されたか

ファイルシステムの下、実際のデバイスの手前にブロック層がある。ここで入出力要求が並べ替えられ、まとめられ、順序を整えてからドライバへ渡る。

この設計はHDDの都合から来ている。磁気ディスクではヘッドの移動時間が支配的なので、要求を回転の順に並べ替えると劇的に速くなる。だから「1本のキューに集めて、賢く並べ替える」のが正解だった。

SSDが普及して、この前提が崩れた。シークが無いので並べ替えの利得はほとんど無い。むしろ内部で何十チャネルも並列に動くので、要求を速くたくさん投げることのほうが重要になる。ところがキューが1本しかないため、そのロックが上限を作ってしまった。CPUを増やしても、キューへ入れる順番待ちで詰まる。

3.13で入った blk-mq はCPUごとのソフトウェアキューを持ち、それをデバイス側のハードウェアキューへ対応付ける。ロックの競合が消え、SSDの並列性がそのまま出るようになった。5.0で旧来の実装は削除され、いまはこちらに一本化されている。

1
2
3
$ cat /sys/block/vda/queue/scheduler
$ cat /sys/block/vda/queue/rotational
$ ls /sys/block/vda/mq
1
2
3
none [mq-deadline] kyber bfq
1
0

I/Oスケジューラの選択は、デバイスの性質で変わる。回転メディアではシークを減らす並べ替えが効く。NVMeでは、並べ替えの利得よりもキューへ入れる速さのほうが効いてくる。そのため none を選ぶ場合が多い。rotational はデバイスが回転メディアかどうかのヒントで、この値を見て既定が決まる。

6.3 io_uring が生まれた事情

「1回の入出力ごとに1回のシステムコール」という前提は、長らく問題視されていなかった。それが変わった理由は2つある。

1つは、SSDが速くなりすぎたこと。デバイスが数十マイクロ秒で応答するようになると、システムコールの往復が無視できない割合を占める。もう1つは、Meltdown 対策の KPTI(4.15)だ。システムコールのたびにページテーブルを切り替える必要が生じ、システムコールそのものが目に見えて高くなった

非同期I/Oの仕組みは以前からあった(aio)が、バッファなしI/Oでしか実質使えないなど制約が多く、代替になっていなかった。5.1で入った io_uring は、ユーザ空間とカーネルが共有するリングバッファへ要求を積み、完了を同じ仕組みから受け取る。要求ごとにシステムコールを呼ぶ必要そのものが消えた。

1
$ zcat /proc/config.gz | grep IO_URING
1
CONFIG_IO_URING=y

ただし、これは「使えば速くなる」という単純な話ではない。既存のコードは全面的に書き換えが要るし、攻撃面が広いことを理由に環境によっては無効化されている場合もある。後述するように、Goの標準ランタイムはこれを使っていない。

7. ネットワークとパケットの旅

7.1 受信の経路

ネットワークは、カーネルの中でもハードウェアからユーザ空間までの距離が最も長い。1本のパケットが届いてからアプリケーションが読むまでに何が起きるかを段階ごとに追う。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

要点は3つある。それぞれ、具体的な失敗から生まれた仕組みになる。

1. 割り込みハンドラを短くする理由

割り込みハンドラが動いている間、そのCPUでは他の処理が進まない。パケット処理の全部をハンドラの中でやると、処理中に届いた次のパケットを取りこぼす。だからハンドラは「後でやる」と予約するだけで抜け、実際の処理は softirq に回される。この前半と後半の分割はネットワークに限らず、Linuxの割り込み処理の基本形になっている。

1
$ head -6 /proc/softirqs
1
2
3
4
5
6
                    CPU0       CPU1       CPU2       CPU3
          HI:          0          0          0          0
       TIMER:       3233       3329       3263       2816
      NET_TX:          5          7          1          0
      NET_RX:      10900      11020       2648       1826
       BLOCK:         17         83         15       3299

NET_RX の回数がCPUごとに偏っているのが見て取れる。受信キューをどのCPUが処理するかは割り込みの割り当てで決まるので、偏りが問題になる環境ではここを調整する。

2. NAPIは「受信ライブロック」への回答

パケット1本ごとに割り込みを上げる方式には、皮肉な破綻の仕方がある。負荷が上がるほど割り込みが増え、割り込み処理でCPUが埋まり、受け取ったパケットを処理する時間が無くなる。結果として、負荷を上げるほどスループットが落ちる。1990年代にこの現象は受信ライブロックとして報告されている。

NAPIは、最初の割り込みで以降の割り込みを止め、ポーリングへ切り替えることでこれを避ける。負荷が下がればまた割り込み駆動へ戻る。まばらなときは割り込みで低遅延に、忙しいときはポーリングで効率よく、という切り替えになる。図の2枚目のタブでパケット毎秒を上げると、上のレーン(NAPIなし)の割り込みが壁のように連続するのに対し、下のレーン(NAPI)はまばらなまま、代わりにキューが伸びていく様子が見える。

3. XDPはコストを払う前に判断するための場所

DDoS対策では、届いたパケットの大半を捨てる。ところが従来の経路では、捨てると決まっているパケットにも sk_buff の確保とプロトコル処理の費用がかかっていた。判断の前に準備を済ませてしまっている。

4.8で入ったXDPは、ドライバがパケットを受け取った直後、sk_buff を作る前にeBPFプログラムを走らせる。破棄・通過・送り返し・別NICへの転送を判断できるので、捨てるだけなら桁違いに安い。ロードバランサも同じ理屈で速くなる。

1
$ cat /proc/net/softnet_stat

1行が1CPUに対応し、1列目が処理したパケット数、2列目がキューあふれで捨てた数になる。2列目が増え続けているなら受信処理が追いついていない。

7.2 ネットワークスタックの変遷

バージョン変更解決した問題
2.4Netfilter と iptablesパケットの選別と書き換えを行う統一の入口が無かった
2.6系NAPI の一般化、GRO/GSO割り込み過多と、1パケットあたりの固定コスト
3.13nftablesiptables はルールを順に評価するため、数が増えると比例して遅くなった
4.8XDP捨てるパケットにも準備の費用がかかっていた
4.9BBR輻輳制御ロスを信号にする方式では、バッファの深い経路で遅延が膨らんだ
5.6WireGuard、MPTCP既存のVPNは設定も実装も複雑だった

コンテナ環境では、この上にさらに大量のNAT変換とルーティングが積まれる。Kubernetes の Service が動く仕組みは、結局のところ netfilter か eBPF のどちらかで宛先を書き換えているだけだ。ルールが増えるほど1パケットあたりのコストが上がるので、大規模クラスタで iptables から eBPF ベースの実装へ移行する動機はここにある。3.13で nftables が入ったのも、線形評価という同じ問題への回答になる。

8. デバイスドライバとハードウェア

8.1 デバイスとの3つの会話手段

ここからカーネルの下側、物理的な部品との境界に降りる。デバイスを選ぶと、その物理的な接続からユーザ空間で見える形までの5段が表示される。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

種類がどれだけ違っても、カーネルとデバイスの会話手段は3つしかない。

  • MMIO:デバイスのレジスタをメモリアドレスとして読み書きする。カーネルからデバイスへの指示
  • DMA:デバイスがCPUを介さず直接メモリを読み書きする。大量のデータの運搬
  • 割り込み:デバイスからカーネルへの通知。「終わった」「届いた」を伝える

DMAが必要な理由は分かりやすい。1Gbpsのネットワークで届いたデータをCPUが1バイトずつメモリへ運んでいたら、それだけでCPUが埋まる。デバイスに直接書かせれば、CPUは「届いた」という通知だけ受け取ればよい。

NIC、GPU、NVMeのどれも、この3つの組み合わせで動いている。たとえばNVMeなら、ドアベルレジスタ(MMIO)に「キューへ積んだ」と書くと、コントローラがDMAでコマンドとデータを読み書きし、完了をMSI-X割り込みで返す。GPUなら、コマンドをリングバッファへ書き、GPUがDMAで読み出し、処理完了とVBLANKが割り込みで返る。

実際の割り込み割り当ては /proc/interrupts で見える。

1
$ head -12 /proc/interrupts
1
2
3
4
5
           CPU0       CPU1       CPU2       CPU3
 24:          1          0          0          0  IO-APIC   5-edge      ACPI:Ged
 26:          0          0        152          0  IO-APIC   4-edge      ttyS0
 29:          0          0          0          0  PCI-MSIX-0000:00:01.0   0-edge      virtio0-config
 35:         14          0          0          0  PCI-MSIX-0000:00:0a.0   1-edge      virtio9-input

PCI-MSIX-0000:00:01.0 の形式が、PCIのバス・デバイス・ファンクション番号にあたる。かつては割り込み線が物理的に数本しかなく、複数のデバイスで共有していたため、割り込みが来るたびに「どのデバイスか」を全部に問い合わせる必要があった。MSI-Xは割り込みをメッセージとして送る方式で、1つのデバイスが複数の番号を持てる。virtio0-configvirtio9-input のように用途ごとに分かれているのはこのためで、受信キューごとに別のCPUで処理できるのもこの仕組みによる。

8.2 基板の上の部品と、担当するモジュール

抽象の話が続いたので、実際の基板へ落としてみる。部品を選ぶと、どのバスでつながり、どうやって見つけられ、どのモジュールが担当し、ユーザ空間からどう見えるかが表示される。「起動して認識させる」を押すと、電源投入からルートファイルシステムのマウントまで、カーネルがどの順で部品を見つけていくかを追える。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

この図で見てほしいのは、部品によって「見つかりやすさ」がまったく違う点だ。

  • CPU:唯一、探さなくても自分から名乗る。CPUID命令を呼べばベンダと世代が分かる
  • PCIe上の部品(GPU、NVMe、NIC、USBコントローラ):コンフィグ空間を走査すればベンダIDとデバイスIDが読める。ドライバは対応IDの一覧を持っていて、一致したものがバインドされる
  • 低速バス上の部品(温度センサ、ファン、RTC、TPM):自己申告しない。ACPIのテーブルに「このアドレスにこれがある」と書いてもらわないと、存在すら分からない

3番目が、ハードウェア対応の面倒くささの正体になる。同じ温度センサチップを積んでいても、マザーボードが違えばACPIの記述が違い、認識されないことがある。x86はACPI、組み込みARMはデバイスツリーを使うという違いはあるが、どちらも「自己申告しない部品のための配線図」という同じ役割を担っている。

8.3 統一デバイスモデルと udev が要った理由

2.6より前は、デバイスの管理方法がバスごとにばらばらだった。PCIにはPCIのやり方、USBにはUSBのやり方があり、共通の台帳が無い。ここで困るのは次のような場面になる。

  • サスペンドするとき、どのデバイスをどの順で眠らせるべきか分からない
  • USBハブを抜いたとき、その先にぶら下がっていた機器をどう辿るか決まっていない
  • どのデバイスがどのドライバに担当されているかを一覧する手段が無い

2.6で入った統一デバイスモデルは、「バス・デバイス・ドライバ」の三者関係を1本のツリーで表し、その結果を sysfs として公開した。親子関係がはっきりするので、電源管理とホットプラグの順序を決められる。

もう1つの問題が /dev にあった。従来はデバイスファイルを静的に置いていたため、ありうる機器の分だけノードを作っておく必要があり、数千個が並ぶ状態になっていた。それでも新しい機器には対応できないし、メジャー番号とマイナー番号も枯渇に向かっていた。

udev はこれを裏返した。カーネルは sysfs へ変化(uevent)を通知するだけで、/dev のノードを作るのはユーザ空間のデーモンになる。実際にある機器の分だけが作られ、命名規則や権限の方針はカーネルを触らずに変えられる。ネットワークインタフェースが eth0 から enp3s0 のような名前になったのも udev 側の規則変更で、これは「起動のたびに認識順が変わって番号が入れ替わる」問題への対策だった。

1
2
3
$ ls /sys/class          # 機能の種類ごと(net, block, tty, drm, hwmon ...)
$ ls /sys/class/net      # ネットワークインタフェース
$ ls /sys/block          # ブロックデバイス

8.4 GPUだけは事情が違う

GPUは、カーネルの役割が他のデバイスと少し違う。描画や計算そのものはGPU上のプログラムが実行するので、カーネルの仕事は「メモリの管理」と「コマンド投入の交通整理」に寄っている。

2.6.29で入ったKMS(Kernel Mode Setting)以前は、画面モードの設定がXサーバ側にあった。ユーザ空間のプログラムが画面の解像度を決める構造には無理がある。

  • 起動中にカーネルとXサーバの両方がモードを設定するので、画面が何度もちらつく
  • 仮想端末とXの間を行き来するたびにモードを再設定するため、崩れることがある
  • カーネルパニックが起きたとき、エラーを表示する手段が無い。モードを知っているのが落ちていないXサーバ側だったため

設定をカーネルへ移したことで、この3つがまとめて解決した。以後 /dev/dri/card0 がグラフィックスの入口になる。

1
2
$ ls /dev/dri
$ ls /sys/class/drm

card0 は表示権限を含むノード、renderD128 は計算専用のノードになる。コンテナへGPU計算だけ渡したい場合は後者を渡す。

9. 電源と熱

9.1 APMからACPIへ、そしてschedutilへ

電源管理の主導権をどこが持つか、という問いには歴史がある。

初期のPCでは APM という仕組みがあり、電源の制御はBIOSが握っていた。OSは「もうすぐアイドルです」と伝えるだけで、実際に何が起きるかはBIOSの実装次第になる。中身が見えないので、OS側は最適化のしようがない。

ACPIはこれを裏返し、ファームウェアは情報とテーブルを提供し、判断はOSがするという分担に変えた。どのデバイスがどう眠れるか、温度センサがどこにあるかをテーブルで受け取り、いつ何をするかはカーネルが決める。ハードウェアの発見にACPIが要るという話(8.2)と、電源管理をACPIが担うという話は、同じ「ファームウェアからの情報提供」という仕組みの裏表になる。

周波数の決め方にも似た経緯がある。かつての cpufreq ガバナは、スケジューラとは独立に一定周期で負荷を測り、そこから周波数を決めていた。ところがスケジューラはすでに各CPUの負荷を計算している。二重に推定していたうえ、周期的な観測なので反応も遅れた。schedutil はスケジューラが持つ見積もりをそのまま目標に使うことで、この無駄と遅れを消している。

負荷を変えるとどこがどう動くかを図で確認できる。図のスライダで負荷を変えると、周波数はすぐ上がるのに温度はゆっくり追いつき、しばらくしてからファンが回り、さらに遅れて周波数が抑えられる、という時間差を持った動きが見られる。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

連鎖は次の順で伝わる。

  1. スケジューラが各CPUの負荷を見積もる
  2. cpufreq のガバナ(現在の既定は多くの環境で schedutil)が、その見積もりをそのまま周波数の目標に使う
  3. ドライバがMSR(モデル固有レジスタ)へ書き込み、P-stateが変わる
  4. 周波数と電圧が上がると発熱が増える
  5. thermal サブシステムが温度をトリップポイントと比較する
  6. 超えていれば冷却デバイス(ファン、あるいは周波数の制限そのもの)を段階的に働かせる

面白いのは6の「冷却デバイス」に 周波数の制限そのものが含まれる 点だ。ファンが足りなければ性能を落として発熱を減らす、という選択をカーネルが取る。これがサーマルスロットリングで、冷却が足りない機械でベンチマークが再現しない原因になりやすい。

9.2 状態の種類が4系統ある

ACPIは電源の状態を複数の系統で定義している。名前が似ているが対象が違う。眠らせる粒度を分けているのは、「使っていない部分だけを止めたい」という要求が階層ごとに違うためだ。

名前対象内容
S状態システム全体S0(動作中)、S3(メモリへサスペンド)、S4(ディスクへハイバネート)、S5(電源断)
C状態CPUコアのアイドル数字が大きいほど深く眠り、復帰も遅くなる
P状態CPUの動作周波数と電圧C0(実行中)の中での違い
D状態デバイスごとの電源使っていないデバイスを個別に眠らせる

S3とS4の違いは、メモリの内容をどこに置くかにある。S3はRAMへ電力を供給し続け、S4はスワップ領域へ書き出して電源を切る。復帰の速さと電力消費が逆になる。

9.3 ファンと電源装置はどう見えるか

温度センサ、ファン、電圧、電流といった情報は hwmon サブシステムに集約される。バラバラのチップから読んだ値を、共通のファイル名で見せるための層になる。sensors コマンドがやっているのは、この下のファイルを読んで整形しているだけだ。

1
2
3
$ ls /sys/class/hwmon
$ cat /sys/class/hwmon/hwmon0/temp1_input   # ミリ度単位
$ ls /sys/class/thermal                     # thermal_zone と cooling_device

電源側は power_supply サブシステムに出る。バッテリの残量、ACアダプタの接続、電圧などがここに並ぶ。

1
2
$ ls /sys/class/power_supply
$ cat /sys/class/power_supply/BAT0/capacity

CPUパッケージの消費電力は、Intelなら RAPL(Running Average Power Limit)が提供する。読み取りだけでなく、電力の上限を与える用途にも使える。データセンターで電力予算を守るための機能になる。

ファンの回転数をカーネルが直接決めているわけではない点にも注意したい。thermal サブシステムが決めるのは「冷却デバイスをどの段階で使うか」までで、そこからPWMのデューティ比への変換はドライバとハードウェアの仕事になる。さらにマザーボードのファームウェア側が制御を握っている構成では、OSからは読み取りしかできない場合もある。

10. 時刻はどこから来るか

10.1 ハードウェアのカウンタからユーザ空間まで

clock_gettime が返す値は、ハードウェアのカウンタを何段も加工した結果になっている。クロックIDを選ぶと、その経路と、NTPやサスペンドでの振る舞いが表示される。図の下では3つのクロックが実際に動いていて、「NTPが時刻を戻す」「サスペンドする」を押すとどれが飛んでどれが飛ばないかをその場で確かめられる。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

時間源が複数あるのにも理由がある。単独で条件を満たすものが無いためだ。

  • TSC:CPUコア内蔵のカウンタ。専用命令で読むだけなので最も速い。ただし過去には省電力で周波数が変わると進み方が変わり、コア間でずれる問題があった。「不変TSC」が普及して初めて信頼できるようになった
  • HPET:チップセット上の高精度タイマ。安定しているがMMIO経由なのでTSCより遅い
  • RTC:電池で動き続ける時計。電源を切っても時刻を保持するが、精度は低い
  • PTPハードウェアクロック:NICに載った高精度な時計。ネットワーク越しの時刻同期に使う

起動時にこれらを比較し、精度と安定性の評価が最も高いものを選ぶ。

1
2
$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
1
2
tsc kvm-clock
tsc

検証環境では tsc が選ばれていた。候補に kvm-clock が並んでいるのは仮想マシンだからで、ホスト側の時間をゲストへ伝える仕組みになる。

10.2 vDSOという抜け道

時刻の取得は、システムコールの中でも群を抜いて呼ばれる回数が多い。ログを1行出すたび、タイムアウトを測るたびに呼ばれる。ここでカーネルモードへの切り替えが毎回発生するのは、割に合わない。

そこでカーネルは vDSO(virtual Dynamic Shared Object)という小さな共有ライブラリを各プロセスのアドレス空間へマップし、そこに時刻を読む関数を置いている。

1
$ grep -E "vdso|vvar" /proc/self/maps
1
2
3
7f4361072000-7f4361076000 r--p 00000000 00:00 0    [vvar]
7f4361076000-7f4361078000 r--p 00000000 00:00 0    [vvar_vclock]
7f4361078000-7f436107a000 r-xp 00000000 00:00 0    [vdso]

vvar がカーネルの書き込んだ時刻データを読み取り専用で見せている領域で、vdso がそれを読むコードになる。カーネルモードへの切り替えが起きないので、コストは通常の関数呼び出しに近い。

効果は実測できる。time.Now() を10万回呼ぶGoのプログラムを strace で観察しても、clock_gettime システムコールは1回も現れない。

1
$ strace -f -c ./probe clock 2>&1 | grep -c clock_gettime
1
0

10.3 単調増加と壁時計が分かれている理由

「経過時間を測る」のに壁時計を使うと事故が起きる。NTPが時刻を補正した瞬間、時計は戻る場合がある。開始と終了の差が負になり、タイムアウトの判定は壊れる。うるう秒の挿入で同じ問題が起きた例も知られている。

そこで用途ごとにクロックが分かれている。

  • CLOCK_REALTIME:人間が読む時刻。NTPで飛ぶことがある。ログや証明書の期限に使う
  • CLOCK_MONOTONIC:起動からの経過時間。飛ばない。タイムアウトや経過時間の測定に使う
  • CLOCK_BOOTTIMECLOCK_MONOTONIC にサスペンド中の時間も加えたもの

Goの time.Now() は壁時計と単調時計の両方を持っていて、time.Since のような差分計算では自動的に単調時計側を使うようになっている。言語側でこの区別を吸収してしまう設計は、間違いが起きやすい部分を塞ぐという意味で参考になる。

なお、カーネルは定期的なタイマ割り込み(tick)で時間を刻んでいるが、アイドル時にはこれを止める(tickless)。定期的に割り込むのは、定期的にCPUを起こすのと同じだ。これでは深いC状態へ落ちられない。何もしていないのに電池が減る原因はここにあった。

11. セキュリティと隔離

コンテナは単一の機能ではなく、いくつかのカーネル機能の組み合わせで成り立っている。それぞれ別の不満から生まれたものが、後から組み合わさった。

隔離の出発点は chroot で、見えるファイルシステムの範囲を変える仕組みだった。ただしこれには2つの限界がある。特権を持ったままなら抜け出す方法が知られていたことと、隔離できるのがファイルシステムだけだったことだ。プロセス一覧やネットワークが共有されたままでは、隔離とは呼べない。

namespace は、この「見える範囲」を資源ごとへ分けられるようにしたものだ。PID、ネットワーク、マウント、ホスト名、UIDと段階的に増えた。3.8で user namespace が実用水準へ達し、非rootでもコンテナを作れるようになる。ここが分岐点で、コンテナランタイムが一般ユーザの道具になった。

一方 cgroup は別の要求から来ている。「見える範囲」ではなく「使える量」の制限にあたり、1台の機械を複数の用途へ分け合う場面で必要になる。プロセス単位の ulimit では、プロセスを増やされると意味が無い。

機能解決した問題導入
namespacechroot では抜け出せるうえ、隔離できるのがファイルシステムだけだった2.4〜3.8にかけて段階的に
cgroupプロセス単位の制限では、プロセスを増やされると効かなかった2.6.24、v2は4.5
ケーパビリティrootかどうかの二択しかなく、権限を絞れなかった2.2
seccomp侵入されたときに、呼べるシステムコールを制限する手段が無かった2.6.12、フィルタ方式は3.5
LSM権限の判断方法を変えるには本体の改造が要った2.6
overlayfsイメージの差分を表現する標準の方法が無かった3.18

cgroup v2 が要った理由も具体的だ。v1ではコントローラごとに別のツリーを作れたため、「CPUではこのグループ、メモリでは別のグループ」という状態が起こりえた。すると「このグループがCPUとメモリをどれだけ使ったか」をまとめて見ることができない。v2は単一階層へ整理してこれを解いた。

1
2
$ cat /proc/self/cgroup
$ ls -l /proc/self/ns

検証環境の /proc/self/cgroup を見ると、v1のコントローラごとの行と、v2を示す 0::/ の行が混在していた。この状態を混成(ハイブリッド)構成と呼ぶ。

12. Goランタイムから見たカーネル

ここまで下から見てきたものを、今度は上から見直す。Goランタイムはカーネルの機能をどう使い、何を使っていないのか。カーネル側の機能を選ぶと、対応するランタイムの部分と実測値が表示される。図の粒はGoの操作1回にあたり、境界を越えた粒だけがカーネルへ届く。vDSOread(2) を切り替えると、越える粒がゼロか全部かという両極端を並べて見られる。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

12.1 実測してみる

まず数字から見る。次のプログラムを Go 1.26.6 でビルドし、strace -f -c でシステムコールを数えた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// time.Now() を10万回呼ぶ
case "clock":
    var s int64
    for i := 0; i < 100000; i++ {
        s += time.Now().UnixNano()
    }

// バッファなしチャネルで10万回の送受信
case "chan":
    ch := make(chan int)
    go func() {
        for i := 0; i < 100000; i++ {
            <-ch
        }
    }()
    for i := 0; i < 100000; i++ {
        ch <- i
    }

// goroutine を1万本立てて全部回収する
case "spawn":
    done := make(chan struct{})
    for i := 0; i < 10000; i++ {
        go func() { done <- struct{}{} }()
    }
    for i := 0; i < 10000; i++ {
        <-done
    }

結果は次のようになった。

操作回数対応するシステムコール実測
time.Now()100000clock_gettime0回(vDSOで完結)
チャネルの送受信100000futex240〜285回(4回試行の範囲)
goroutine の生成10000clone35〜6回
TCPで1往復1epoll_create11回

goroutine を1万本立ててもOSスレッドは5本前後しか作られない。10万回の送受信でカーネルへ入るのも250回程度にとどまる。どちらも数字で確認できる。ユーザ空間でどれだけ仕事を済ませているかがそのまま表れている。

1
$ strace -f -c -U name,calls ./probe chan 2>&1 | grep -E "futex|clone"

12.2 うまく使っている点

1. 待つ場所をカーネルからランタイムへ移した

チャネル待ちやミューテックス待ちは、ランタイム内の待ちキューへGを入れるだけで済む。OSスレッドは止まらず、そのまま別のGを実行する。カーネルから見ると、そのスレッドはずっと走り続けているだけで「待っている」という状態が存在しない。

futex がそもそもこの発想の産物になる。それ以前のロックは、競合していなくてもカーネルへ入って確認する必要があった。ほとんどの場合は競合しないのだから、その確認は無駄になる。futex は競合しない限りユーザ空間のアトミック命令だけで完結し、待つときだけカーネルへ落ちる。Goはその上にもう一段、goroutine 単位の待ちを重ねている。

2. 待ち合わせ点を1か所に集約している

netpoller の epoll_wait は、ネットワークI/Oの待ちとタイマの待ちを同時に扱う。次に発火するタイマまでの時間を epoll_wait のタイムアウトへ渡すことで、タイマ専用のスレッドや timerfd を増やさずに済ませている。

3. カーネルからは大きく借り、細かく配る

mmap で確保するのは大きな単位のアリーナで、実際の割り当てはPごとの mcache から切り出す。割り当て回数とシステムコール回数が切り離されているので、秒間数百万回の割り当てでもカーネルには入らない。

4. カーネルが用意した抜け道を使う

time.Now() の vDSO はその代表だ。カーネル側が「システムコールを避けるための仕組み」を用意しており、ランタイムはそれを素直に使っている。

5. 避けられないブロックは1スレッドに閉じ込める

通常のファイルI/Oは epoll で待てないため、実際に read(2) でスレッドごとブロックする。そこでランタイムは、システムコールへ入る前にPをMから切り離し、sysmon が切り離されたPを別のMへ渡す。止まるのはスレッド1本だけになる。

12.3 使っていない機能もある

対比として面白いのは、Goが io_uring を使っていない ことだ。理由はいくつか考えられる。

  • 移植性。io_uring はLinux固有で、Goは多くのOSで同じランタイムを動かす必要がある
  • セキュリティ方針。seccomp で禁止されている環境や、脆弱性を理由に無効化されている環境がある
  • 既存の netpoller で多くの用途が足りている

ファイルI/Oが支配的なワークロードでは外部ライブラリという選択肢もあるが、ランタイムのスケジューラとの統合は自前で面倒を見ることになる。

同じく、GoランタイムはNUMAを考慮しない。ノードをまたぐメモリアクセスの局所性は制御できないため、NUMA構成のマシンで最後の性能を絞りたい場合は、numactl などOS側の道具で外から縛ることになる。

12.4 境界をまたぐときに効いてくること

  • GOMAXPROCS はCPU時間の上限ではない。Pの数はGoコードを同時に実行できる本数であって、cgroup のCPUクォータとは別の概念になる。Go 1.25以降は cgroup を読んで自動設定するが、明示設定すると追随は止まる
  • スレッド数の上限はOS側にもある。Goの既定上限は10000スレッドだが、その手前で cgroup の pids.maxulimit -u に当たる場合がある
  • シグナルはカーネルと共有している。ランタイムは SIGURG(非同期プリエンプション)や SIGPROF(プロファイル)を内部的に使うので、cgo で読み込んだライブラリがこれらを上書きすると挙動が変わる
  • メモリの返却は即座ではないmadvise で物理ページを返しても、仮想アドレスの予約は残る。RSSでリークを判定するときはこの遅れを考慮する

13. 観測方法

カーネルの状態を見る手段は世代ごとに増えてきた。ここにも流れがある。

最初は /proc/sys で、カーネルが出したい情報を出す方式だった。次に ftraceperf のような、あらかじめ仕込まれた計測点を使う方式が入る。どちらも「その情報を出すコードがカーネルに入っているか」に依存していて、想定外のことを調べたいときは手が出ない。

eBPFはこの制約を外した。検証済みのプログラムを任意の地点へ差し込めるので、カーネルを書き換えず、止めもせずに、必要な計測だけを追加できる。観測のやり方が根本から変わったのはこの点になる。

手段用途備考
/proc/sys状態の読み取り全般追加のツールが要らない。まずここを見る
dmesgカーネルのログデバイスの認識やエラーの一次情報
straceプロセスのシステムコール遅いが原因の切り分けに強い。-c で集計できる
perfCPUプロファイル、各種イベントハードウェアの性能カウンタも読める
ftrace/sys/kernel/tracingカーネル内部の関数トレース追加インストールが不要
eBPF(bpftrace、bcc)任意の地点での計測現在の主力。カーネルを止めずに動的に仕掛けられる
1
2
3
4
5
6
7
8
9
# システムコールの回数を集計する(速度は落ちるので本番では注意)
$ strace -f -c -U name,calls ./app

# 割り込みとsoftirqの偏りを見る
$ head /proc/interrupts
$ head /proc/softirqs

# いまのカーネルで何が有効になっているか
$ zcat /proc/config.gz | grep -E "CONFIG_PREEMPT|IO_URING|BPF_JIT"

14. 注意点

  • 仮想マシンでは見えないものが多い。温度、ファン、周波数、電源はホスト側の管轄になるため、ゲストからは /sys にすら現れない。ハードウェア寄りの検証は物理マシンで行う
  • /proc/sys の内容はカーネルのバージョンで変わる。監視スクリプトがこれらをパースしている場合、カーネルを上げるときに確認が要る
  • 設定の既定値は環境ごとに違う。ディストリビューションもクラウドの提供イメージも独自の調整を入れているので、「Linuxの既定は〜」という言い方は危うい。実機で確認する
  • カーネルパラメータを変える前に、なぜ既定がその値なのかを調べる。多くの値には理由があり、片方を改善すると別の場所が悪化しかねない
  • 性能の問題は層をまたいで現れる。ディスクが遅く見えて、実はメモリ不足だった。ネットワークが遅く見えて、正体はCPUの割り込み偏りだった。こうした話は珍しくない。まず全体図の上でどこを見ているかを確認する

参考

一次情報を中心に挙げる。バージョンや数値は公式のドキュメントで確認してほしい。

Share


See also