コンテンツにスキップ

OSの層のコスト

この章でわかること:

  • システムコールの実コスト(実測: 通常の関数呼び出しの約120倍)と、その理由
  • スレッド間の起こし合い(コンテキストスイッチを含む)の実測(1往復約18マイクロ秒)
  • ファイルI/Oの実像 — ページキャッシュと、readmmap(実測3.9倍)
  • システムコールを減らすという現代のI/O設計の潮流(io_uring)
  • なぜ体系に必須か — ここまでの26章分はすべて「1つのプロセスの中」の 話でした。プログラムはOSの上で動きます。OSとの境界のコストモデルが ないと、I/Oとスレッドの設計判断に根拠を持てません

OSは、アプリケーションが動くユーザー空間と、カーネルが動く カーネル空間を、CPUの特権レベル機構で分離しています。 ファイルを読む、スレッドを作る、時刻を知る——ハードウェアや 他プロセスに関わる操作はすべて、システムコール(system call)という 「保護された関数呼び出し」でカーネルに依頼します。

保護には値段がつきます。実測しましょう。何もしないに等しい システムコール(getpid)と、通常の関数呼び出しを比べます。

関数呼び出し vs システムコール
use std::hint::black_box;
use std::time::Instant;
unsafe extern "C" {
fn getpid() -> i32;
}
#[inline(never)]
fn plain_function(x: i32) -> i32 {
black_box(x + 1)
}
fn main() {
let n = 5_000_000;
let start = Instant::now();
let mut acc = 0i64;
for i in 0..n {
acc += plain_function(i) as i64;
}
let t = start.elapsed();
println!(
"通常の関数呼び出し: {t:>9.3?} ({:5.1}ns/回, acc={acc})",
t.as_nanos() as f64 / n as f64
);
let start = Instant::now();
let mut acc = 0i64;
for _ in 0..n {
acc += unsafe { getpid() } as i64;
}
let t = start.elapsed();
println!(
"getpidシステムコール: {t:>7.3?} ({:5.1}ns/回, acc={acc})",
t.as_nanos() as f64 / n as f64
);
}
stable / releasePlaygroundで開く ↗

筆者の実測(Playground)では、関数呼び出しが1.9ナノ秒、 getpid222.6ナノ秒——約120倍です。

差の内訳は、特権レベルの遷移(専用命令での往復)、レジスタの 退避と復元、そして近年はCPU脆弱性の緩和策(カーネルとユーザーの ページテーブル分離など、14章の 機構に絡む対策)です。さらに目に見えないコストとして、 カーネルコードの実行がキャッシュとTLBを汚す (2章14章) ため、戻ってきた後のユーザーコードもしばらく遅くなります。

この数字から、I/O設計の第一原則が出てきます——バッチ化。 1バイトずつwriteすれば1バイトに222ナノ秒払いますが、 64KBまとめれば1バイトあたり0.003ナノ秒です。Rustの BufReader/BufWriterは、まさにこのバッチ化を自動で行う ラッパーです(「ファイルI/Oは必ずBufを挟め」という慣用句の 根拠がこの実験です)。

なお、Instant::now()のような超高頻度の操作が数十ナノ秒で 済むのは、カーネルが時刻データをユーザー空間に共有メモリで 公開し、システムコールなしで読ませる仕組み(vDSO)のおかげです。 「よく呼ぶものは境界を越えさせない」——同じ原則の別解です。

スレッドを起こし合うコスト — コンテキストスイッチ

Section titled “スレッドを起こし合うコスト — コンテキストスイッチ”

スレッドの切り替え(コンテキストスイッチ、context switch)は システムコールのさらに上位のコストです。2本のスレッドが チャネルで値を打ち返し合う実験で測ります。1往復には最低2回の 「相手を起こして自分は眠る」が含まれます。

スレッド間の1往復はいくらか
use std::sync::mpsc;
use std::time::Instant;
fn main() {
// 2スレッドがチャネルで打ち返し合う。1往復 = 2回のスレッド起床
let rounds = 100_000;
let (tx1, rx1) = mpsc::channel::<u64>();
let (tx2, rx2) = mpsc::channel::<u64>();
let handle = std::thread::spawn(move || {
for _ in 0..rounds {
let v = rx1.recv().unwrap();
tx2.send(v + 1).unwrap();
}
});
let start = Instant::now();
let mut v = 0u64;
for _ in 0..rounds {
tx1.send(v).unwrap();
v = rx2.recv().unwrap();
}
let t = start.elapsed();
handle.join().unwrap();
println!(
"{rounds}往復: {t:?} (1往復あたり {:5.2}µs, v={v})",
t.as_nanos() as f64 / rounds as f64 / 1000.0
);
}
stable / releasePlaygroundで開く ↗

筆者の実測(Playground、2コア)では1往復あたり約18マイクロ秒でした。 正確に言うと、これは「切り替え1回の単価」ではなく、チャネルの 送受信・カーネルによる起床(futexシステムコール)・スケジューラの 実行・(コアが埋まっていれば)レジスタとスタックの切り替え、 そしてキャッシュとTLBの再ウォームアップまでを含む、 スレッド間の起こし合い1往復の総額です。実務で払うのも まさにこの総額なので、設計判断にはこの数字を使えます。

この数字を19章の隣に置いてください。 asyncタスクの切り替え(ユーザー空間の関数呼び出し)は サブマイクロ秒、スレッドの切り替えは十数マイクロ秒。 「待ちの多重化はカーネルにやらせず、ユーザー空間でやる」という asyncランタイムの存在理由が、この差そのものです。 同時に、5章のrayonがスレッドプールを 使い回す理由(スレッド生成76マイクロ秒+切り替えコストの節約)も ここに根拠があります。

