RustではじめるCPUとGPU

unsafeと未定義動作とFFI

この章でわかること:

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

unsafeは何を解除するか

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の意味は、「コンパイラが証明できない安全条件を、私が証明した」と 宣言することであり、「検査をやめる」ことではありません。問題は、その証明を破ったときに何が起きるかです。

未定義動作: 契約違反の正確な意味

未定義動作(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の検出器

「証明した」つもりの安全条件を機械に検査させるツールがあります。 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: 他言語との境界

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());
}

筆者の実測(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の運用規律

最後に、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、リンカ、コンパイル時間、バイナリサイズ)を扱います。