OSの層のコスト
この章でわかること:
- システムコールの実コスト(実測: 通常の関数呼び出しの約120倍)と、その理由
- スレッド間で互いを起床させる処理(コンテキストスイッチを含む)の実測(1往復約18マイクロ秒)
- ファイルI/Oの実像: ページキャッシュと、
read対mmap(実測3.9倍) - システムコールを減らすという現代のI/O設計の方向(io_uring)
- なぜ体系に必須か: ここまでの26章分はすべて1つのプロセスの中の 話でした。プログラムはOSの上で動きます。OSとの境界のコストモデルが ないと、I/Oとスレッドの設計判断に根拠を持てません
ユーザー空間とカーネル空間
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では必ずBufReader/BufWriterを使う」
という定石の根拠がこの実験です)。
なお、Instant::now()のような高頻度の操作が数十ナノ秒で
済むのは、カーネルが時刻データをユーザー空間に共有メモリで
公開し、システムコールなしで読ませる仕組み(vDSO)があるためです。
頻繁に呼ばれる操作には境界を越えさせない、という同じ原則の別の適用です。
スレッドの切り替えのコスト: コンテキストスイッチ
スレッドの切り替え(コンテキストスイッチ、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タスクの切り替え(ユーザー空間の関数呼び出し)は 1マイクロ秒未満、スレッドの切り替えは十数マイクロ秒です。 待ちの多重化をカーネルではなくユーザー空間で行う、という asyncランタイムの存在理由が、この差です。 同時に、5章のrayonがスレッドプールを 再利用する理由(スレッド生成76マイクロ秒+切り替えコストの節約)も ここに根拠があります。
ファイル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回目 : 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クレートが
広く使われている、安全性を重視したラッパーです。
システムコールを減らす: 現代のI/O設計の方向
この章の実測はすべて、境界を越えるたびに発生するコストでした。 現代の高性能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タスク(1マイクロ秒未満) との差が、ユーザー空間での並行処理の存在理由です
- ファイルはページキャッシュを介して読まれます。
mmapはコピーを 省き、3.9倍速くなりました(適するのは巨大な読み取り中心のファイルです) - io_uringは、要求をまとめて投入し、完了をまとめて回収することで システムコール自体を減らします。頻繁に越える境界は設計で取り除く、 という本書で繰り返し現れた原則の最後の適用です
最終章では、ここまでの全知識を実務で運用する方法を扱います。 レイテンシの統計、性能の回帰検出、そして本書全体の知識の全体像です。