ファイルI/Oとページキャッシュ

Section titled “ファイルI/Oとページキャッシュ”

ファイルの読み書きの実像は、「ディスクとのやり取り」では ありません。OSは読み書きされたファイルの中身をメモリに キャッシュします(ページキャッシュ、page cache。 14章のページ機構の応用です)。 2回目以降のアクセスはディスクに触れず、メモリ速度で返ります。

では、キャッシュに載っているファイルを読む最速の方法は何か。 64MBのファイルで、通常のfs::readと、 14章で紹介したmmap (ファイルをアドレス空間に貼る)を比べます。mmapの呼び出しは 20章のFFIの実践でもあります。

fs::read vs mmap (どちらもキャッシュ済み)
use std::fs;
use std::time::Instant;
fn main() {
// 64MBのファイルを用意する(プロセスIDつきの一時ファイル名にする)
let path = std::env::temp_dir().join(format!("book_io_{}.bin", std::process::id()));
let path = path.as_path();
let size = 64 * 1024 * 1024usize;
let data: Vec<u8> = (0..size).map(|i| (i % 251) as u8).collect();
let start = Instant::now();
fs::write(path, &data).unwrap();
println!("書き込み(64MB) : {:>9.3?}", start.elapsed());
drop(data);
// (1) fs::read で全体を読む(カーネル→ユーザー空間へのコピー)
for round in 1..=2 {
let start = Instant::now();
let buf = fs::read(path).unwrap();
let sum: u64 = buf.iter().map(|&b| b as u64).sum();
println!(
"fs::read {round}回目 : {:>9.3?} (sum={sum})",
start.elapsed()
);
}
// (2) mmap でアドレス空間に貼って読む(コピーなし、14章の実践)
use std::os::fd::AsRawFd;
let file = fs::File::open(path).unwrap();
assert_eq!(file.metadata().unwrap().len() as usize, size);
let start = Instant::now();
let ptr = unsafe {
libc::mmap(
std::ptr::null_mut(),
size,
libc::PROT_READ,
libc::MAP_PRIVATE,
file.as_raw_fd(),
0,
)
};
assert!(ptr != libc::MAP_FAILED);
// SAFETY: mmapが成功し、size バイトが読み取り可能
let mapped: &[u8] = unsafe { std::slice::from_raw_parts(ptr as *const u8, size) };
let sum: u64 = mapped.iter().map(|&b| b as u64).sum();
println!("mmap + 走査 : {:>9.3?} (sum={sum})", start.elapsed());
let rc = unsafe { libc::munmap(ptr, size) };
assert_eq!(rc, 0);
fs::remove_file(path).unwrap();
}
stable / releasePlaygroundで開く ↗

筆者の実測(Playground)です。

書き込み(64MB) : 92ms (ページキャッシュへの受け付け時間。ディスクへの反映は非同期)
fs::read 1回目 : 59ms
fs::read 2回目 : 59ms
mmap + 走査 : 15ms (3.9倍)

fs::readの59ミリ秒の内訳は、64MBのヒープ確保とその初回タッチ (18章+14章の ページフォールト)、そしてページキャッシュからユーザーのバッファへの コピーです。mmapはページキャッシュのページをそのまま アドレス空間に見せるため、バッファの確保もコピーも起きません (代わりにファイル由来のページフォールトはあります)。 3.9倍の差は、これらの合算の差です。

ただしmmapは万能ではありません。アクセスのたびに ページフォールト(14章)が潜在し、I/Oエラーが戻り値ではなく シグナル(SIGBUS)で届き、小さいファイルでは設定コストが勝ちます。 「巨大な読み取り中心のファイル」が適地です。Rustでは memmap2クレートが 定番の安全寄りラッパーです。

システムコールを減らす — 現代の潮流

Section titled “システムコールを減らす — 現代の潮流”

この章の実測はすべて「境界を越えるたびに払う」コストでした。 現代の高性能I/Oの潮流は、この境界越え自体を消しにかかっています。

代表がLinuxのio_uringです。アプリケーションとカーネルが 2本のリングバッファ(提出用と完了用)を共有メモリに持ち、 多数のI/O要求の投入と完了の回収を、少数のシステムコールに まとめて行います(専用スレッドにカーネル側で監視させる設定なら、 提出のシステムコール自体をほぼゼロにもできます)。24章で見た GPUのコマンドキューと同じ設計です——「1件ずつ依頼して待つ」から 「積んでおいて、まとめて回収する」へ。Rustからは tokio-uringやglommioといったランタイムが利用します。

さらに極端な世界(カーネルバイパス、DPDKなど)もありますが、 方向性は1つです。頻繁に越える境界は、設計で消す。 関数呼び出し(インライン化、6章)から、 FFI(20章)、GPU(24章)、 そしてカーネルまで——本書で繰り返し現れた同じ原則です。

  • システムコールは関数呼び出しの約120倍(実測222ナノ秒)。 対策はバッチ化で、BufReader/BufWriterはその自動化です
  • スレッドの切り替えは1往復約18マイクロ秒。asyncタスク(サブマイクロ秒) との差が、ユーザー空間並行処理の存在理由です
  • ファイルはページキャッシュ越しに読まれます。mmapはコピーを 消して3.9倍でした(適地は巨大な読み取り中心ファイル)
  • io_uringは「積んで、まとめて回収」でシステムコール自体を消します。 頻繁に越える境界は設計で消す——本書を貫く原則の最後の適用です

最終章では、ここまでの全知識を「実務で運用する方法」に束ねます。 レイテンシの統計、性能の回帰検出、そして本書全体の知識の地図です。