OSの層のコスト
この章でわかること:
- システムコールの実コスト(実測: 通常の関数呼び出しの約120倍)と、その理由
- スレッド間の起こし合い(コンテキストスイッチを含む)の実測(1往復約18マイクロ秒)
- ファイルI/Oの実像 — ページキャッシュと、
read対mmap(実測3.9倍) - システムコールを減らすという現代のI/O設計の潮流(io_uring)
- なぜ体系に必須か — ここまでの26章分はすべて「1つのプロセスの中」の 話でした。プログラムはOSの上で動きます。OSとの境界のコストモデルが ないと、I/Oとスレッドの設計判断に根拠を持てません
ユーザー空間とカーネル空間
Section titled “ユーザー空間とカーネル空間”OSは、アプリケーションが動くユーザー空間と、カーネルが動く カーネル空間を、CPUの特権レベル機構で分離しています。 ファイルを読む、スレッドを作る、時刻を知る——ハードウェアや 他プロセスに関わる操作はすべて、システムコール(system call)という 「保護された関数呼び出し」でカーネルに依頼します。
保護には値段がつきます。実測しましょう。何もしないに等しい
システムコール(getpid)と、通常の関数呼び出しを比べます。
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 );}筆者の実測(Playground)では、関数呼び出しが1.9ナノ秒、
getpidが222.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回の 「相手を起こして自分は眠る」が含まれます。
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 );}筆者の実測(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の実践でもあります。
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();}筆者の実測(Playground)です。
書き込み(64MB) : 92ms (ページキャッシュへの受け付け時間。ディスクへの反映は非同期)fs::read 1回目 : 59msfs::read 2回目 : 59msmmap + 走査 : 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は「積んで、まとめて回収」でシステムコール自体を消します。 頻繁に越える境界は設計で消す——本書を貫く原則の最後の適用です
最終章では、ここまでの全知識を「実務で運用する方法」に束ねます。 レイテンシの統計、性能の回帰検出、そして本書全体の知識の地図です。