unsafeと未定義動作とFFI
この章でわかること:
unsafeが正確には何を許可するのか(そして何を許可しないのか)- 未定義動作(UB)の正確な意味 — なぜ「何が起きてもおかしくない」が 文字どおりなのか
- UBが「静かに成功する」ことの実演と、Miriによる機械的検出
- FFI(C関数呼び出し)の実コストの実測と、境界の設計原則
- なぜ体系に必須か — 本書は最適化の最終手段として何度も
unsafeに 言及してきました(4章、7章、 17章)。その最終手段を安全に扱う知識が なければ、最適化の道具箱は完成しません。そしてUBの理解は、 6章のコンパイラ最適化の理解の裏面です
unsafeは何を解除するか
Section titled “unsafeは何を解除するか”unsafeブロックについて最初に正すべき誤解は、
「検査が全部消えるモード」という理解です。実際に解除されるのは、
代表的には次の5つの操作の禁止です。
- 生ポインタ(
*const T/*mut T)の参照外し unsafe fnの呼び出し(FFI関数を含む)- 可変または外部(extern)のstatic変数へのアクセス
unsafe trait(Send/Syncなど)の実装unionのフィールドの読み取り
(このほか、エディション2024ではexternブロックや一部の属性の
宣言自体にunsafeを書く形が導入されるなど、言語の進化とともに
細目は増えています。正確な一覧は言語リファレンスにあります)
借用検査も型検査も、unsafeブロックの中で通常どおり働き続けます。
unsafeの意味は「検査をやめる」ではなく、
「コンパイラが証明できない安全条件を、私が証明した」と署名する
ことです。問題は、その証明を破ったときに何が起きるかです。
未定義動作 — 契約違反の正確な意味
Section titled “未定義動作 — 契約違反の正確な意味”未定義動作(undefined behavior、UB)とは「プログラムが クラッシュする」ことではありません。もっと悪いものです。
6章を思い出してください。コンパイラの
最適化は「コードの意味を保つ変形」でした。ではその「意味」は
何を前提に定義されているか——UBが存在しないことです。
境界チェックの除去(7章)も、
エイリアス前提の並べ替え(6章)も、「範囲外アクセスは起きない」
「&mutは排他だ」という前提の上に成立しています。
UBを起こすコードは、この前提を破ります。前提が偽になった論理からは
任意の結論が導けるように、最適化後のプログラムの挙動には
いっさいの保証がなくなります。
言葉だけでは伝わらないので、実演します。長さ3のVecの
7番目をget_uncheckedで読みます——明白なUBです。
fn main() { let v = vec![10u8, 20, 30]; let i = std::hint::black_box(7usize); // 範囲外の添字 // 検査つきなら panic するが…… let x = unsafe { *v.get_unchecked(i) }; println!("v[{i}] = {x} (?!)");}筆者の実行(Playground)では、クラッシュせず**v[7] = 0と表示して
正常終了**しました。これがUBの最悪の性質です。
爆発するとは限らない。黙って偽の値を返し、動き続ける。
テストは通り、本番のデータやコンパイラの更新で突然発症します。
「動いているからこのunsafeは正しい」という推論が成立しないのです。
Rustで特に踏みやすいUBを列挙しておきます。
- データ競合 — 同期なしの並行読み書き(5章)
- 無効なメモリアクセス — 範囲外、解放済み(use-after-free)、 nullや未整列のポインタの参照外し
- 参照の規則違反 — 生ポインタ経由でも、同じデータへの
&mutの 複数生成や、&越しの書き換えはUBです。参照は「作った瞬間に」 有効・整列・初期化済みを要求します - 未初期化メモリの読み取り — 未初期化領域は
MaybeUninit<T>(「まだ初期化されていないかもしれないT」を型として表す入れ物)で 扱い、初期化が完了する前にTとして触れてはいけません - 不正な値の生成 —
boolに2、存在しないenum判別子、 不正なUTF-8のstrなど。transmuteはこれらの入り口の常連です
Miri — UBの検出器
Section titled “Miri — UBの検出器”「証明した」つもりの安全条件を機械に検査させる道具があります。 Miriは、コンパイラの中間表現 (MIR、6章)を1ステップずつ解釈実行し、 UBを踏んだ瞬間に報告するインタープリタです。先ほどの実験を Miriで実行すると、静かに成功する代わりにこう報告されます。
error: Undefined Behavior: `assume` called with `false` --> src/main.rs:5:23 |5 | let x = unsafe { *v.get_unchecked(i) }; | ^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here使い方はcargo +nightly miri test——テストをMiri上で
走らせるだけです(Playgroundでは画面上部のTools→Miriで試せます)。
参照の排他規則のような繊細な違反も、Stacked Borrowsと呼ばれる
規則モデルで検査してくれます(後継のTree Borrowsモデルも
-Zmiri-tree-borrowsで試せる実験段階にあります)。
限界も知っておきます。Miriは実際に実行したパスしか検査できず
(テストの網羅性がそのまま検査の網羅性です)、FFIの先は追えず、
実行は素のテストより数十〜数百倍遅くなります。それでも、
unsafeを含むコードのテストをMiriでも回すのは、
現代のRust開発の標準的な規律です。
FFI — 他言語との境界
Section titled “FFI — 他言語との境界”unsafeの主要な用途がもう1つあります。
FFI(foreign function interface)——C言語のABI
(application binary interface。呼び出し規約(1章)
やデータ配置など、機械語レベルの取り決めの総称)を介した
他言語との相互運用です。コンパイラはCの関数の中身を検証できないため、
呼び出しは常にunsafeです。
// externブロック = 「この署名の関数がリンク先に存在する」という宣言unsafe extern "C" { fn labs(x: c_long) -> c_long; // Cライブラリの関数(longはOS依存幅)}まず「FFI呼び出しは遅いのか」を実測で確かめます。
Rustの.abs()(インライン化される)と、同じ計算をするCライブラリの
labs(FFI呼び出し)を1億回ずつ実行します。
use std::hint::black_box;use std::time::Instant;
use std::ffi::c_long;
// Cライブラリの関数を直接宣言する(labs = C言語のlongの絶対値)。// Cのlongの幅はOS依存(64bit Linux/macOSでは64bit、Windowsでは32bit)// なので、対応するRust型 c_long を使うunsafe extern "C" { fn labs(x: c_long) -> c_long;}
fn main() { let n = 100_000_000i64;
// (1) Rustの .abs() : インライン化され、ベクトル化もされうる let start = Instant::now(); let mut sum = 0i64; for i in -n / 2..n / 2 { sum = sum.wrapping_add(black_box(i).abs()); } println!("Rust abs : {:>9.3?} (sum={sum})", start.elapsed());
// (2) C関数へのFFI呼び出し: 呼び出し境界を毎回越える let start = Instant::now(); let mut sum = 0i64; for i in -n / 2..n / 2 { sum = sum.wrapping_add(unsafe { labs(black_box(i) as c_long) } as i64); } println!("C labs : {:>9.3?} (sum={sum})", start.elapsed());}筆者の実測(Playground)では約64ミリ秒対約80ミリ秒—— 1億回の呼び出し越しでも差は2割ほどです。この差にはFFIの 呼び出し境界だけでなく、インライン実装とライブラリ実装の 違いも混ざっているため厳密な「1回あたりの値段」は出せませんが、 結論には十分です。FFI呼び出しの機械的なコストは、 呼び出し規約に従う普通の関数呼び出しと同程度—— 1回あたりナノ秒級であり、恐れる場所ではありません。
本当のコストは別の場所にあります。
- 最適化境界 — 6章のとおり、 インライン化も自動ベクトル化も境界で止まります。 「細かい関数を高頻度で呼ぶ」設計が最悪で、 「太い仕事を1回で渡す」のが境界設計の原則です
- データ表現の変換 — Rustの
StringはNUL終端ではないので、 C文字列にはCStringへの変換(確保+コピー)が要ります。 構造体は#[repr(C)](2章)で レイアウトを固定します - 所有権の約束 — 誰が確保し誰が解放するかはABIに現れません。 「Cが返したものはCの解放関数に返す」等の規約を守るのは人間です
- パニックの遮断 — Rustのパニックの巻き戻しをFFI境界の外へ
漏らしてはいけません(
extern "C"関数からの巻き戻しはabortします)。 コールバックをRustで書くときはcatch_unwindで受け止めます
宣言の手書きを避ける道具として、Cヘッダから宣言を生成する bindgen、逆にRustからCヘッダを生成するcbindgenが定番です。
unsafeの運用規律
Section titled “unsafeの運用規律”最後に、unsafeを書くときの規律をまとめます。
これはRustエコシステムでおおむね合意された作法です。
- 範囲を最小に —
unsafeブロックは違反しうる操作だけを包み、 1ブロック1論点にします - SAFETYコメント — なぜ安全なのかの証明をブロックの直上に
書きます(
// SAFETY: i は直前で len 未満と検査済み)。 clippyのundocumented_unsafe_blocksリントで強制できます - 安全な抽象で包む —
unsafeはモジュールの内側に閉じ込め、 外には安全なAPIだけを見せます。Vec自身が内部はunsafeの塊なのに 安全に使える、あの構造を自分のコードでも作るということです。 不変条件(「len <= capacity」など)をモジュール境界で守り切れば、 検査すべき範囲がそのモジュール内に閉じます - Miriで検査する — unsafeを含むテストはMiriでも回します
unsafeは5つの操作の禁止を解除し、安全条件の証明義務を プログラマが引き受ける署名です。検査が消えるわけではありません- UBは「クラッシュ」ではなく最適化前提の崩壊です。実験では 範囲外読みが静かに0を返しました。「動いたから正しい」は成立しません
- MiriはUBを実行時に検出します。unsafeを含むコードの標準装備です
- FFI呼び出し自体は普通の関数呼び出しと同程度でした。実コストは 最適化境界・表現変換・所有権規約・パニック遮断にあります
- 最小範囲、SAFETYコメント、安全な抽象への封じ込めが運用の三原則です
次章は、コードを一切変えずに性能を引き出す最後の領域—— ビルドの制御(PGO、リンカ、コンパイル時間、バイナリサイズ)です。