コンテンツにスキップ

unsafeと未定義動作とFFI

この章でわかること:

  • unsafeが正確には何を許可するのか(そして何を許可しないのか)
  • 未定義動作(UB)の正確な意味 — なぜ「何が起きてもおかしくない」が 文字どおりなのか
  • UBが「静かに成功する」ことの実演と、Miriによる機械的検出
  • FFI(C関数呼び出し)の実コストの実測と、境界の設計原則
  • なぜ体系に必須か — 本書は最適化の最終手段として何度もunsafeに 言及してきました(4章7章17章)。その最終手段を安全に扱う知識が なければ、最適化の道具箱は完成しません。そしてUBの理解は、 6章のコンパイラ最適化の理解の裏面です

unsafeブロックについて最初に正すべき誤解は、 「検査が全部消えるモード」という理解です。実際に解除されるのは、 代表的には次の5つの操作の禁止です。

  1. 生ポインタ(*const T / *mut T)の参照外し
  2. unsafe fnの呼び出し(FFI関数を含む)
  3. 可変または外部(extern)のstatic変数へのアクセス
  4. unsafe trait(Send/Syncなど)の実装
  5. 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} (?!)");
}
stable / releasePlaygroundで開く ↗

筆者の実行(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は、コンパイラの中間表現 (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開発の標準的な規律です。

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億回ずつ実行します。

FFI呼び出しのコストを測る
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());
}
stable / releasePlaygroundで開く ↗

筆者の実測(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を書くときの規律をまとめます。 これは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、リンカ、コンパイル時間、バイナリサイズ)です。